Compare commits

...
Author SHA1 Message Date
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
119 changed files with 17419 additions and 90 deletions
+113 -1
View File
@@ -1,5 +1,115 @@
# Changelog
## 0.1.0-alpha.17 — 2026-09-09
- Prototype de trois réseaux oubliés sur l’île initiale : mines et ateliers,
salles étranges, infrastructures abandonnées. Accès depuis la surface,
couloirs et embranchements, outils vanilla utilisables, mobilier et traces de
chantier. Placement borné, hors ville et eaux protégées, avec appuis vérifiés.
- Option FR/EN « Structures expérimentales » dans Personnaliser, enregistrée
avec la taille du nouveau monde. Les connexions entre réseaux, le réacteur,
la progression et les reliques ne sont pas encore implémentés.
- Codec `sanctuary:island_v17`, pièces natives indépendantes, journal 17.
Les mondes 16 et antérieurs conservent leurs plans et futurs chunks.
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** :
@@ -21,7 +131,9 @@
`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. La distribution est en préparation.
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)
+149 -7
View File
@@ -13,12 +13,150 @@ 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
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.13 réunit l’île
initiale et les outils dexpansion dans un seul choix de monde Sanctuary.
Les trois tailles et linterface native sont vérifiées. La distribution est en
préparation ; les versions précédentes restent décrites dans les sections historiques.
décrits dans la vision ne sont pas encore implémentés. **Lalpha.17 est publiée** :
trois familles de réseaux oubliés expérimentaux rejoignent la ville et ses grands
sous-sols sur les nouvelles îles de départ. Le canal packwiz et la même instance
Prism sont à jour ; voir [Distribution](docs/packwiz.md).
## 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
@@ -53,6 +191,10 @@ dexpansion/rechargement passent. Moyen atteint 63,66 secondes pour la créati
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
- Mod `sanctuary` indépendant et pack Sanctuary construit avec packwiz.
@@ -267,7 +409,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.13, en préparation |
| Sanctuary / pack | 0.1.0-alpha.17 |
Java 25 et Python 3.11 ou plus récent sont nécessaires. Le script pack utilise
uniquement la bibliothèque standard et repère aussi une installation Python
@@ -296,7 +438,7 @@ décrits dans [Validation](docs/testing.md).
Résultats :
- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.13.jar` : mod à installer avec
- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.17.jar` : mod à installer avec
Fabric API sur la version Minecraft indiquée.
- `build/packwiz/` : pack de développement complet, avec le mod construit et
l'index vérifié. Voir [Installation packwiz](packwiz/README.md).
+36 -3
View File
@@ -1,7 +1,7 @@
# Alpha.13 — un monde Sanctuary unifié
**Implémentée et vérifiée localement.** Cette version réunit l’île de départ complète et les outils
dexpansion du laboratoire. La distribution est en préparation ;
**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.
@@ -120,6 +120,37 @@ temples, cabanes de marais et camps abandonnés dépendent du biome, du diamètr
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
@@ -176,4 +207,6 @@ 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 procédure de distribution reste à terminer.
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).
+211 -1
View File
@@ -503,10 +503,220 @@ deux fois, avec 466 fichiers personnels et réglages suivis conservés.
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. Publication et synchronisation en préparation.
`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.
## 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.
+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."
}
+198 -1
View File
@@ -13,10 +13,207 @@ 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.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 utilise 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 utilise cette
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 :
+164
View File
@@ -0,0 +1,164 @@
# 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).
## 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.
+74
View File
@@ -19,6 +19,80 @@ 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
+65 -2
View File
@@ -28,6 +28,11 @@ Le premier incrément porte sur le socle Fabric et la génération du monde : re
La progression, l'économie, les nouveaux monstres, les interfaces de pack et les autres dimensions restent des intentions futures. Ils ne constituent pas des dépendances du premier générateur. Le [backlog](backlog.md) précise les premiers tickets et leurs critères d'acceptation.
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é |
@@ -170,9 +175,67 @@ L'ambition est de reprendre puis d'étendre la centaine de biomes du catalogue T
biome, leur emprise et leurs fondations conviennent au terrain flottant.
Lautorisation ne garantit pas leur présence sur chaque île. Le laboratoire
commence par les villages, temples, cabanes de marais et camps abandonnés.
- **Lost Cities** : des zones traitées comme des biomes, où des bâtiments en ruine remplacent la végétation dominante. Le nom exprime ici un type de territoire souhaité, sans décider encore s'il sera produit par une intégration ou par du code Sanctuary.
- **Structures contemporaines** : parcs, parkings et autres vestiges. Les parkings sont souterrains et peuvent s'étendre comme une infestation. Ils n'apparaissent pas dans la dimension des cavernes.
- **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
+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.13
pack_version=0.1.0-alpha.13
mod_version=0.1.0-alpha.17
pack_version=0.1.0-alpha.17
maven_group=fr.koka.sanctuary
+142 -1
View File
@@ -39,8 +39,18 @@ tasks.named('runGameTest') {
systemProperty('sanctuary.test.densityMaps', providers.gradleProperty('sanctuaryDensityMaps').getOrElse('true'))
systemProperty('sanctuary.test.unified', providers.gradleProperty('sanctuaryUnifiedTests').getOrElse('true'))
systemProperty('sanctuary.test.diameter', providers.gradleProperty('sanctuaryTestDiameter').getOrElse('724'))
systemProperty('sanctuary.test.generation', providers.gradleProperty('sanctuaryTestGeneration').getOrElse(
providers.gradleProperty('sanctuaryLostCityTests').map { it.toBoolean() }.getOrElse(false) ? '14' : providers.gradleProperty('sanctuaryLostCity15Tests').map { it.toBoolean() }.getOrElse(false) ? '15' : providers.gradleProperty('sanctuaryLostCity16Tests').map { it.toBoolean() }.getOrElse(false) ? '16' : '17'))
systemProperty('sanctuary.test.expansion', providers.gradleProperty('sanctuaryExpansionTests').getOrElse('false'))
systemProperty('sanctuary.test.expansionReload', providers.gradleProperty('sanctuaryExpansionReload').getOrElse('false'))
systemProperty('sanctuary.test.lostCity', providers.gradleProperty('sanctuaryLostCityTests').getOrElse('false'))
systemProperty('sanctuary.test.lostCity15', providers.gradleProperty('sanctuaryLostCity15Tests').getOrElse('false'))
systemProperty('sanctuary.test.lostCity16', providers.gradleProperty('sanctuaryLostCity16Tests').getOrElse('false'))
systemProperty('sanctuary.test.ruins17', providers.gradleProperty('sanctuaryRuins17Tests').getOrElse('false'))
systemProperty('sanctuary.test.experimentalStructures', providers.gradleProperty('sanctuaryExperimentalStructures').getOrElse('true'))
systemProperty('sanctuary.test.cityMatrix', providers.gradleProperty('sanctuaryCityMatrix').getOrElse('false'))
systemProperty('sanctuary.test.cityMatrixOnly', providers.gradleProperty('sanctuaryCityMatrixOnly').getOrElse('false'))
systemProperty('sanctuary.test.voidReuse', providers.gradleProperty('sanctuaryVoidReuseTests').getOrElse('false'))
systemProperty('sanctuary.test.optionalStructureWitness', providers.gradleProperty('sanctuaryOptionalStructureWitness').getOrElse('false'))
}
@@ -51,8 +61,18 @@ tasks.named('processGametestResources') {
inputs.property('unified', unified)
def expansion = providers.gradleProperty('sanctuaryExpansionTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('expansion', expansion)
def lostCity = providers.gradleProperty('sanctuaryLostCityTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('lostCity', lostCity)
def lostCity15 = providers.gradleProperty('sanctuaryLostCity15Tests').map { it.toBoolean() }.getOrElse(false)
inputs.property('lostCity15', lostCity15)
def lostCity16 = providers.gradleProperty('sanctuaryLostCity16Tests').map { it.toBoolean() }.getOrElse(false)
inputs.property('lostCity16', lostCity16)
def ruins17 = providers.gradleProperty('sanctuaryRuins17Tests').map { it.toBoolean() }.getOrElse(false)
inputs.property('ruins17', ruins17)
def voidReuse = providers.gradleProperty('sanctuaryVoidReuseTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('voidReuse', voidReuse)
filesMatching('fabric.mod.json') {
expand(gametest_entrypoint: expansion ? 'fr.koka.sanctuary.gametest.ExpansionWorldGameTests' : benchmark
expand(gametest_entrypoint: ruins17 ? 'fr.koka.sanctuary.gametest.Ruins17WorldGameTests' : voidReuse ? 'fr.koka.sanctuary.gametest.ExpansionVoidReuseGameTests' : lostCity16 ? 'fr.koka.sanctuary.gametest.LostCity16WorldGameTests' : lostCity15 ? 'fr.koka.sanctuary.gametest.LostCity15WorldGameTests' : lostCity ? 'fr.koka.sanctuary.gametest.LostCityWorldGameTests' : expansion ? 'fr.koka.sanctuary.gametest.ExpansionWorldGameTests' : benchmark
? 'fr.koka.sanctuary.gametest.WorldgenPerformanceGameTests'
: unified ? 'fr.koka.sanctuary.gametest.UnifiedWorldGameTests'
: 'fr.koka.sanctuary.gametest.SanctuaryWorldGameTests')
@@ -274,3 +294,124 @@ tasks.register('expansionPlacementSmoke', JavaExec) {
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) }
}
@@ -117,10 +117,10 @@ public final class ExpansionCommandChecks {
helper.assertTrue(session.islands().equals(before), "Rejecting an unsupported diameter must preserve the registry");
boolean outsideRangeRejected = false;
try { dispatcher.execute("sanctuary expansion create command_out_of_range sanctuary east boreal 513 natural", console); }
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 512 before changing the registry");
"The parser must reject a diameter above 1024 before changing the registry");
} catch (CommandSyntaxException exception) {
throw new IOException("Registered expansion command checks failed", exception);
}
@@ -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,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); }
}
}
@@ -60,8 +60,9 @@ public final class PopulationDiagnostics {
}
if (level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator) {
helper.assertTrue(generator.getBiomeSource() instanceof SanctuaryBiomeSource
&& generator.getBiomeSource().possibleBiomes().size() == 44,
"The unified generator must retain Population's ten layers and the 34 native expansion biomes");
&& 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 {
@@ -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); }
}
}
@@ -33,7 +33,20 @@ final class UnifiedWorldSettingsDiagnostics {
var standard = publicPreset.createWorldDimensions();
helper.assertTrue(standard.overworld() instanceof SanctuaryChunkGenerator,
"The sole Sanctuary world type must use the unified generator");
var historical = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v13"))).value().createWorldDimensions();
helper.assertTrue(historical.overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 13 && !old.source().citiesEnabled(),
"Historical alpha.13 codec retains its original biome source and generation");
var historical14 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v14"))).value().createWorldDimensions();
helper.assertTrue(historical14.overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 14 && old.source().generation() == 14,
"Historical alpha.14 keeps its own biome codec and city generator");
var historical15 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v15"))).value().createWorldDimensions();
helper.assertTrue(historical15.overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 15 && old.source().generation() == 15,
"Historical alpha.15 keeps its own biome codec and city generator");
var historical16 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v16"))).value().createWorldDimensions();
helper.assertTrue(historical16.overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 16
&& old.source().generation() == 16 && !old.experimentalStructures(), "Historical16 never gains experimental structures");
var generator = (SanctuaryChunkGenerator) standard.overworld();
helper.assertTrue(generator.rootGeneration() == 17 && generator.source().generation() == 17 && generator.experimentalStructures(), "The public preset uses generation17 with its new-world option");
helper.assertTrue(generator.initialDiameter() == 724, "The public preset defaults to Medium / 724 blocks");
for (var tag : List.of(WorldPresetTags.NORMAL, WorldPresetTags.EXTENDED)) {
var visible = presets.getOrThrow(tag).stream().map(holder -> holder.getRegisteredName())
@@ -43,9 +56,15 @@ final class UnifiedWorldSettingsDiagnostics {
}
var ops = RegistryOps.create(JsonOps.INSTANCE, access);
var serialized = SanctuaryChunkGenerator.CODEC.codec().encodeStart(ops, generator).getOrThrow().getAsJsonObject();
var activeCodec = generator.rootGeneration() == 17 ? SanctuaryChunkGenerator.RUINS17_CODEC : generator.rootGeneration() == 16 ? SanctuaryChunkGenerator.CITY16_CODEC : generator.rootGeneration() == 15 ? SanctuaryChunkGenerator.CITY15_CODEC : generator.rootGeneration() == 14 ? SanctuaryChunkGenerator.CITY_CODEC : SanctuaryChunkGenerator.CODEC;
var serialized = activeCodec.codec().encodeStart(ops, generator).getOrThrow().getAsJsonObject();
serialized.remove("experimental_structures");
helper.assertTrue(activeCodec.codec().parse(ops, serialized).getOrThrow().experimentalStructures(),
"Generation17 omitted experimental option defaults to enabled");
helper.assertTrue(!((SanctuaryChunkGenerator) historical16.overworld()).withExperimentalStructures(true).experimentalStructures(),
"The new-world switch cannot activate experimental generation in a historical16 generator");
serialized.remove("initial_diameter");
helper.assertTrue(SanctuaryChunkGenerator.CODEC.codec().parse(ops, serialized).getOrThrow().initialDiameter() == 724,
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(),
@@ -56,8 +75,8 @@ final class UnifiedWorldSettingsDiagnostics {
Files.createDirectories(directory);
var storage = LevelStorageSource.createDefault(directory);
List<Map<String, Object>> results = new ArrayList<>();
for (int diameter : new int[]{512, 724, 1024}) {
var chosen = generator.withInitialDiameter(diameter);
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);
@@ -66,17 +85,17 @@ final class UnifiedWorldSettingsDiagnostics {
"Customizing the island preserves the other dimensions");
}
var original = new WorldGenSettings(new WorldOptions(helper.getLevel().getSeed(), false, true), dimensions);
String name = "diameter-" + diameter;
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.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, "disk_round_trip", true));
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(
@@ -21,7 +21,8 @@ public abstract class SanctuaryGameTestServerMixin {
// generation test's deadline before its IO/workers can complete. This is test-only pacing.
@Inject(method = "waitUntilNextTick", at = @At("RETURN"))
private void sanctuary$paceExpansionWorkers(CallbackInfo ci) {
if (Boolean.getBoolean("sanctuary.test.expansion")) {
if (Boolean.getBoolean("sanctuary.test.ruins17") || Boolean.getBoolean("sanctuary.test.expansion") || Boolean.getBoolean("sanctuary.test.lostCity")
|| Boolean.getBoolean("sanctuary.test.lostCity16") || Boolean.getBoolean("sanctuary.test.lostCity15") || Boolean.getBoolean("sanctuary.test.voidReuse")) {
long until = System.nanoTime() + 20_000_000L;
long remaining;
// Worker completion unparks the server; keep the pacing deadline despite those signals.
@@ -44,7 +45,11 @@ public abstract class SanctuaryGameTestServerMixin {
@Redirect(method = "lambda$create$1", at = @At(value = "FIELD", target =
"Lnet/minecraft/world/level/levelgen/presets/WorldPresets;FLAT_ALL_DIMENSIONS:Lnet/minecraft/resources/ResourceKey;"))
private static ResourceKey<WorldPreset> sanctuary$selectActualPreset() {
if (Boolean.getBoolean("sanctuary.test.unified")) return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary"));
if (Boolean.getBoolean("sanctuary.test.unified")) {
int generation = Integer.getInteger("sanctuary.test.generation", 17);
if (generation != 13 && generation != 14 && generation != 15 && generation != 16 && generation != 17) throw new IllegalArgumentException("Unsupported test generation");
return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id(generation == 17 ? "sanctuary" : "sanctuary_v" + generation));
}
if (Boolean.getBoolean("sanctuary.test.expansion")) return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("expansion_lab"));
int players = Integer.getInteger("sanctuary.test.players", 10);
String preset = switch (players) {
@@ -65,7 +70,8 @@ public abstract class SanctuaryGameTestServerMixin {
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)));
generator.withInitialDiameter(Integer.getInteger("sanctuary.test.diameter", 724))
.withExperimentalStructures(Boolean.parseBoolean(System.getProperty("sanctuary.test.experimentalStructures", "true"))));
}
return dimensions;
}
@@ -51,6 +51,16 @@ public final class SanctuaryMod implements ModInitializer {
Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("main_island"), MainIslandDensity.CODEC);
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v13"), SanctuaryChunkGenerator.CODEC);
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v13"), SanctuaryBiomeSource.CODEC);
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v14"), SanctuaryChunkGenerator.CITY_CODEC);
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v14"), SanctuaryBiomeSource.CITY_CODEC);
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v15"), SanctuaryChunkGenerator.CITY15_CODEC);
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v15"), SanctuaryBiomeSource.CITY15_CODEC);
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v16"), SanctuaryChunkGenerator.CITY16_CODEC);
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v16"), SanctuaryBiomeSource.CITY16_CODEC);
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v17"), SanctuaryChunkGenerator.RUINS17_CODEC);
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v17"), SanctuaryBiomeSource.RUINS17_CODEC);
fr.koka.sanctuary.worldgen.city.LostCityStructures.register();
fr.koka.sanctuary.worldgen.ruins.RuinsStructures17.register();
Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("population_island"), PopulationIslandDensity.CODEC);
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("population_island"), PopulationIslandBiomeSource.CODEC);
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("population_groves"), PopulationGrovesFeature.CODEC);
@@ -12,12 +12,13 @@ import net.minecraft.client.gui.screens.worldselection.WorldCreationContext;
import net.minecraft.network.chat.CommonComponents;
import net.minecraft.network.chat.Component;
/** Edits only the pending new world's island size. Cancel never changes its dimensions. */
/** 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) {
@@ -28,6 +29,7 @@ public final class SanctuaryWorldOptionsScreen extends Screen {
}
generator = sanctuary;
selectedSize = IslandSize.fromDiameter(generator.initialDiameter());
experimental = generator.experimentalStructures();
}
@Override
@@ -49,6 +51,13 @@ public final class SanctuaryWorldOptionsScreen extends Screen {
contents.addChild(sizeButton);
diameterHint = contents.addChild(new MultiLineTextWidget(diameterHint(), font)
.setMaxWidth(310).setCentered(true));
if (generator.rootGeneration() == 17) {
contents.addChild(CycleButton.builder((Boolean enabled) -> enabled ? CommonComponents.OPTION_ON : CommonComponents.OPTION_OFF, experimental)
.withValues(true, false).create(0, 0, 280, 20, Component.translatable("sanctuary.world_options.experimental"),
(button, value) -> experimental = value));
contents.addChild(new MultiLineTextWidget(Component.translatable("sanctuary.world_options.experimental_hint"), font)
.setMaxWidth(310).setCentered(true));
}
layout.addToContents(contents);
LinearLayout actions = LinearLayout.horizontal().spacing(8);
@@ -65,7 +74,7 @@ public final class SanctuaryWorldOptionsScreen extends Screen {
private void applyAndClose() {
parent.getUiState().updateDimensions((registries, dimensions) -> dimensions.replaceOverworldGenerator(
registries, generator.withInitialDiameter(selectedSize.diameter)));
registries, generator.withInitialDiameter(selectedSize.diameter).withExperimentalStructures(experimental)));
onClose();
}
@@ -79,11 +79,13 @@ public final class ExpansionAdmission {
int radius = island.reservedRadius();
int minX = Math.floorDiv(island.centerX() - radius, 16), maxX = Math.floorDiv(island.centerX() + radius, 16);
int minZ = Math.floorDiv(island.centerZ() - radius, 16), maxZ = Math.floorDiv(island.centerZ() + radius, 16);
if ((long) (maxX - minX + 1) * (maxZ - minZ + 1) > MAX_CHUNKS) throw new IOException("Expansion admission exceeds 4000 chunks");
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++) positions.add(new ChunkPos(x, z));
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;
}
@@ -93,7 +93,7 @@ public class ExpansionBiomeSource extends BiomeSource {
}
String selected = switch (island.climate()) {
case "temperate" -> Math.abs(variation) < 0.13 ? "plains"
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";
@@ -120,6 +120,8 @@ public class ExpansionChunkGenerator extends ChunkGenerator {
}
@Override public void createStructures(RegistryAccess registries, ChunkGeneratorStructureState state, StructureManager structures,
ChunkAccess chunk, StructureTemplateManager templates, ResourceKey<Level> dimension) {
fr.koka.sanctuary.worldgen.city.LostCityStructures.generate(this, registries, state, structures, chunk, templates, dimension);
fr.koka.sanctuary.worldgen.ruins.RuinsStructures17.generate(this, registries, state, structures, chunk, templates, dimension);
ExpansionStructures.generate(this, registries, state, structures, chunk, templates, dimension);
}
@Override public void spawnOriginalMobs(WorldGenRegion region) {
@@ -48,12 +48,12 @@ public final class ExpansionCommands {
.then(createTree("create", false)).then(createTree("preview", true))
.then(Commands.literal("quick").then(Commands.argument("direction", StringArgumentType.word())
.suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.DIRECTIONS, b))
.executes(c -> quick(c, null, 128, "natural"))
.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"), 128, "natural"))
.then(Commands.argument("diameter", IntegerArgumentType.integer(64, 512))
.suggests((c, b) -> SharedSuggestionProvider.suggest(List.of("64", "128", "256", "512"), b))
.executes(c -> quick(c, word(c, "climate"), 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))
@@ -78,8 +78,8 @@ public final class ExpansionCommands {
})
.then(Commands.argument("direction", StringArgumentType.word()).suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.DIRECTIONS, b))
.then(Commands.argument("climate", StringArgumentType.word()).suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.CLIMATES, b))
.then(Commands.argument("diameter", IntegerArgumentType.integer(64, 512))
.suggests((c, b) -> SharedSuggestionProvider.suggest(List.of("64", "128", "256", "512"), b))
.then(Commands.argument("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"))))))))));
@@ -113,8 +113,11 @@ public final class ExpansionCommands {
}
String direction = word(c, "direction");
if (!ExpansionIsland.DIRECTIONS.contains(direction)) throw new IllegalArgumentException("invalid_direction");
var island = session.create("island_" + next, "sanctuary", direction,
climate == null ? ExpansionIsland.directionalClimate(direction) : climate, diameter, relief, minimumDistance);
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;
});
@@ -122,6 +125,8 @@ public final class ExpansionCommands {
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);
@@ -6,12 +6,13 @@ 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) {
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");
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> DIAMETERS = List.of(64, 128, 256, 512);
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(
@@ -25,15 +26,24 @@ public record ExpansionIsland(String id, String parent, String direction, String
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.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 (!(DIAMETERS.contains(diameter) || (id.equals("sanctuary") && (diameter == 724 || generation == 13 && diameter == 1024)))) throw new IllegalArgumentException("Unsupported nominal diameter");
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 && !(generation == 13 && id.equals("sanctuary")))) throw new IllegalArgumentException("Unsupported island state or generation");
if (!List.of("reserved", "ready").contains(status) || (generation != GENERATION && !spatialGeneration(generation) && !(generation == 13 && id.equals("sanctuary")))) throw new IllegalArgumentException("Unsupported island state or generation");
if (climate.equals("lost_city") && (!spatialGeneration(generation) || id.equals("sanctuary"))) throw new IllegalArgumentException("Lost City requires a generation 14, 15, 16 or 17 expansion");
if (reuseToken == null || !reuseToken.isEmpty() && (!spatialGeneration(generation) || id.equals("sanctuary") || !reuseToken.matches("[0-9a-f]{64}")))
throw new IllegalArgumentException("Invalid void reuse certificate token");
}
public static ExpansionIsland origin(int diameter, long seed) {
@@ -43,12 +53,13 @@ public record ExpansionIsland(String id, String parent, String direction, String
return new ExpansionIsland("sanctuary", "sanctuary", "north", "temperate", diameter, 0, 0, seed, "natural", "ready", generation);
}
public static boolean spatialGeneration(int generation) { return generation == 14 || generation == 15 || generation == 16 || generation == 17; }
public boolean origin() { return id.equals("sanctuary"); }
public double referenceDiameter() { return origin() && diameter == 724 ? Math.sqrt(2) * 512 : 512; }
public double horizontalScale() { return origin() ? 1.0 : diameter / referenceDiameter(); }
public double radius() { return diameter / 2.0; }
public int terrainRadius() { if (generation == 13 && origin()) return diameter == 724 ? 395 : diameter / 2 + 32; return origin() && diameter == 724 ? 395 : (int) Math.ceil(radius() * 1.125); }
public int reservedRadius() { return terrainRadius() + DECORATION_MARGIN + GENERATION_HALO; }
public 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);
@@ -56,10 +67,12 @@ public record ExpansionIsland(String id, String parent, String direction, String
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); }
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";
@@ -40,6 +40,7 @@ public final class ExpansionJournal {
private final long seed;
private final int rootDiameter;
private final int rootGeneration;
private final DirectorySync directorySync;
private List<ExpansionIsland> islands;
private boolean failed;
@@ -47,10 +48,16 @@ public final class ExpansionJournal {
this(directory, seed, rootDiameter, ExpansionIsland.GENERATION);
}
public ExpansionJournal(Path directory, long seed, int rootDiameter, int rootGeneration) throws IOException {
if (rootGeneration != 12 && rootGeneration != 13) throw new IOException("Unsupported root generation");
if (rootDiameter != 512 && rootDiameter != 724 && !(rootGeneration == 13 && rootDiameter == 1024))
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;
@@ -110,7 +117,7 @@ public final class ExpansionJournal {
// No fallback to a non-atomic replacement: failure must remain visible to the caller.
Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
temporary = null;
try (FileChannel parent = FileChannel.open(directory, StandardOpenOption.READ)) { parent.force(true); }
directorySync.force(directory);
if (!read().equals(next)) throw new IOException("Expansion journal verification disagrees with the committed plan");
} catch (IOException | RuntimeException e) {
failed = true;
@@ -123,6 +130,19 @@ public final class ExpansionJournal {
}
}
@FunctionalInterface
interface DirectorySync { void force(Path directory) throws IOException; }
static DirectorySync directorySyncFor(String separator, DirectorySync supportedSync) {
// Windows FileChannel cannot open directory handles. Only this additional
// directory flush is omitted: file force, atomic replacement and read-back remain mandatory.
return "\\".equals(separator) ? directory -> {} : supportedSync;
}
private static void forceDirectory(Path directory) throws IOException {
try (FileChannel parent = FileChannel.open(directory, StandardOpenOption.READ)) { parent.force(true); }
}
private List<ExpansionIsland> read() throws IOException {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Existing expansion directory has no regular journal file");
if (Files.size(file) > MAX_BYTES) throw new IOException("Expansion journal exceeds its size limit");
@@ -138,10 +158,13 @@ public final class ExpansionJournal {
List<ExpansionIsland> decoded = new ArrayList<>();
for (JsonElement element : entries) {
JsonObject entry = element.getAsJsonObject();
requireFields(entry, ISLAND_FIELDS);
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")));
string(entry, "relief"), string(entry, "status"), integer(entry, "generation"),
entry.has("reuse_token") ? string(entry, "reuse_token") : ""));
}
return validate(decoded);
} catch (RuntimeException e) {
@@ -158,12 +181,13 @@ public final class ExpansionJournal {
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 (!followsDirection(island.direction(), dx, dz)) throw new IOException("Expansion position does not follow its direction: " + island.id());
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);
}
@@ -194,7 +218,9 @@ public final class ExpansionJournal {
entry.addProperty("direction", island.direction()); entry.addProperty("climate", island.climate());
entry.addProperty("diameter", island.diameter()); entry.addProperty("center_x", island.centerX()); entry.addProperty("center_z", island.centerZ());
entry.addProperty("seed", island.seed()); entry.addProperty("relief", island.relief()); entry.addProperty("status", island.status());
entry.addProperty("generation", island.generation()); array.add(entry);
entry.addProperty("generation", island.generation());
if (!island.reuseToken().isEmpty()) entry.addProperty("reuse_token", island.reuseToken());
array.add(entry);
}
root.add("islands", array);
return root;
@@ -23,12 +23,16 @@ public final class ExpansionPlacement {
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", ExpansionIsland.GENERATION);
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;
@@ -40,7 +44,7 @@ public final class ExpansionPlacement {
long z = parent.centerZ() + DZ[ordinal] * component;
if (Math.abs(x) > MAX_DISTANCE || Math.abs(z) > MAX_DISTANCE) break;
var candidate = new ExpansionIsland(id, parentId, direction, climate, diameter, (int) x, (int) z,
sample.seed(), relief, "reserved", ExpansionIsland.GENERATION);
sample.seed(), relief, "reserved", generation);
if (islands.stream().noneMatch(candidate::conflicts)) candidates.add(candidate);
}
return List.copyOf(candidates);
@@ -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,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);
}
@@ -22,7 +22,7 @@ import net.minecraft.world.level.Level;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.storage.LevelResource;
/** Server-owned append-only admission and bounded preparation queue. No existing chunk is regenerated. */
/** 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);
@@ -38,6 +38,7 @@ public final class ExpansionRuntime {
&& !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);
@@ -53,6 +54,7 @@ public final class ExpansionRuntime {
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());
}
@@ -109,17 +111,20 @@ public final class ExpansionRuntime {
for (var value : candidates) {
ExpansionAdmission.checkDeadline(deadline);
try {
// Existing void is preserved: only proven occupation advances along the same ray.
ExpansionAdmission.verify(level, value, deadline);
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(value);
ExpansionAdmission.verifyHolders(level, value, deadline);
next.add(admitted);
if (inspection == null) ExpansionAdmission.verifyHolders(level, value, deadline);
commitJournal(next); // Durability and re-read come before worldgen sees the island.
generator.setIslands(journal.islands());
if (!admitted.reuseToken().isEmpty()) ExpansionVoidReuse.deferred(level, admitted);
publishActiveIslands();
failure = "";
enqueue(value);
return value;
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 {
@@ -133,12 +138,19 @@ public final class ExpansionRuntime {
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);
@@ -213,6 +225,8 @@ public final class ExpansionRuntime {
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();
@@ -222,9 +236,10 @@ public final class ExpansionRuntime {
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);
generator.setIslands(journal.islands());
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))
@@ -255,7 +270,8 @@ public final class ExpansionRuntime {
this.island = island;
int radius = island.terrainRadius() + ExpansionIsland.DECORATION_MARGIN;
for (int x = (island.centerX() - radius) >> 4; x <= (island.centerX() + radius) >> 4; x++)
for (int z = (island.centerZ() - radius) >> 4; z <= (island.centerZ() + radius) >> 4; z++) chunks.add(new ChunkPos(x, z));
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) {
@@ -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);
}
}
@@ -123,6 +123,15 @@ public final class ExpansionStructures {
RandomState randomState, StructureStart start, boolean temple) {
// Native beard adaptation already expands this box by 12 blocks when applicable.
BoundingBox total = start.getBoundingBox();
if (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;
@@ -130,6 +139,9 @@ public final class ExpansionStructures {
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";
@@ -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,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();
}
@@ -13,6 +13,22 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/** Only new alpha.13 decoration writes are filtered, never player edits or saved chunk loading. */
@Mixin(WorldGenRegion.class)
public abstract class UnifiedDecorationProtectionMixin {
/** Some native features (notably ore veins) write directly to a chunk section after
* checking ensureCanWrite, so setBlock alone cannot enforce island ownership. */
@Inject(method = "ensureCanWrite", at = @At("HEAD"), cancellable = true)
private void sanctuary$keepIslandOwnership(BlockPos pos, CallbackInfoReturnable<Boolean> callback) {
var region = (WorldGenRegion) (Object) this;
if (!(region.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator generator)
|| !fr.koka.sanctuary.expansion.ExpansionIsland.spatialGeneration(generator.rootGeneration())) return;
var center = region.getCenter();
var owner = generator.islandForChunk(center.x(), center.z());
var destination = generator.islandForChunk(pos.getX() >> 4, pos.getZ() >> 4);
if (owner == null || destination == null || !owner.id().equals(destination.id())
|| !fr.koka.sanctuary.worldgen.city.LostCityStructures.isPlacing()
&& !fr.koka.sanctuary.worldgen.ruins.RuinsStructures17.isPlacing() && generator.protectsCityDecoration(pos))
callback.setReturnValue(false);
}
@Inject(method = "setBlock", at = @At("HEAD"), cancellable = true)
private void sanctuary$keepHydrology(BlockPos pos, BlockState state, int flags, int recursion,
CallbackInfoReturnable<Boolean> callback) {
@@ -22,6 +38,11 @@ public abstract class UnifiedDecorationProtectionMixin {
if (!region.isWithinWriteZone(pos)) return;
// The dedicated shoreline feature still grows cane on its naturally valid dry banks.
if (state.is(Blocks.SUGAR_CANE)) return;
// Generation16 reserves its architectural volume before decoration. Only its own
// native pieces may replace water there; published generations retain the old rule.
if ((generator.rootGeneration() == 16 || generator.rootGeneration() == 17)
&& fr.koka.sanctuary.worldgen.city.LostCityStructures.isPlacing()
&& generator.protectsCityDecoration(pos)) return;
if (generator.protectsDecoration(pos) && !region.getBlockState(pos).equals(state)) {
generator.rejectedDecorationWrite();
callback.setReturnValue(false);
@@ -28,13 +28,37 @@ public final class SanctuaryBiomeSource extends ExpansionBiomeSource {
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)));
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),
@@ -44,9 +68,14 @@ public final class SanctuaryBiomeSource extends ExpansionBiomeSource {
}
/** A world or delegate gets its own activation snapshot; registered holders remain immutable. */
public SanctuaryBiomeSource copy() { return new SanctuaryBiomeSource(allBiomes); }
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 CODEC; }
@Override protected MapCodec<SanctuaryBiomeSource> codec() { return 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);
@@ -69,6 +98,10 @@ public final class SanctuaryBiomeSource extends ExpansionBiomeSource {
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;
@@ -7,6 +7,8 @@ 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;
@@ -24,8 +26,43 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator {
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)));
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 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();
@@ -39,12 +76,29 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator {
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 && 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 CODEC; }
@Override protected MapCodec<SanctuaryChunkGenerator> codec() { return 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);
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 boolean intersectsExperimentalStructures(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {
return generation == 17 && ruinsPlan17.intersectsProtection(minX, minY, minZ, maxX, maxY, maxZ);
}
public IslandCapacity capacity() {
return switch (initialDiameter()) {
@@ -70,12 +124,221 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator {
return equivalent == null ? biome : registries.lookupOrThrow(Registries.BIOME).getOrThrow(
net.minecraft.resources.ResourceKey.create(Registries.BIOME, net.minecraft.resources.Identifier.withDefaultNamespace(equivalent)));
}
@Override public String journalDirectory() { return "sanctuary-world-v13"; }
@Override public int rootGeneration() { return 13; }
@Override public 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);
prepareRuins(islands);
}
@Override public void setIslands(List<ExpansionIsland> islands) {
// Complete and publish city geometry before workers can observe the new owner list.
prepareCities(islands);
prepareRuins(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) && !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) {
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 || !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 -> 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 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);
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 == 16 || generation == 17) {
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) return cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ())
|| generation == 17 && ruinsPlan17.protects(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) && cityProtection16.intersects(minX, minY, minZ, maxX, maxY, maxZ);
}
public fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan undergroundPlan16(ExpansionIsland island) {
if (generation != 16 && generation != 17) 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) return List.of();
lostCityPlan(island);
return citySupports16.getOrDefault(cityIdentity(island), List.of());
}
public LostCityPlanner16.Fallback cityReservation16(ExpansionIsland island) {
if (generation != 16 && generation != 17) 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 (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 && 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);
return super.findNearestMapStructure(level, structures, origin, radius, skipReferenced);
}
@Override protected Context context(ExpansionIsland island) {
if (!island.origin()) return super.context(island);
@@ -102,5 +365,16 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator {
@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) && 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,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;
}
}
@@ -0,0 +1,76 @@
package fr.koka.sanctuary.worldgen.city;
import java.util.ArrayList;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.StructureManager;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.StructurePiece;
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
/** Saved generation16 fallback masonry. The enclosing bounding box is NOT its write mask:
* below the basement only the explicitly saved three-by-three anchors are written. */
public final class LostCityFoundationPiece16 extends StructurePiece {
private final LostCityPlanner16.Fallback reservation;
private final long seed;
public LostCityFoundationPiece16(LostCityPlanner16.Fallback reservation, long seed) {
super(LostCityStructures.FOUNDATION16, 0, bounds(reservation));
this.reservation = reservation; this.seed = seed;
setOrientation(null);
}
public LostCityFoundationPiece16(StructurePieceSerializationContext context, CompoundTag tag) {
super(LostCityStructures.FOUNDATION16, tag);
reservation = readReservation(tag); seed = tag.getLong("FoundationSeed").orElseThrow();
if (!getBoundingBox().equals(bounds(reservation)))
throw new IllegalArgumentException("Saved city foundation bounds disagree with its geometry");
setOrientation(null);
}
public LostCityPlanner16.Fallback reservation() { return reservation; }
@Override public BlockPos getLocatorPosition() {
return new BlockPos(reservation.minX() + 24, reservation.floorY() + 1, reservation.minZ() + 24);
}
@Override protected void addAdditionalSaveData(StructurePieceSerializationContext context, CompoundTag tag) {
writeReservation(reservation, tag); tag.putLong("FoundationSeed", seed);
}
/** Public codec helpers are also used by round-trip tests without a live StructureStart. */
public static void writeReservation(LostCityPlanner16.Fallback r, CompoundTag tag) {
tag.putInt("SanctuarySchema", 1);
tag.putInt("MinX", r.minX()); tag.putInt("MinZ", r.minZ());
tag.putInt("Width", r.width()); tag.putInt("Depth", r.depth()); tag.putInt("FloorY", r.floorY());
tag.putInt("MinY", r.minY()); tag.putInt("MaxY", r.maxY());
int[] anchors = new int[r.anchors().size() * 6]; int i = 0;
for (var a : r.anchors()) {
anchors[i++] = a.minX(); anchors[i++] = a.minY(); anchors[i++] = a.minZ();
anchors[i++] = a.maxX(); anchors[i++] = a.maxY(); anchors[i++] = a.maxZ();
}
tag.putIntArray("Anchors", anchors);
}
public static LostCityPlanner16.Fallback readReservation(CompoundTag tag) {
if (tag.getInt("SanctuarySchema").orElseThrow() != 1)
throw new IllegalArgumentException("Unsupported generation16 foundation schema");
int[] saved = tag.getIntArray("Anchors").orElseThrow();
if (saved.length % 6 != 0 || saved.length > 150) throw new IllegalArgumentException("Invalid foundation anchors");
var anchors = new ArrayList<LostCityPlanner16.Anchor>();
for (int i = 0; i < saved.length; i += 6)
anchors.add(new LostCityPlanner16.Anchor(saved[i], saved[i + 1], saved[i + 2], saved[i + 3], saved[i + 4], saved[i + 5]));
return new LostCityPlanner16.Fallback(tag.getInt("MinX").orElseThrow(), tag.getInt("MinZ").orElseThrow(),
tag.getInt("Width").orElseThrow(), tag.getInt("Depth").orElseThrow(), tag.getInt("FloorY").orElseThrow(),
tag.getInt("MinY").orElseThrow(), tag.getInt("MaxY").orElseThrow(), anchors);
}
@Override public void postProcess(WorldGenLevel level, StructureManager manager, ChunkGenerator generator,
RandomSource random, BoundingBox clip, ChunkPos chunk, BlockPos pivot) {
LostCityStructures.placing(() -> LostCityFoundation16.render(reservation, seed, (x, y, z, state) -> {
if (clip.isInside(x, y, z)) placeBlock(level, state, x, y, z, clip);
}));
}
public static BoundingBox bounds(LostCityPlanner16.Fallback r) {
int bottom = r.anchors().stream().mapToInt(LostCityPlanner16.Anchor::minY).min().orElse(r.minY());
return new BoundingBox(r.minX(), bottom, r.minZ(), r.maxX(), r.floorY() - 3, r.maxZ());
}
}
@@ -0,0 +1,468 @@
package fr.koka.sanctuary.worldgen.city;
import fr.koka.sanctuary.worldgen.city.LostCityPlan.Kind;
import fr.koka.sanctuary.worldgen.city.LostCityPlan.Parcel;
import java.util.ArrayList;
import java.util.List;
import net.minecraft.core.Direction;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.Rotation;
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.DoorHingeSide;
import net.minecraft.world.level.block.state.properties.DoubleBlockHalf;
import net.minecraft.world.level.block.state.properties.SlabType;
import net.minecraft.world.level.storage.loot.BuiltInLootTables;
import net.minecraft.world.level.storage.loot.LootTable;
/** Complete generation-15 buildings. Generation 14 deliberately never calls this renderer.
* A north-facing plan is rotated once; all positions remain relative to the saved parcel. */
public final class LostCityInteriors15 {
private static final BlockState AIR = Blocks.AIR.defaultBlockState();
private static final BlockState STONE = Blocks.STONE.defaultBlockState();
private static final BlockState SMOOTH = Blocks.SMOOTH_STONE.defaultBlockState();
private static final BlockState WOOD = Blocks.DARK_OAK_PLANKS.defaultBlockState();
private static final BlockState TILE = Blocks.POLISHED_ANDESITE.defaultBlockState();
private static final int STOREY = 5;
private LostCityInteriors15() {}
/** Reachable standing positions, in the same rotated local coordinates as BlockWriter. */
public record Room(String name, int x, int y, int z) {}
public static void render(Parcel parcel, LostCityRenderer.BlockWriter output) {
validate(parcel);
new Building(parcel, output).render();
}
/** These describe actual rooms and services, not decorative labels placed in the world. */
public static List<Room> rooms(Parcel parcel) {
validate(parcel);
List<Room> result = new ArrayList<>();
add(result, parcel, "street_entrance", 8, 1, 0);
add(result, parcel, "vestibule", 8, 1, 3);
for (int floor = 0; floor < parcel.floors(); floor++) {
int y = floor * STOREY + 1;
String prefix = "floor_" + floor + "/";
add(result, parcel, prefix + "corridor", 8, y, 8);
add(result, parcel, prefix + "stair_landing", 4, y, 8);
String front = switch (parcel.kind()) {
case APARTMENT -> "kitchen_dining";
case HOTEL -> floor == 0 ? "reception" : "guest_suite";
case LIBRARY -> "book_stacks";
case RESTAURANT -> "dining_room";
case BARRACKS -> "dormitory";
case HALL -> floor == 0 ? "ticket_office" : "administration";
default -> throw new IllegalArgumentException("Not an interior kind");
};
add(result, parcel, prefix + front, 11, y, 6);
if (parcel.kind() == Kind.HOTEL && floor > 0)
add(result, parcel, prefix + "suite_bathroom", 12, y, 3);
if (parcel.kind() == Kind.HALL && floor < 2) {
if (floor == 0) {
add(result, parcel, prefix + "auditorium", 8, y, 11);
add(result, parcel, prefix + "stage", 9, y + 1, 13);
} else add(result, parcel, prefix + "gallery", 8, y, 10);
} else {
String west = switch (parcel.kind()) {
case APARTMENT, HOTEL -> floor == 0 && parcel.kind() == Kind.HOTEL ? "lounge" : "bedroom";
case LIBRARY -> "reading_room";
case RESTAURANT, BARRACKS -> "washrooms";
default -> "meeting_room";
};
String east = switch (parcel.kind()) {
case APARTMENT -> "bathroom";
case HOTEL -> floor == 0 ? "laundry" : "guest_room";
case LIBRARY -> "archive";
case RESTAURANT -> "kitchen";
case BARRACKS -> "equipment_store";
default -> "meeting_room_east";
};
add(result, parcel, prefix + west, 5, y, 12);
add(result, parcel, prefix + east, 11, y, 12);
if (parcel.kind() == Kind.APARTMENT)
add(result, parcel, prefix + "bathroom_cubicle", 12, y, 14);
if (parcel.kind() == Kind.RESTAURANT || parcel.kind() == Kind.BARRACKS)
add(result, parcel, prefix + "washroom_cubicle", 3, y, 14);
}
}
add(result, parcel, "roof", 8, parcel.floors() * STOREY + 1, 8);
return List.copyOf(result);
}
private static void validate(Parcel p) {
if (!p.kind().building() || p.width() != 17 || p.depth() != 17 || p.floors() < 2)
throw new IllegalArgumentException("Generation-15 interiors require a 17x17 building with 2..5 floors");
}
private static void add(List<Room> result, Parcel p, String name, int x, int y, int z) {
result.add(new Room(name, rx(x, z, p.facing()), y, rz(x, z, p.facing())));
}
private static int rx(int x, int z, int facing) {
return switch (facing) { case 0 -> x; case 1 -> 16 - z; case 2 -> 16 - x; default -> z; };
}
private static int rz(int x, int z, int facing) {
return switch (facing) { case 0 -> z; case 1 -> x; case 2 -> 16 - z; default -> 16 - x; };
}
private static final class Building {
final Parcel p;
final LostCityRenderer.BlockWriter output;
final Rotation rotation;
Building(Parcel p, LostCityRenderer.BlockWriter output) {
this.p = p;
this.output = output;
rotation = switch (p.facing()) {
case 0 -> Rotation.NONE; case 1 -> Rotation.CLOCKWISE_90;
case 2 -> Rotation.CLOCKWISE_180; default -> Rotation.COUNTERCLOCKWISE_90;
};
}
void set(int x, int y, int z, BlockState state) {
output.set(rx(x, z, p.facing()), y, rz(x, z, p.facing()), state.rotate(rotation));
}
void set(int x, int y, int z, Block block) { set(x, y, z, block.defaultBlockState()); }
void box(int x0, int y0, int z0, int x1, int y1, int z1, BlockState state) {
for (int x = x0; x <= x1; x++) for (int z = z0; z <= z1; z++)
for (int y = y0; y <= y1; y++) set(x, y, z, state);
}
BlockState facing(Block block, Direction direction) {
return block.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, direction);
}
BlockState brick(int x, int y, int z) { return LostCityPalette.masonry(p.seed(), x, y, z); }
int roll(int x, int y, int z, int bound) { return LostCityPalette.roll(p.seed(), x, y, z, bound); }
void render() {
int roof = p.floors() * STOREY;
box(0, -p.foundationDepth(), 0, 16, -1, 16, STONE);
box(0, 0, 0, 16, 0, 16, SMOOTH);
box(0, 1, 0, 16, roof + 4, 16, AIR);
shell(roof);
for (int floor = 0; floor < p.floors(); floor++) {
partitions(floor);
furnishings(floor);
}
stairs();
entrance();
roof(roof);
// Only one deferred loot table per building; empty furniture is not a resource vault.
int lootX = 14;
int lootZ = p.kind() == Kind.HALL ? 6 : 14;
box(lootX, 2, lootZ, lootX, 3, lootZ, AIR);
ResourceKey<LootTable> table = switch (p.kind()) {
case LIBRARY, HALL -> BuiltInLootTables.VILLAGE_CARTOGRAPHER;
case RESTAURANT -> BuiltInLootTables.VILLAGE_BUTCHER;
case BARRACKS -> BuiltInLootTables.VILLAGE_FLETCHER;
default -> BuiltInLootTables.VILLAGE_PLAINS_HOUSE;
};
output.chest(rx(lootX, lootZ, p.facing()), 1, rz(lootX, lootZ, p.facing()), table,
p.seed() ^ 0x15c17a7eL ^ p.kind().ordinal());
}
void shell(int roof) {
for (int floor = 0; floor <= p.floors(); floor++) {
int base = floor * STOREY;
box(1, base, 1, 15, base, 15, floor == p.floors() ? SMOOTH : floorMaterial());
if (floor == p.floors()) continue;
// A pale ceiling and contrasting structural floor are separate layers.
box(2, base + 4, 2, 14, base + 4, 14,
Blocks.SMOOTH_STONE_SLAB.defaultBlockState().setValue(BlockStateProperties.SLAB_TYPE, SlabType.TOP));
for (int y = 1; y <= 4; y++) for (int horizontal = 1; horizontal <= 15; horizontal++) {
facade(horizontal, base + y, 1, horizontal, y, true);
facade(horizontal, base + y, 15, horizontal, y, true);
facade(1, base + y, horizontal, horizontal, y, false);
facade(15, base + y, horizontal, horizontal, y, false);
}
// Floor bands remain intact even where windows have fallen out.
for (int n = 1; n <= 15; n++) {
set(n, base, 1, SMOOTH); set(n, base, 15, SMOOTH);
set(1, base, n, SMOOTH); set(15, base, n, SMOOTH);
}
if (p.kind() == Kind.HOTEL || p.kind() == Kind.LIBRARY) {
for (int x : new int[]{3, 13}) for (int y = 0; y <= 4; y++) set(x, base + y, 0, SMOOTH);
}
if (p.kind() == Kind.APARTMENT) for (int x = 11; x <= 14; x++)
set(x, base + 1, 0, Blocks.SMOOTH_STONE_SLAB);
if (p.kind() == Kind.RESTAURANT) box(10, base + 4, 0, 14, base + 4, 0,
Blocks.DARK_OAK_SLAB.defaultBlockState());
}
if (p.kind() == Kind.HALL) {
// The auditorium spans two storeys; its upper level is a guarded gallery.
box(3, 4, 11, 13, 5, 14, AIR);
for (int x = 3; x <= 13; x++) set(x, 6, 11, rail(true));
for (int z = 12; z <= 14; z++) { set(3, 6, z, rail(false)); set(13, 6, z, rail(false)); }
}
}
BlockState floorMaterial() {
return switch (p.kind()) {
case LIBRARY, APARTMENT, HOTEL -> WOOD;
case RESTAURANT, BARRACKS -> TILE;
default -> SMOOTH;
};
}
void facade(int x, int y, int z, int horizontal, int withinFloor, boolean eastWest) {
boolean column = horizontal == 1 || horizontal == 6 || horizontal == 10 || horizontal == 15;
boolean window = !column && (withinFloor == 2 || withinFloor == 3
|| withinFloor == 1 && (p.kind() == Kind.LIBRARY || p.kind() == Kind.RESTAURANT || p.kind() == Kind.HALL));
if (column) set(x, y, z, p.kind() == Kind.BARRACKS ? brick(x, y, z) : STONE);
else if (window) {
if (roll(x, y / 5, z, 13) != 0)
set(x, y, z, p.kind() == Kind.BARRACKS ? rail(eastWest) : pane(eastWest));
} else set(x, y, z, withinFloor == 4 ? SMOOTH : brick(x, y, z));
}
void partitions(int floor) {
int y = floor * STOREY;
// The west stairwell, central corridor and vestibule are independent of furniture.
box(5, y + 1, 2, 5, y + 4, 9, SMOOTH);
door(5, y + 1, 8, Direction.WEST, false);
box(10, y + 1, 2, 10, y + 4, 7, SMOOTH);
box(10, y + 1, 7, 14, y + 4, 7, SMOOTH);
door(10, y + 1, 6, Direction.EAST, false);
if (floor == 0) {
box(6, 1, 4, 9, 4, 4, SMOOTH);
door(8, 1, 4, Direction.SOUTH, false);
}
if (p.kind() == Kind.HALL && floor < 2) {
if (floor == 0) {
box(2, 1, 10, 14, 4, 10, SMOOTH);
door(7, 1, 10, Direction.SOUTH, true);
door(8, 1, 10, Direction.SOUTH, false);
}
} else {
box(2, y + 1, 10, 6, y + 4, 10, SMOOTH);
box(6, y + 1, 10, 6, y + 4, 14, SMOOTH);
box(10, y + 1, 10, 14, y + 4, 10, SMOOTH);
box(10, y + 1, 10, 10, y + 4, 14, SMOOTH);
door(6, y + 1, 12, Direction.WEST, false);
door(10, y + 1, 12, Direction.EAST, false);
}
// A hanging lantern touches a full ceiling block, never the recessed underside of a top slab.
set(8, y + 4, 8, SMOOTH);
set(8, y + 3, 8, Blocks.LANTERN.defaultBlockState().setValue(BlockStateProperties.HANGING, true));
// The hall's stage is raised one block: keep its two-block headroom free of debris.
if (p.kind() == Kind.HALL && floor == 0) set(14, y + 3, 14, Blocks.COBWEB);
else set(9, y + 3, 13, Blocks.COBWEB);
set(14, y + 3, 6, Blocks.COBWEB);
}
void furnishings(int floor) {
int y = floor * STOREY;
switch (p.kind()) {
case APARTMENT -> {
kitchen(y, true);
bedroom(y, false);
washroom(y, true);
}
case HOTEL -> {
if (floor == 0) { reception(y); lounge(y, false); laundry(y); }
else { guestSuite(y); bedroom(y, false); bedroom(y, true); }
}
case LIBRARY -> {
box(14, y + 1, 2, 14, y + 3, 6, Blocks.BOOKSHELF.defaultBlockState());
box(12, y + 1, 2, 12, y + 2, 3, Blocks.BOOKSHELF.defaultBlockState());
desk(12, y, 5, Direction.SOUTH);
readingRoom(y, false);
box(12, y + 1, 14, 14, y + 3, 14, Blocks.BOOKSHELF.defaultBlockState());
box(14, y + 1, 11, 14, y + 2, 12, Blocks.BARREL.defaultBlockState());
set(12, y + 1, 11, facing(Blocks.LECTERN, Direction.SOUTH));
}
case RESTAURANT -> {
table(13, y, 3); table(13, y, 5);
set(11, y + 1, 2, Blocks.POTTED_DEAD_BUSH);
kitchen(y, false); washroom(y, false);
}
case BARRACKS -> {
bed(12, y + 1, 3, Direction.SOUTH); bed(14, y + 1, 3, Direction.SOUTH);
set(11, y + 1, 2, Blocks.BARREL); set(13, y + 1, 2, Blocks.BARREL);
seat(13, y + 1, 6, Direction.NORTH);
washroom(y, false);
box(12, y + 1, 14, 14, y + 2, 14, Blocks.BARREL.defaultBlockState());
set(14, y + 1, 11, Blocks.FLETCHING_TABLE);
set(12, y + 1, 11, Blocks.GRINDSTONE);
}
case HALL -> {
reception(y);
if (floor == 0) auditorium();
else if (floor >= 2) { readingRoom(y, false); readingRoom(y, true); }
}
default -> throw new IllegalArgumentException("Not an interior kind");
}
}
void kitchen(int y, boolean front) {
int z = front ? 2 : 14;
Direction face = front ? Direction.SOUTH : Direction.NORTH;
set(11, y + 1, z, facing(Blocks.FURNACE, face));
set(12, y + 1, z, Blocks.WATER_CAULDRON.defaultBlockState().setValue(BlockStateProperties.LEVEL_CAULDRON, 2));
set(13, y + 1, z, facing(Blocks.SMOKER, face));
set(14, y + 1, z, Blocks.BARREL);
box(11, y + 3, z, 14, y + 3, z, SMOOTH);
set(13, y + 2, z, Blocks.IRON_TRAPDOOR);
if (front) table(13, y, 5);
else {
set(14, y + 1, 11, SMOOTH); set(14, y + 2, 11, Blocks.FLOWER_POT);
set(12, y + 1, 11, Blocks.CRAFTING_TABLE);
}
for (int x = 11; x <= 14; x++) set(x, y, z, (x & 1) == 0 ? SMOOTH : TILE);
}
void bedroom(int y, boolean east) {
int x = east ? 13 : 2;
bed(x, y + 1, 12, Direction.SOUTH);
set(x + 1, y + 1, 13, Blocks.BARREL);
set(x + 1, y + 2, 13, Blocks.FLOWER_POT);
int deskX = east ? 12 : 3;
desk(deskX, y, 11, Direction.SOUTH);
set(east ? 14 : 2, y + 1, 14, Blocks.BOOKSHELF);
}
void guestSuite(int y) {
box(11, y + 1, 4, 14, y + 4, 4, SMOOTH);
door(12, y + 1, 4, Direction.NORTH, false);
bed(14, y + 1, 5, Direction.SOUTH);
set(11, y + 1, 5, Blocks.BARREL); set(11, y + 2, 5, Blocks.FLOWER_POT);
set(14, y + 1, 2, Blocks.WATER_CAULDRON.defaultBlockState().setValue(BlockStateProperties.LEVEL_CAULDRON, 2));
set(13, y + 1, 2, seating(Blocks.POLISHED_ANDESITE_STAIRS, Direction.SOUTH));
set(13, y + 2, 2, Blocks.IRON_TRAPDOOR);
for (int x = 11; x <= 14; x++) for (int z = 2; z <= 3; z++) set(x, y, z, ((x + z) & 1) == 0 ? SMOOTH : TILE);
}
void washroom(int y, boolean east) {
int left = east ? 11 : 2;
int doorX = east ? 12 : 3;
box(left, y + 1, 13, left + 2, y + 3, 13, SMOOTH);
door(doorX, y + 1, 13, Direction.SOUTH, false);
set(left, y + 1, 14, seating(Blocks.POLISHED_ANDESITE_STAIRS, Direction.EAST));
set(left, y + 2, 14, Blocks.IRON_TRAPDOOR);
set(left + 2, y + 1, 14, Blocks.WATER_CAULDRON.defaultBlockState().setValue(BlockStateProperties.LEVEL_CAULDRON, 2));
set(left, y + 1, 11, Blocks.CAULDRON);
set(left + 2, y + 1, 11, SMOOTH); set(left + 2, y + 2, 11, Blocks.FLOWER_POT);
for (int x = left; x <= left + 3; x++) for (int z = 11; z <= 14; z++) set(x, y, z, ((x + z) & 1) == 0 ? SMOOTH : TILE);
}
void reception(int y) {
box(12, y + 1, 5, 14, y + 1, 5, WOOD);
set(13, y + 2, 5, Blocks.FLOWER_POT);
set(14, y + 1, 2, Blocks.BOOKSHELF); set(14, y + 2, 2, Blocks.BARREL);
desk(12, y, 3, Direction.SOUTH);
set(11, y + 1, 2, Blocks.POTTED_DEAD_BUSH);
}
void laundry(int y) {
for (int z = 11; z <= 12; z++) set(14, y + 1, z, Blocks.WATER_CAULDRON.defaultBlockState()
.setValue(BlockStateProperties.LEVEL_CAULDRON, z == 11 ? 1 : 2));
box(12, y + 1, 14, 14, y + 1, 14, Blocks.BARREL.defaultBlockState());
box(12, y + 3, 14, 14, y + 3, 14, Blocks.DARK_OAK_SLAB.defaultBlockState());
set(12, y + 1, 11, Blocks.LOOM);
}
void lounge(int y, boolean east) {
int start = east ? 11 : 2;
box(start, y + 1, 14, start + 2, y + 1, 14, seating(Blocks.DARK_OAK_STAIRS, Direction.NORTH));
desk(start + 1, y, 11, Direction.SOUTH);
set(start, y + 1, 12, Blocks.BOOKSHELF);
}
void readingRoom(int y, boolean east) {
int left = east ? 11 : 2;
desk(left + 1, y, 12, Direction.SOUTH);
box(left, y + 1, 14, left + 2, y + 2, 14, Blocks.BOOKSHELF.defaultBlockState());
set(left, y + 1, 11, facing(Blocks.LECTERN, Direction.SOUTH));
}
void auditorium() {
for (int x : new int[]{3, 4, 5, 11, 12, 13}) seat(x, 1, 11, Direction.SOUTH);
box(3, 1, 13, 13, 1, 14, WOOD);
set(8, 1, 12, facing(Blocks.DARK_OAK_STAIRS, Direction.SOUTH));
set(8, 2, 14, facing(Blocks.LECTERN, Direction.NORTH));
for (int x : new int[]{3, 13}) {
box(x, 2, 14, x, 3, 14, WOOD);
set(x, 4, 14, Blocks.STONE_BRICK_SLAB);
}
}
void stairs() {
for (int floor = 0; floor < p.floors(); floor++) {
int base = floor * STOREY;
for (int step = 1; step <= STOREY; step++) {
int z = step + 2;
box(2, base + step, z, 3, base + step + 3, z, AIR);
for (int x = 2; x <= 3; x++) set(x, base + step, z,
facing(Blocks.STONE_BRICK_STAIRS, Direction.SOUTH));
}
box(2, base + STOREY, 8, 4, base + STOREY, 9, TILE);
box(2, base + STOREY + 1, 8, 4, base + STOREY + 3, 9, AIR);
}
// This full-height side aisle joins every lower stair approach to its landing door.
for (int floor = 0; floor <= p.floors(); floor++) {
int y = floor * STOREY;
box(4, y + 1, 2, 4, y + 3, 9, AIR);
}
}
void entrance() {
for (int x : new int[]{6, 9}) box(x, 1, 0, x, 3, 0, SMOOTH);
box(6, 4, 0, 9, 4, 1, Blocks.STONE_BRICK_SLAB.defaultBlockState());
box(7, 0, 0, 8, 0, 3, TILE);
// Native SOUTH/RIGHT opens against the west jamb; SOUTH/LEFT against the east jamb.
door(7, 1, 1, Direction.SOUTH, true);
door(8, 1, 1, Direction.SOUTH, false);
set(4, 1, 0, Blocks.POTTED_DEAD_BUSH);
set(12, 1, 16, Blocks.COMPOSTER);
}
void roof(int y) {
for (int n = 1; n <= 15; n++) {
if (roll(n, y, 1, 9) != 0) set(n, y + 1, 1, brick(n, y, 1));
if (roll(n, y, 15, 9) != 0) set(n, y + 1, 15, brick(n, y, 15));
set(1, y + 1, n, brick(1, y, n)); set(15, y + 1, n, brick(15, y, n));
}
box(11, y + 1, 11, 13, y + 1, 13, SMOOTH);
set(12, y + 2, 12, Blocks.IRON_TRAPDOOR);
set(6, y + 1, 13, Blocks.POTTED_DEAD_BUSH);
set(14, y + 2, 14, Blocks.COBWEB);
}
void door(int x, int y, int z, Direction facing, boolean rightHinge) {
BlockState state = facing(Blocks.DARK_OAK_DOOR, facing)
.setValue(BlockStateProperties.DOOR_HINGE, rightHinge ? DoorHingeSide.RIGHT : DoorHingeSide.LEFT)
.setValue(BlockStateProperties.OPEN, false)
.setValue(BlockStateProperties.POWERED, false);
set(x, y, z, state.setValue(BlockStateProperties.DOUBLE_BLOCK_HALF, DoubleBlockHalf.LOWER));
set(x, y + 1, z, state.setValue(BlockStateProperties.DOUBLE_BLOCK_HALF, DoubleBlockHalf.UPPER));
}
void bed(int x, int y, int z, Direction facing) {
BlockState state = facing(Blocks.BED.white(), facing);
set(x, y, z, state.setValue(BlockStateProperties.BED_PART, BedPart.FOOT));
set(x + facing.getStepX(), y, z + facing.getStepZ(), state.setValue(BlockStateProperties.BED_PART, BedPart.HEAD));
}
void table(int x, int y, int z) {
set(x, y + 1, z, Blocks.DARK_OAK_FENCE); set(x, y + 2, z, Blocks.DARK_OAK_PRESSURE_PLATE);
seat(x - 1, y + 1, z, Direction.EAST);
seat(x + 1, y + 1, z, Direction.WEST);
}
void desk(int x, int y, int z, Direction approachSide) {
set(x, y + 1, z, Blocks.DARK_OAK_FENCE); set(x, y + 2, z, Blocks.DARK_OAK_PRESSURE_PLATE);
seat(x + approachSide.getStepX(), y + 1, z + approachSide.getStepZ(), approachSide.getOpposite());
}
void seat(int x, int y, int z, Direction lookingToward) {
set(x, y, z, seating(Blocks.DARK_OAK_STAIRS, lookingToward));
}
BlockState seating(Block stair, Direction lookingToward) {
// Stair FACING points uphill, into its full-height back, opposite a seated player's view.
return facing(stair, lookingToward.getOpposite());
}
BlockState rail(boolean eastWest) { return connected(Blocks.IRON_BARS, eastWest); }
BlockState pane(boolean eastWest) { return connected(Blocks.GLASS_PANE, eastWest); }
BlockState connected(Block block, boolean eastWest) {
return block.defaultBlockState().setValue(BlockStateProperties.EAST, eastWest)
.setValue(BlockStateProperties.WEST, eastWest).setValue(BlockStateProperties.NORTH, !eastWest)
.setValue(BlockStateProperties.SOUTH, !eastWest);
}
}
}
@@ -0,0 +1,584 @@
package fr.koka.sanctuary.worldgen.city;
import fr.koka.sanctuary.worldgen.city.LostCityPlan.Kind;
import fr.koka.sanctuary.worldgen.city.LostCityPlan.Parcel;
import java.util.ArrayList;
import java.util.List;
import net.minecraft.core.Direction;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.Rotation;
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.DoorHingeSide;
import net.minecraft.world.level.block.state.properties.DoubleBlockHalf;
import net.minecraft.world.level.block.state.properties.SlabType;
import net.minecraft.world.level.storage.loot.BuiltInLootTables;
import net.minecraft.world.level.storage.loot.LootTable;
/** Complete generation-16 buildings. Generation 15 deliberately never calls this renderer.
* A north-facing plan is rotated once; all positions remain relative to the saved parcel. */
public final class LostCityInteriors16 {
private static final BlockState AIR = Blocks.AIR.defaultBlockState();
private static final BlockState STONE = Blocks.STONE.defaultBlockState();
private static final BlockState SMOOTH = Blocks.SMOOTH_STONE.defaultBlockState();
private static final BlockState WOOD = Blocks.DARK_OAK_PLANKS.defaultBlockState();
private static final BlockState TILE = Blocks.POLISHED_ANDESITE.defaultBlockState();
private static final int STOREY = 5;
private LostCityInteriors16() {}
/** Reachable standing positions, in the same rotated local coordinates as BlockWriter. */
public record Room(String name, int x, int y, int z) {}
/** Local, already rotated enclosure including the floor, full ceiling, four walls and door. */
public record Bathroom(String name, int minX, int minY, int minZ, int maxX, int maxY, int maxZ,
int doorX, int doorY, int doorZ) {}
private record Area(int left, int front, int right, int back) {}
/** Adjacent storeys always change plan; the saved parcel seed chooses which plan comes first. */
public static int layout(Parcel parcel, int floor) {
validate(parcel);
if (floor < 0 || floor >= parcel.floors()) throw new IllegalArgumentException("Invalid interior floor");
return (int) ((parcel.seed() ^ (parcel.seed() >>> 32)) & 1L) ^ (floor & 1);
}
private static Area rear(Parcel p, int floor, boolean east) {
boolean wide = layout(p, floor) == 1;
return east ? new Area(wide ? 10 : 11, wide ? 10 : 11, 14, 14)
: new Area(2, wide ? 10 : 11, wide ? 6 : 5, 14);
}
private static int frontWall(Parcel p, int floor) { return layout(p, floor) == 0 ? 7 : 8; }
private static String frontUse(Parcel p, int floor) {
int variant = layout(p, floor);
return switch (p.kind()) {
case APARTMENT -> variant == 0 ? "kitchen_dining" : "studio_bedroom";
case HOTEL -> floor == 0 ? "reception" : "guest_suite";
case LIBRARY -> variant == 0 ? "book_stacks" : "periodicals_reading";
case RESTAURANT -> variant == 0 ? "dining_room" : "kitchen";
case BARRACKS -> variant == 0 ? "dormitory" : "duty_office";
case HALL -> floor == 0 ? "ticket_office" : "administration";
default -> throw new IllegalArgumentException("Not an interior kind");
};
}
private static String rearUse(Parcel p, int floor, boolean east) {
int variant = layout(p, floor);
return switch (p.kind()) {
case APARTMENT -> east ? "bathroom" : variant == 0 ? "bedroom" : "kitchen_dining";
case HOTEL -> floor == 0 ? east ? "laundry" : "lounge"
: variant == 0 ? east ? "guest_room" : "bedroom" : east ? "bathroom" : "guest_lounge";
case LIBRARY -> variant == 0 ? east ? "archive" : "reading_room" : east ? "bathroom" : "catalogue_office";
case RESTAURANT -> variant == 0 ? east ? "kitchen" : "washrooms" : east ? "pantry" : "dining_room";
case BARRACKS -> east ? "equipment_store" : variant == 0 ? "washrooms" : "officer_bedroom";
case HALL -> variant == 0 ? east ? "bathroom" : "meeting_room" : east ? "meeting_room" : "archive";
default -> throw new IllegalArgumentException("Not an interior kind");
};
}
public static void render(Parcel parcel, LostCityRenderer.BlockWriter output) {
validate(parcel);
new Building(parcel, output).render();
}
/** These describe actual rooms and services, not decorative labels placed in the world. */
public static List<Room> rooms(Parcel parcel) {
validate(parcel);
List<Room> result = new ArrayList<>();
add(result, parcel, "street_entrance", 8, 1, 0);
add(result, parcel, "vestibule", 8, 1, 3);
for (int floor = 0; floor < parcel.floors(); floor++) {
int y = floor * STOREY + 1;
String prefix = "floor_" + floor + "/";
add(result, parcel, prefix + "corridor", 8, y, 8);
add(result, parcel, prefix + "stair_landing", 4, y, 8);
add(result, parcel, prefix + frontUse(parcel, floor), 11, y, 6);
if (parcel.kind() == Kind.HALL && floor < 2) {
if (floor == 0) {
add(result, parcel, prefix + "auditorium", 8, y, 11);
add(result, parcel, prefix + "stage", 9, y + 1, 13);
} else add(result, parcel, prefix + "gallery", 8, y, 10);
} else {
Area west = rear(parcel, floor, false), east = rear(parcel, floor, true);
add(result, parcel, prefix + rearUse(parcel, floor, false), west.right(), y, 11);
add(result, parcel, prefix + rearUse(parcel, floor, true), east.left(), y, 11);
}
}
for (var bath : bathroomPlans(parcel)) {
int targetZ = bath.southDoor() ? bath.z1() - 1 : bath.z0() + 1;
add(result, parcel, bath.name(), bath.doorX(), bath.floor() * STOREY + 1, targetZ);
}
add(result, parcel, "roof", 8, parcel.floors() * STOREY + 1, 8);
return List.copyOf(result);
}
private record BathPlan(String name, int floor, int x0, int z0, int x1, int z1, int doorX, boolean southDoor) {}
private static List<BathPlan> bathroomPlans(Parcel p) {
List<BathPlan> result = new ArrayList<>();
for (int floor = 0; floor < p.floors(); floor++) {
if (p.kind() == Kind.HOTEL && floor > 0)
result.add(new BathPlan("floor_" + floor + "/suite_bathroom", floor, 10, 1, 15, 4, 12, true));
if (p.kind() == Kind.HALL && floor < 2) continue;
for (boolean east : new boolean[]{false, true}) {
String use = rearUse(p, floor, east);
if (!use.equals("bathroom") && !use.equals("washrooms")) continue;
Area room = rear(p, floor, east);
result.add(new BathPlan("floor_" + floor + "/" + (east ? "east" : "west") + "_bathroom_interior",
floor, room.left() - 1, 12, room.right() + 1, 15, east ? 12 : 4, false));
}
}
return List.copyOf(result);
}
public static List<Bathroom> bathrooms(Parcel p) {
validate(p);
List<Bathroom> result = new ArrayList<>();
for (var b : bathroomPlans(p)) {
int x0 = rx(b.x0(), b.z0(), p.facing()), z0 = rz(b.x0(), b.z0(), p.facing());
int x1 = rx(b.x1(), b.z1(), p.facing()), z1 = rz(b.x1(), b.z1(), p.facing());
int doorZ = b.southDoor() ? b.z1() : b.z0();
result.add(new Bathroom(b.name(), Math.min(x0, x1), b.floor() * STOREY, Math.min(z0, z1),
Math.max(x0, x1), b.floor() * STOREY + 4, Math.max(z0, z1), rx(b.doorX(), doorZ, p.facing()),
b.floor() * STOREY + 1, rz(b.doorX(), doorZ, p.facing())));
}
return List.copyOf(result);
}
private static void validate(Parcel p) {
if (!p.kind().building() || p.width() != 17 || p.depth() != 17 || p.floors() < 2)
throw new IllegalArgumentException("Generation-16 interiors require a 17x17 building with 2..5 floors");
}
private static void add(List<Room> result, Parcel p, String name, int x, int y, int z) {
result.add(new Room(name, rx(x, z, p.facing()), y, rz(x, z, p.facing())));
}
private static int rx(int x, int z, int facing) {
return switch (facing) { case 0 -> x; case 1 -> 16 - z; case 2 -> 16 - x; default -> z; };
}
private static int rz(int x, int z, int facing) {
return switch (facing) { case 0 -> z; case 1 -> x; case 2 -> 16 - z; default -> 16 - x; };
}
private static final class Building {
final Parcel p;
final LostCityRenderer.BlockWriter output;
final Rotation rotation;
Building(Parcel p, LostCityRenderer.BlockWriter output) {
this.p = p;
this.output = output;
rotation = switch (p.facing()) {
case 0 -> Rotation.NONE; case 1 -> Rotation.CLOCKWISE_90;
case 2 -> Rotation.CLOCKWISE_180; default -> Rotation.COUNTERCLOCKWISE_90;
};
}
void set(int x, int y, int z, BlockState state) {
output.set(rx(x, z, p.facing()), y, rz(x, z, p.facing()), state.rotate(rotation));
}
void set(int x, int y, int z, Block block) { set(x, y, z, block.defaultBlockState()); }
void box(int x0, int y0, int z0, int x1, int y1, int z1, BlockState state) {
for (int x = x0; x <= x1; x++) for (int z = z0; z <= z1; z++)
for (int y = y0; y <= y1; y++) set(x, y, z, state);
}
BlockState facing(Block block, Direction direction) {
return block.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, direction);
}
BlockState brick(int x, int y, int z) { return LostCityPalette.masonry(p.seed(), x, y, z); }
int roll(int x, int y, int z, int bound) { return LostCityPalette.roll(p.seed(), x, y, z, bound); }
void render() {
int roof = p.floors() * STOREY;
box(0, -p.foundationDepth(), 0, 16, -1, 16, STONE);
box(0, 0, 0, 16, 0, 16, SMOOTH);
box(0, 1, 0, 16, roof + 4, 16, AIR);
shell(roof);
for (int floor = 0; floor < p.floors(); floor++) {
partitions(floor);
furnishings(floor);
}
stairs();
entrance();
roof(roof);
// Only one deferred loot table per building; empty furniture is not a resource vault.
int lootX = 6;
int lootZ = 6;
box(lootX, 2, lootZ, lootX, 3, lootZ, AIR);
ResourceKey<LootTable> table = switch (p.kind()) {
case LIBRARY, HALL -> BuiltInLootTables.VILLAGE_CARTOGRAPHER;
case RESTAURANT -> BuiltInLootTables.VILLAGE_BUTCHER;
case BARRACKS -> BuiltInLootTables.VILLAGE_FLETCHER;
default -> BuiltInLootTables.VILLAGE_PLAINS_HOUSE;
};
output.chest(rx(lootX, lootZ, p.facing()), 1, rz(lootX, lootZ, p.facing()), table,
p.seed() ^ 0x16c17a7eL ^ p.kind().ordinal());
}
void shell(int roof) {
for (int floor = 0; floor <= p.floors(); floor++) {
int base = floor * STOREY;
box(1, base, 1, 15, base, 15, floor == p.floors() ? SMOOTH : floorMaterial());
if (floor == p.floors()) continue;
// A pale ceiling and contrasting structural floor are separate layers.
box(2, base + 4, 2, 14, base + 4, 14,
Blocks.SMOOTH_STONE_SLAB.defaultBlockState().setValue(BlockStateProperties.SLAB_TYPE, SlabType.TOP));
for (int y = 1; y <= 4; y++) for (int horizontal = 1; horizontal <= 15; horizontal++) {
facade(horizontal, base + y, 1, horizontal, y, true);
facade(horizontal, base + y, 15, horizontal, y, true);
facade(1, base + y, horizontal, horizontal, y, false);
facade(15, base + y, horizontal, horizontal, y, false);
}
// Floor bands remain intact even where windows have fallen out.
for (int n = 1; n <= 15; n++) {
set(n, base, 1, SMOOTH); set(n, base, 15, SMOOTH);
set(1, base, n, SMOOTH); set(15, base, n, SMOOTH);
}
if (p.kind() == Kind.HOTEL || p.kind() == Kind.LIBRARY) {
for (int x : new int[]{3, 13}) for (int y = 0; y <= 4; y++) set(x, base + y, 0, SMOOTH);
}
if (p.kind() == Kind.APARTMENT) for (int x = 11; x <= 14; x++)
set(x, base + 1, 0, Blocks.SMOOTH_STONE_SLAB);
if (p.kind() == Kind.RESTAURANT) box(10, base + 4, 0, 14, base + 4, 0,
Blocks.DARK_OAK_SLAB.defaultBlockState());
}
if (p.kind() == Kind.HALL) {
// The auditorium spans two storeys; its upper level is a guarded gallery.
box(3, 4, 11, 13, 5, 14, AIR);
for (int x = 3; x <= 13; x++) set(x, 6, 11, rail(true));
for (int z = 12; z <= 14; z++) { set(3, 6, z, rail(false)); set(13, 6, z, rail(false)); }
}
}
BlockState floorMaterial() {
return switch (p.kind()) {
case LIBRARY, APARTMENT, HOTEL -> WOOD;
case RESTAURANT, BARRACKS -> TILE;
default -> SMOOTH;
};
}
void facade(int x, int y, int z, int horizontal, int withinFloor, boolean eastWest) {
boolean column = horizontal == 1 || horizontal == 6 || horizontal == 10 || horizontal == 15;
boolean window = !column && (withinFloor == 2 || withinFloor == 3
|| withinFloor == 1 && (p.kind() == Kind.LIBRARY || p.kind() == Kind.RESTAURANT || p.kind() == Kind.HALL));
if (column) set(x, y, z, p.kind() == Kind.BARRACKS ? brick(x, y, z) : STONE);
else if (window) {
if (roll(x, y / 5, z, 13) != 0)
set(x, y, z, p.kind() == Kind.BARRACKS ? rail(eastWest) : pane(eastWest));
} else set(x, y, z, withinFloor == 4 ? SMOOTH : brick(x, y, z));
}
void partitions(int floor) {
int y = floor * STOREY;
int front = frontWall(p, floor);
// The west stairwell, central corridor and vestibule are independent of furniture.
box(5, y + 1, 2, 5, y + 4, 9, SMOOTH);
door(5, y + 1, 8, Direction.WEST, false);
box(10, y + 1, 2, 10, y + 4, front, SMOOTH);
box(10, y + 1, front, 15, y + 4, front, SMOOTH);
door(10, y + 1, 6, Direction.EAST, false);
if (floor == 0) {
box(6, 1, 4, 9, 4, 4, SMOOTH);
door(8, 1, 4, Direction.SOUTH, false);
}
if (p.kind() == Kind.HALL && floor < 2) {
if (floor == 0) {
box(2, 1, 10, 14, 4, 10, SMOOTH);
door(7, 1, 10, Direction.SOUTH, true);
door(8, 1, 10, Direction.SOUTH, false);
}
} else {
Area west = rear(p, floor, false), east = rear(p, floor, true);
box(1, y + 1, west.front() - 1, west.right() + 1, y + 4, west.front() - 1, SMOOTH);
box(west.right() + 1, y + 1, west.front() - 1, west.right() + 1, y + 4, 15, SMOOTH);
box(east.left() - 1, y + 1, east.front() - 1, 15, y + 4, east.front() - 1, SMOOTH);
box(east.left() - 1, y + 1, east.front() - 1, east.left() - 1, y + 4, 15, SMOOTH);
door(west.right() + 1, y + 1, 11, Direction.WEST, false);
door(east.left() - 1, y + 1, 11, Direction.EAST, false);
}
// A hanging lantern touches a full ceiling block, never the recessed underside of a top slab.
set(8, y + 4, 8, SMOOTH);
set(8, y + 3, 8, Blocks.LANTERN.defaultBlockState().setValue(BlockStateProperties.HANGING, true));
// Weathering stays on facades and roofs: it must not replace a privacy partition.
}
void furnishings(int floor) {
int y = floor * STOREY;
int variant = layout(p, floor);
switch (p.kind()) {
case APARTMENT -> {
if (variant == 0) { kitchenFront(y); bedroom(floor, false); }
else { frontBedroom(floor); kitchenRear(floor, false); }
}
case HOTEL -> {
if (floor == 0) { reception(y); lounge(floor, false); laundry(floor); }
else {
anchoredBed(14, y + 1, frontWall(p, floor) - 2, Direction.SOUTH);
set(11, y + 1, 5, Blocks.BARREL); set(11, y + 2, 5, Blocks.FLOWER_POT);
if (variant == 0) { bedroom(floor, false); bedroom(floor, true); }
else lounge(floor, false);
}
}
case LIBRARY -> {
if (variant == 0) {
box(14, y + 1, 2, 14, y + 3, 6, Blocks.BOOKSHELF.defaultBlockState());
box(12, y + 1, 2, 12, y + 2, 3, Blocks.BOOKSHELF.defaultBlockState());
desk(12, y, 5, Direction.SOUTH);
readingRoom(floor, false); archive(floor, true);
} else {
readingFront(y); readingRoom(floor, false);
set(2, y + 1, 14, Blocks.CARTOGRAPHY_TABLE);
}
}
case RESTAURANT -> {
if (variant == 0) {
table(13, y, 3); table(13, y, 5);
set(11, y + 1, 2, Blocks.POTTED_DEAD_BUSH);
kitchenRear(floor, true);
} else {
kitchenFront(y); diningRear(floor, false); pantry(floor, true);
}
}
case BARRACKS -> {
if (variant == 0) {
anchoredBed(12, y + 1, 3, Direction.NORTH); anchoredBed(14, y + 1, 3, Direction.NORTH);
set(11, y + 1, 2, Blocks.BARREL); set(11, y + 2, 2, Blocks.BARREL);
seat(13, y + 1, 6, Direction.NORTH);
} else { reception(y); bedroom(floor, false); }
box(12, y + 1, 14, 14, y + 2, 14, Blocks.BARREL.defaultBlockState());
set(14, y + 1, 11, Blocks.FLETCHING_TABLE);
set(12, y + 1, 11, Blocks.GRINDSTONE);
}
case HALL -> {
if (floor == 0 || floor > 1 && variant == 0) reception(y); else readingFront(y);
if (floor == 0) auditorium();
else if (floor >= 2) {
if (variant == 0) readingRoom(floor, false);
else { archive(floor, false); readingRoom(floor, true); }
}
}
default -> throw new IllegalArgumentException("Not an interior kind");
}
for (var bath : bathroomPlans(p)) if (bath.floor() == floor) bathroom(bath);
}
void kitchenFront(int y) {
kitchenLine(y, 11, 2, Direction.SOUTH);
table(13, y, 5);
}
void kitchenRear(int floor, boolean east) {
int y = floor * STOREY;
Area room = rear(p, floor, east);
kitchenLine(y, room.left(), 14, Direction.NORTH);
if (east) {
set(14, y + 1, 11, SMOOTH); set(14, y + 2, 11, Blocks.FLOWER_POT);
set(12, y + 1, 11, Blocks.CRAFTING_TABLE);
} else table(room.left() + 2, y, 11);
}
void kitchenLine(int y, int left, int z, Direction face) {
set(left, y + 1, z, facing(Blocks.FURNACE, face));
set(left + 1, y + 1, z, Blocks.WATER_CAULDRON.defaultBlockState().setValue(BlockStateProperties.LEVEL_CAULDRON, 2));
set(left + 2, y + 1, z, facing(Blocks.SMOKER, face));
set(left + 3, y + 1, z, Blocks.BARREL);
box(left, y + 3, z, left + 3, y + 3, z, SMOOTH);
set(left + 2, y + 2, z, Blocks.IRON_TRAPDOOR);
for (int x = left; x <= left + 3; x++) set(x, y, z, (x & 1) == 0 ? SMOOTH : TILE);
}
void bedroom(int floor, boolean east) {
int y = floor * STOREY;
Area room = rear(p, floor, east);
anchoredBed(east ? room.right() - 1 : room.left() + 1, y + 1, 13, east ? Direction.EAST : Direction.WEST);
set(east ? room.right() : room.left(), y + 1, 11, Blocks.BARREL);
desk(east ? room.left() + 1 : room.right() - 1, y, 11, Direction.SOUTH);
set(east ? room.left() : room.right(), y + 1, 14, Blocks.BOOKSHELF);
}
void frontBedroom(int floor) {
int y = floor * STOREY;
anchoredBed(14, y + 1, 3, Direction.NORTH);
set(11, y + 1, 2, Blocks.BARREL); set(11, y + 2, 2, Blocks.FLOWER_POT);
desk(12, y, 5, Direction.SOUTH);
}
void bathroom(BathPlan bath) {
int y = bath.floor() * STOREY;
for (int x = bath.x0(); x <= bath.x1(); x++) for (int z = bath.z0(); z <= bath.z1(); z++) {
set(x, y, z, ((x + z) & 1) == 0 ? SMOOTH : TILE);
set(x, y + 4, z, SMOOTH);
boolean wall = x == bath.x0() || x == bath.x1() || z == bath.z0() || z == bath.z1();
for (int up = 1; up <= 3; up++) set(x, y + up, z, wall ? SMOOTH : AIR);
}
int doorZ = bath.southDoor() ? bath.z1() : bath.z0();
door(bath.doorX(), y + 1, doorZ, bath.southDoor() ? Direction.NORTH : Direction.SOUTH, false);
int fixtureZ = bath.southDoor() ? bath.z0() + 1 : bath.z1() - 1;
int toiletX = bath.southDoor() ? bath.x1() - 2 : bath.x0() + 1;
Direction view = bath.southDoor() ? Direction.SOUTH : Direction.EAST;
set(toiletX, y + 1, fixtureZ, seating(Blocks.POLISHED_ANDESITE_STAIRS, view));
set(toiletX, y + 2, fixtureZ, Blocks.IRON_TRAPDOOR);
set(bath.x1() - 1, y + 1, fixtureZ, Blocks.WATER_CAULDRON.defaultBlockState().setValue(BlockStateProperties.LEVEL_CAULDRON, 2));
}
void reception(int y) {
box(12, y + 1, 5, 14, y + 1, 5, WOOD);
set(13, y + 2, 5, Blocks.FLOWER_POT);
set(14, y + 1, 2, Blocks.BOOKSHELF); set(14, y + 2, 2, Blocks.BARREL);
desk(12, y, 3, Direction.SOUTH);
set(11, y + 1, 2, Blocks.POTTED_DEAD_BUSH);
}
void laundry(int floor) {
int y = floor * STOREY;
for (int z = 11; z <= 12; z++) set(14, y + 1, z, Blocks.WATER_CAULDRON.defaultBlockState()
.setValue(BlockStateProperties.LEVEL_CAULDRON, z == 11 ? 1 : 2));
box(12, y + 1, 14, 14, y + 1, 14, Blocks.BARREL.defaultBlockState());
box(12, y + 3, 14, 14, y + 3, 14, Blocks.DARK_OAK_SLAB.defaultBlockState());
set(12, y + 1, 11, Blocks.LOOM);
}
void lounge(int floor, boolean east) {
int y = floor * STOREY;
Area room = rear(p, floor, east);
box(room.left(), y + 1, 14, room.left() + 2, y + 1, 14, seating(Blocks.DARK_OAK_STAIRS, Direction.NORTH));
desk(room.left() + 1, y, 11, Direction.SOUTH);
set(room.left(), y + 1, 12, Blocks.BOOKSHELF);
}
void readingRoom(int floor, boolean east) {
int y = floor * STOREY;
Area room = rear(p, floor, east);
desk(east ? room.left() + 1 : room.right() - 1, y, 11, Direction.SOUTH);
box(room.left(), y + 1, 14, room.right(), y + 2, 14, Blocks.BOOKSHELF.defaultBlockState());
set(east ? room.right() : room.left(), y + 1, 11, facing(Blocks.LECTERN, Direction.NORTH));
}
void readingFront(int y) {
desk(12, y, 3, Direction.SOUTH);
box(14, y + 1, 2, 14, y + 2, 5, Blocks.BOOKSHELF.defaultBlockState());
set(11, y + 1, 2, facing(Blocks.LECTERN, Direction.SOUTH));
}
void archive(int floor, boolean east) {
int y = floor * STOREY;
Area room = rear(p, floor, east);
box(room.left(), y + 1, 14, room.right(), y + 3, 14, Blocks.BOOKSHELF.defaultBlockState());
box(east ? room.right() : room.left(), y + 1, 11, east ? room.right() : room.left(), y + 2, 12, Blocks.BARREL.defaultBlockState());
set(room.left() + 1, y + 1, 12, facing(Blocks.LECTERN, Direction.NORTH));
}
void pantry(int floor, boolean east) {
int y = floor * STOREY;
Area room = rear(p, floor, east);
box(room.left(), y + 1, 14, room.right(), y + 2, 14, Blocks.BARREL.defaultBlockState());
set(room.right(), y + 1, 11, Blocks.CRAFTING_TABLE);
set(room.right(), y + 2, 11, Blocks.FLOWER_POT);
}
void diningRear(int floor, boolean east) {
Area room = rear(p, floor, east);
int y = floor * STOREY;
table(room.left() + 2, y, 12);
box(room.left(), y + 1, 14, room.right(), y + 1, 14, seating(Blocks.DARK_OAK_STAIRS, Direction.NORTH));
}
void auditorium() {
for (int x : new int[]{3, 4, 5, 11, 12, 13}) seat(x, 1, 11, Direction.SOUTH);
box(3, 1, 13, 13, 1, 14, WOOD);
set(8, 1, 12, facing(Blocks.DARK_OAK_STAIRS, Direction.SOUTH));
set(8, 2, 14, facing(Blocks.LECTERN, Direction.NORTH));
for (int x : new int[]{3, 13}) {
box(x, 2, 14, x, 3, 14, WOOD);
set(x, 4, 14, Blocks.STONE_BRICK_SLAB);
}
}
void stairs() {
for (int floor = 0; floor < p.floors(); floor++) {
int base = floor * STOREY;
for (int step = 1; step <= STOREY; step++) {
int z = step + 2;
box(2, base + step, z, 3, base + step + 3, z, AIR);
for (int x = 2; x <= 3; x++) set(x, base + step, z,
facing(Blocks.STONE_BRICK_STAIRS, Direction.SOUTH));
}
box(2, base + STOREY, 8, 4, base + STOREY, 9, TILE);
box(2, base + STOREY + 1, 8, 4, base + STOREY + 3, 9, AIR);
}
// This full-height side aisle joins every lower stair approach to its landing door.
for (int floor = 0; floor <= p.floors(); floor++) {
int y = floor * STOREY;
box(4, y + 1, 2, 4, y + 3, 9, AIR);
}
}
void entrance() {
for (int x : new int[]{6, 9}) box(x, 1, 0, x, 3, 0, SMOOTH);
box(6, 4, 0, 9, 4, 1, Blocks.STONE_BRICK_SLAB.defaultBlockState());
box(7, 0, 0, 8, 0, 3, TILE);
// Native SOUTH/RIGHT opens against the west jamb; SOUTH/LEFT against the east jamb.
door(7, 1, 1, Direction.SOUTH, true);
door(8, 1, 1, Direction.SOUTH, false);
set(4, 1, 0, Blocks.POTTED_DEAD_BUSH);
set(12, 1, 16, Blocks.COMPOSTER);
}
void roof(int y) {
for (int n = 1; n <= 15; n++) {
if (roll(n, y, 1, 9) != 0) set(n, y + 1, 1, brick(n, y, 1));
if (roll(n, y, 15, 9) != 0) set(n, y + 1, 15, brick(n, y, 15));
set(1, y + 1, n, brick(1, y, n)); set(15, y + 1, n, brick(15, y, n));
}
box(11, y + 1, 11, 13, y + 1, 13, SMOOTH);
set(12, y + 2, 12, Blocks.IRON_TRAPDOOR);
set(6, y + 1, 13, Blocks.POTTED_DEAD_BUSH);
set(14, y + 2, 14, Blocks.COBWEB);
}
void door(int x, int y, int z, Direction facing, boolean rightHinge) {
BlockState state = facing(Blocks.DARK_OAK_DOOR, facing)
.setValue(BlockStateProperties.DOOR_HINGE, rightHinge ? DoorHingeSide.RIGHT : DoorHingeSide.LEFT)
.setValue(BlockStateProperties.OPEN, false)
.setValue(BlockStateProperties.POWERED, false);
set(x, y, z, state.setValue(BlockStateProperties.DOUBLE_BLOCK_HALF, DoubleBlockHalf.LOWER));
set(x, y + 1, z, state.setValue(BlockStateProperties.DOUBLE_BLOCK_HALF, DoubleBlockHalf.UPPER));
}
void anchoredBed(int x, int y, int z, Direction facing) {
BlockState state = facing(Blocks.BED.white(), facing);
set(x, y, z, state.setValue(BlockStateProperties.BED_PART, BedPart.FOOT));
set(x + facing.getStepX(), y, z + facing.getStepZ(), state.setValue(BlockStateProperties.BED_PART, BedPart.HEAD));
int wallX = x + facing.getStepX() * 2, wallZ = z + facing.getStepZ() * 2;
Direction alongWall = facing.getClockWise();
for (int width = -1; width <= 1; width++) for (int up = 0; up <= 3; up++)
set(wallX + alongWall.getStepX() * width, y + up, wallZ + alongWall.getStepZ() * width, SMOOTH);
}
void table(int x, int y, int z) {
set(x, y + 1, z, Blocks.DARK_OAK_FENCE); set(x, y + 2, z, Blocks.DARK_OAK_PRESSURE_PLATE);
seat(x - 1, y + 1, z, Direction.EAST);
seat(x + 1, y + 1, z, Direction.WEST);
}
void desk(int x, int y, int z, Direction approachSide) {
set(x, y + 1, z, Blocks.DARK_OAK_FENCE); set(x, y + 2, z, Blocks.DARK_OAK_PRESSURE_PLATE);
seat(x + approachSide.getStepX(), y + 1, z + approachSide.getStepZ(), approachSide.getOpposite());
}
void seat(int x, int y, int z, Direction lookingToward) {
set(x, y, z, seating(Blocks.DARK_OAK_STAIRS, lookingToward));
}
BlockState seating(Block stair, Direction lookingToward) {
// Stair FACING points uphill, into its full-height back, opposite a seated player's view.
return facing(stair, lookingToward.getOpposite());
}
BlockState rail(boolean eastWest) { return connected(Blocks.IRON_BARS, eastWest); }
BlockState pane(boolean eastWest) { return connected(Blocks.GLASS_PANE, eastWest); }
BlockState connected(Block block, boolean eastWest) {
return block.defaultBlockState().setValue(BlockStateProperties.EAST, eastWest)
.setValue(BlockStateProperties.WEST, eastWest).setValue(BlockStateProperties.NORTH, !eastWest)
.setValue(BlockStateProperties.SOUTH, !eastWest);
}
}
}
@@ -0,0 +1,37 @@
package fr.koka.sanctuary.worldgen.city;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
/** Restrained masonry for the abandoned city; weathering never changes the structural palette. */
public final class LostCityPalette {
public static final BlockState STONE = Blocks.STONE.defaultBlockState();
public static final BlockState SMOOTH = Blocks.SMOOTH_STONE.defaultBlockState();
public static final BlockState BRICK = Blocks.STONE_BRICKS.defaultBlockState();
public static final BlockState AIR = Blocks.AIR.defaultBlockState();
private LostCityPalette() {}
public static BlockState masonry(long seed, int x, int y, int z) {
int weather = roll(seed, x / 3, y / 2, z / 3, 29);
if (weather < 4) return Blocks.MOSSY_STONE_BRICKS.defaultBlockState();
if (weather < 8) return Blocks.CRACKED_STONE_BRICKS.defaultBlockState();
return BRICK;
}
public static BlockState pavement(long seed, int x, int z) {
int wear = roll(seed, x / 3, 0, z / 3, 31);
if (wear < 3) return Blocks.ANDESITE.defaultBlockState();
if (wear == 3) return Blocks.CRACKED_STONE_BRICKS.defaultBlockState();
return STONE;
}
public static int roll(long seed, int x, int y, int z, int bound) {
long value = seed ^ ((long) x * 0x9e3779b97f4a7c15L)
^ ((long) y * 0xc2b2ae3d27d4eb4fL) ^ ((long) z * 0x165667b19e3779f9L);
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
value ^= value >>> 31;
return (int) Long.remainderUnsigned(value, bound);
}
}
@@ -0,0 +1,88 @@
package fr.koka.sanctuary.worldgen.city;
import java.util.Arrays;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.resources.ResourceKey;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.StructureManager;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.StructurePiece;
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
import net.minecraft.world.level.storage.loot.LootTable;
/** A persisted parcel is sufficient to decorate a later chunk without recomputing the district. */
public final class LostCityPiece extends StructurePiece {
private static final int SCHEMA = 1;
private final LostCityPlan.Parcel parcel;
public LostCityPiece(LostCityPlan.Parcel parcel) {
super(LostCityStructures.PIECE, 0, bounds(parcel));
this.parcel = parcel;
setOrientation(null);
}
public LostCityPiece(StructurePieceSerializationContext context, CompoundTag tag) {
super(LostCityStructures.PIECE, tag);
if (tag.getInt("SanctuarySchema").orElseThrow() != SCHEMA)
throw new IllegalArgumentException("Unsupported Lost City piece schema");
parcel = new LostCityPlan.Parcel(LostCityPlan.Kind.valueOf(tag.getString("Kind").orElseThrow()),
tag.getInt("MinX").orElseThrow(), tag.getInt("MinZ").orElseThrow(),
tag.getInt("Width").orElseThrow(), tag.getInt("Depth").orElseThrow(),
tag.getInt("FloorY").orElseThrow(), tag.getInt("Floors").orElseThrow(),
tag.getInt("Facing").orElseThrow(), tag.getLong("ParcelSeed").orElseThrow(),
tag.getInt("FoundationDepth").orElseThrow(), tag.getIntArray("SurfaceHeights").orElseThrow());
if (!getBoundingBox().equals(bounds(parcel)))
throw new IllegalArgumentException("Lost City parcel disagrees with its saved native bounding box");
setOrientation(null);
}
public LostCityPlan.Parcel parcel() { return parcel; }
@Override public BlockPos getLocatorPosition() {
int x = parcel.width() / 2, z = parcel.depth() / 2;
return new BlockPos(parcel.minX() + x, parcel.surfaceY(x, z) + 1, parcel.minZ() + z);
}
@Override protected void addAdditionalSaveData(StructurePieceSerializationContext context, CompoundTag tag) {
tag.putInt("SanctuarySchema", SCHEMA);
tag.putString("Kind", parcel.kind().name());
tag.putInt("MinX", parcel.minX()); tag.putInt("MinZ", parcel.minZ());
tag.putInt("Width", parcel.width()); tag.putInt("Depth", parcel.depth());
tag.putInt("FloorY", parcel.floorY()); tag.putInt("Floors", parcel.floors());
tag.putInt("Facing", parcel.facing()); tag.putLong("ParcelSeed", parcel.seed());
tag.putInt("FoundationDepth", parcel.foundationDepth());
tag.putIntArray("SurfaceHeights", parcel.surfaceHeights());
}
@Override public void postProcess(WorldGenLevel level, StructureManager structures, ChunkGenerator generator,
RandomSource random, BoundingBox chunkClip, ChunkPos chunkPos, BlockPos pivot) {
LostCityStructures.placing(() -> LostCityRenderer.render(parcel, new LostCityRenderer.BlockWriter() {
@Override public void set(int x, int y, int z, BlockState block) {
BlockPos pos = world(x, y, z);
if (getBoundingBox().isInside(pos) && chunkClip.isInside(pos))
placeBlock(level, block, pos.getX(), pos.getY(), pos.getZ(), chunkClip);
}
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> loot, long seed) {
BlockPos pos = world(x, y, z);
if (getBoundingBox().isInside(pos) && chunkClip.isInside(pos))
createChest(level, chunkClip, RandomSource.create(seed), pos, loot, null);
}
}));
}
private BlockPos world(int x, int y, int z) {
return new BlockPos(parcel.minX() + x, parcel.floorY() + y, parcel.minZ() + z);
}
private static BoundingBox bounds(LostCityPlan.Parcel parcel) {
int low = Math.min(parcel.floorY(), Arrays.stream(parcel.surfaceHeights()).min().orElse(parcel.floorY()))
- parcel.foundationDepth();
int high = Math.max(parcel.maxY(), Arrays.stream(parcel.surfaceHeights()).max().orElse(parcel.floorY()) + 5);
return new BoundingBox(parcel.minX(), low, parcel.minZ(), parcel.maxX(), high, parcel.maxZ());
}
}
@@ -0,0 +1,92 @@
package fr.koka.sanctuary.worldgen.city;
import java.util.Arrays;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.resources.ResourceKey;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.StructureManager;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.StructurePiece;
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
import net.minecraft.world.level.storage.loot.LootTable;
/** Generation15 surface parcel. Its distinct native type preserves the historical generation14 renderer. */
public final class LostCityPiece15 extends StructurePiece {
private static final int SCHEMA = 1;
private final LostCityPlan.Parcel parcel;
public LostCityPiece15(LostCityPlan.Parcel parcel) {
super(LostCityStructures.PIECE15, 0, bounds(parcel));
this.parcel = parcel;
setOrientation(null);
}
public LostCityPiece15(StructurePieceSerializationContext context, CompoundTag tag) {
super(LostCityStructures.PIECE15, tag);
if (tag.getInt("SanctuarySchema").orElseThrow() != SCHEMA)
throw new IllegalArgumentException("Unsupported Lost City piece schema");
parcel = new LostCityPlan.Parcel(LostCityPlan.Kind.valueOf(tag.getString("Kind").orElseThrow()),
tag.getInt("MinX").orElseThrow(), tag.getInt("MinZ").orElseThrow(),
tag.getInt("Width").orElseThrow(), tag.getInt("Depth").orElseThrow(),
tag.getInt("FloorY").orElseThrow(), tag.getInt("Floors").orElseThrow(),
tag.getInt("Facing").orElseThrow(), tag.getLong("ParcelSeed").orElseThrow(),
tag.getInt("FoundationDepth").orElseThrow(), tag.getIntArray("SurfaceHeights").orElseThrow());
if (!getBoundingBox().equals(bounds(parcel)))
throw new IllegalArgumentException("Lost City parcel disagrees with its saved native bounding box");
setOrientation(null);
}
public LostCityPlan.Parcel parcel() { return parcel; }
@Override public BlockPos getLocatorPosition() {
int x = parcel.width() / 2, z = parcel.depth() / 2;
return new BlockPos(parcel.minX() + x, parcel.surfaceY(x, z) + 1, parcel.minZ() + z);
}
@Override protected void addAdditionalSaveData(StructurePieceSerializationContext context, CompoundTag tag) {
tag.putInt("SanctuarySchema", SCHEMA);
tag.putString("Kind", parcel.kind().name());
tag.putInt("MinX", parcel.minX()); tag.putInt("MinZ", parcel.minZ());
tag.putInt("Width", parcel.width()); tag.putInt("Depth", parcel.depth());
tag.putInt("FloorY", parcel.floorY()); tag.putInt("Floors", parcel.floors());
tag.putInt("Facing", parcel.facing()); tag.putLong("ParcelSeed", parcel.seed());
tag.putInt("FoundationDepth", parcel.foundationDepth());
tag.putIntArray("SurfaceHeights", parcel.surfaceHeights());
}
@Override public void postProcess(WorldGenLevel level, StructureManager structures, ChunkGenerator generator,
RandomSource random, BoundingBox chunkClip, ChunkPos chunkPos, BlockPos pivot) {
LostCityStructures.placing(() -> render(parcel, new LostCityRenderer.BlockWriter() {
@Override public void set(int x, int y, int z, BlockState block) {
BlockPos pos = world(x, y, z);
if (getBoundingBox().isInside(pos) && chunkClip.isInside(pos))
placeBlock(level, block, pos.getX(), pos.getY(), pos.getZ(), chunkClip);
}
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> loot, long seed) {
BlockPos pos = world(x, y, z);
if (getBoundingBox().isInside(pos) && chunkClip.isInside(pos))
createChest(level, chunkClip, RandomSource.create(seed), pos, loot, null);
}
}));
}
private static void render(LostCityPlan.Parcel parcel, LostCityRenderer.BlockWriter writer) {
LostCitySurface15.render(parcel, writer);
}
private BlockPos world(int x, int y, int z) {
return new BlockPos(parcel.minX() + x, parcel.floorY() + y, parcel.minZ() + z);
}
private static BoundingBox bounds(LostCityPlan.Parcel parcel) {
int low = Math.min(parcel.floorY(), Arrays.stream(parcel.surfaceHeights()).min().orElse(parcel.floorY()))
- parcel.foundationDepth();
int high = Math.max(parcel.maxY(), Arrays.stream(parcel.surfaceHeights()).max().orElse(parcel.floorY()) + 5);
return new BoundingBox(parcel.minX(), low, parcel.minZ(), parcel.maxX(), high, parcel.maxZ());
}
}
@@ -0,0 +1,92 @@
package fr.koka.sanctuary.worldgen.city;
import java.util.Arrays;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.resources.ResourceKey;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.StructureManager;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.StructurePiece;
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
import net.minecraft.world.level.storage.loot.LootTable;
/** Generation16 surface parcel. Its distinct native type preserves the historical generation14 renderer. */
public final class LostCityPiece16 extends StructurePiece {
private static final int SCHEMA = 1;
private final LostCityPlan.Parcel parcel;
public LostCityPiece16(LostCityPlan.Parcel parcel) {
super(LostCityStructures.PIECE16, 0, bounds(parcel));
this.parcel = parcel;
setOrientation(null);
}
public LostCityPiece16(StructurePieceSerializationContext context, CompoundTag tag) {
super(LostCityStructures.PIECE16, tag);
if (tag.getInt("SanctuarySchema").orElseThrow() != SCHEMA)
throw new IllegalArgumentException("Unsupported Lost City piece schema");
parcel = new LostCityPlan.Parcel(LostCityPlan.Kind.valueOf(tag.getString("Kind").orElseThrow()),
tag.getInt("MinX").orElseThrow(), tag.getInt("MinZ").orElseThrow(),
tag.getInt("Width").orElseThrow(), tag.getInt("Depth").orElseThrow(),
tag.getInt("FloorY").orElseThrow(), tag.getInt("Floors").orElseThrow(),
tag.getInt("Facing").orElseThrow(), tag.getLong("ParcelSeed").orElseThrow(),
tag.getInt("FoundationDepth").orElseThrow(), tag.getIntArray("SurfaceHeights").orElseThrow());
if (!getBoundingBox().equals(bounds(parcel)))
throw new IllegalArgumentException("Lost City parcel disagrees with its saved native bounding box");
setOrientation(null);
}
public LostCityPlan.Parcel parcel() { return parcel; }
@Override public BlockPos getLocatorPosition() {
int x = parcel.width() / 2, z = parcel.depth() / 2;
return new BlockPos(parcel.minX() + x, parcel.surfaceY(x, z) + 1, parcel.minZ() + z);
}
@Override protected void addAdditionalSaveData(StructurePieceSerializationContext context, CompoundTag tag) {
tag.putInt("SanctuarySchema", SCHEMA);
tag.putString("Kind", parcel.kind().name());
tag.putInt("MinX", parcel.minX()); tag.putInt("MinZ", parcel.minZ());
tag.putInt("Width", parcel.width()); tag.putInt("Depth", parcel.depth());
tag.putInt("FloorY", parcel.floorY()); tag.putInt("Floors", parcel.floors());
tag.putInt("Facing", parcel.facing()); tag.putLong("ParcelSeed", parcel.seed());
tag.putInt("FoundationDepth", parcel.foundationDepth());
tag.putIntArray("SurfaceHeights", parcel.surfaceHeights());
}
@Override public void postProcess(WorldGenLevel level, StructureManager structures, ChunkGenerator generator,
RandomSource random, BoundingBox chunkClip, ChunkPos chunkPos, BlockPos pivot) {
LostCityStructures.placing(() -> render(parcel, new LostCityRenderer.BlockWriter() {
@Override public void set(int x, int y, int z, BlockState block) {
BlockPos pos = world(x, y, z);
if (getBoundingBox().isInside(pos) && chunkClip.isInside(pos))
placeBlock(level, block, pos.getX(), pos.getY(), pos.getZ(), chunkClip);
}
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> loot, long seed) {
BlockPos pos = world(x, y, z);
if (getBoundingBox().isInside(pos) && chunkClip.isInside(pos))
createChest(level, chunkClip, RandomSource.create(seed), pos, loot, null);
}
}));
}
private static void render(LostCityPlan.Parcel parcel, LostCityRenderer.BlockWriter writer) {
LostCitySurface16.render(parcel, writer);
}
private BlockPos world(int x, int y, int z) {
return new BlockPos(parcel.minX() + x, parcel.floorY() + y, parcel.minZ() + z);
}
private static BoundingBox bounds(LostCityPlan.Parcel parcel) {
int low = Math.min(parcel.floorY(), Arrays.stream(parcel.surfaceHeights()).min().orElse(parcel.floorY()))
- parcel.foundationDepth();
int high = Math.max(parcel.maxY(), Arrays.stream(parcel.surfaceHeights()).max().orElse(parcel.floorY()) + 5);
return new BoundingBox(parcel.minX(), low, parcel.minZ(), parcel.maxX(), high, parcel.maxZ());
}
}
@@ -0,0 +1,97 @@
package fr.koka.sanctuary.worldgen.city;
import java.util.List;
/** Immutable, bounded city geometry. Coordinates and block elevations are absolute.
* The plan uses only pre-decoration terrain, never loaded or player-edited chunks. */
public final class LostCityPlan {
public static final LostCityPlan EMPTY = new LostCityPlan(List.of());
private final List<District> districts;
public LostCityPlan(List<District> districts) { this.districts = List.copyOf(districts); }
public List<District> districts() { return districts; }
public boolean isEmpty() { return districts.isEmpty(); }
/** City biomes follow accepted parcels, leaving underground cave biomes intact. */
public boolean contains(int x, int y, int z) {
for (District district : districts) for (Parcel parcel : district.parcels()) {
if (x >= parcel.minX() - 2 && x <= parcel.maxX() + 2
&& z >= parcel.minZ() - 2 && z <= parcel.maxZ() + 2
&& y >= parcel.floorY() - 8 && y <= 383) return true;
}
return false;
}
public boolean intersects(int minX, int minZ, int maxX, int maxZ) {
for (District district : districts) for (Parcel parcel : district.parcels()) {
if (minX <= parcel.maxX() + 2 && maxX >= parcel.minX() - 2
&& minZ <= parcel.maxZ() + 2 && maxZ >= parcel.minZ() - 2) return true;
}
return false;
}
public enum Kind {
STREET, BRIDGE, PARK, PLAZA, MARKET, PARKING,
APARTMENT, HOTEL, LIBRARY, RESTAURANT, BARRACKS, HALL,
EXCAVATION, POOL, PARK_GARDEN, PARK_PLAYGROUND, PARK_MEMORIAL;
public boolean building() {
return switch (this) {
case APARTMENT, HOTEL, LIBRARY, RESTAURANT, BARRACKS, HALL -> true;
default -> false;
};
}
}
public record District(int startChunkX, int startChunkZ, long seed, List<Parcel> parcels) {
public District {
parcels = List.copyOf(parcels);
if (parcels.isEmpty()) throw new IllegalArgumentException("Empty city district");
for (Parcel parcel : parcels) {
if (Math.abs(Math.floorDiv(parcel.minX(), 16) - startChunkX) > 7
|| Math.abs(Math.floorDiv(parcel.maxX(), 16) - startChunkX) > 7
|| Math.abs(Math.floorDiv(parcel.minZ(), 16) - startChunkZ) > 7
|| Math.abs(Math.floorDiv(parcel.maxZ(), 16) - startChunkZ) > 7)
throw new IllegalArgumentException("City parcel exceeds native reference radius");
}
}
public int minX() { return parcels.stream().mapToInt(Parcel::minX).min().orElseThrow(); }
public int maxX() { return parcels.stream().mapToInt(Parcel::maxX).max().orElseThrow(); }
public int minZ() { return parcels.stream().mapToInt(Parcel::minZ).min().orElseThrow(); }
public int maxZ() { return parcels.stream().mapToInt(Parcel::maxZ).max().orElseThrow(); }
}
/** surfaceHeights is empty for a level parcel, or width*depth elevations for a street/ramp.
* Facing: 0 north, 1 east, 2 south, 3 west. Road coordinates are already world-aligned. */
public record Parcel(Kind kind, int minX, int minZ, int width, int depth,
int floorY, int floors, int facing, long seed, int foundationDepth, int[] surfaceHeights) {
public Parcel {
if (kind == null || width < 1 || depth < 1 || width > 25 || depth > 25
|| floors < 0 || floors > 5 || facing < 0 || facing > 3
|| foundationDepth < 0 || foundationDepth > 12
|| floorY < 48 || floorY + Math.max(8, floors * 5 + 5) > 382)
throw new IllegalArgumentException("Invalid city parcel dimensions");
surfaceHeights = surfaceHeights.clone();
if (surfaceHeights.length != 0 && surfaceHeights.length != width * depth)
throw new IllegalArgumentException("Invalid city road elevations");
for (int y : surfaceHeights) if (y < 48 || y > 370)
throw new IllegalArgumentException("Invalid road height");
}
@Override public int[] surfaceHeights() { return surfaceHeights.clone(); }
public int surfaceY(int localX, int localZ) {
return surfaceHeights.length == 0 ? floorY : surfaceHeights[localZ * width + localX];
}
public int maxX() { return minX + width - 1; }
public int maxZ() { return minZ + depth - 1; }
public int minY() {
int y = floorY;
for (int height : surfaceHeights) y = Math.min(y, height);
return y - foundationDepth;
}
public int maxY() {
int y = floorY;
for (int height : surfaceHeights) y = Math.max(y, height);
return y + Math.max(8, floors * 5 + 5);
}
public int maxRoofY() { return maxY(); }
}
}
@@ -0,0 +1,316 @@
package fr.koka.sanctuary.worldgen.city;
import fr.koka.sanctuary.worldgen.city.LostCityPlan.District;
import fr.koka.sanctuary.worldgen.city.LostCityPlan.Kind;
import fr.koka.sanctuary.worldgen.city.LostCityPlan.Parcel;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** A small connected, terraced street grid fitted to actual immutable noise columns.
* Planning never loads a chunk, edits terrain, or retries indefinitely to force a city.
* Unsupported parcels and protected hydrology remain natural, including on the starter island. */
public final class LostCityPlanner {
private static final int CELL = 24;
private static final int LOT = 17;
private static final int MIN_FLOOR = 176;
private static final int MAX_COLUMNS = 16_000;
private static final Ground INVALID = new Ground(0, 0, false, true);
private static final Kind[] KINDS = {
Kind.APARTMENT, Kind.PARK, Kind.LIBRARY, Kind.RESTAURANT,
Kind.PLAZA, Kind.HOTEL, Kind.MARKET, Kind.BARRACKS,
Kind.PARKING, Kind.HALL, Kind.APARTMENT, Kind.PARK,
Kind.HOTEL, Kind.PLAZA, Kind.HALL, Kind.RESTAURANT,
Kind.EXCAVATION, Kind.POOL, Kind.PARK_GARDEN, Kind.PARK_PLAYGROUND, Kind.PARK_MEMORIAL
};
private LostCityPlanner() {}
public record Ground(int top, int solidDepth, boolean dry, boolean protectedTerrain) {}
@FunctionalInterface public interface GroundSampler { Ground sample(int x, int z); }
public static LostCityPlan plan(long seed, int centerX, int centerZ, int diameter,
boolean origin, GroundSampler sampler) {
if (diameter < 64 || diameter > 1024) throw new IllegalArgumentException("Unsupported city island size");
int cells = origin ? 3 : diameter >= 512 ? 4 : diameter >= 256 ? 3 : diameter >= 128 ? 2 : 1;
TerrainCache survey = new TerrainCache(centerX, centerZ, diameter, sampler);
List<Candidate> candidates = candidates(seed, centerX, centerZ, diameter, origin, cells, survey);
candidates.sort(Comparator.comparingInt(Candidate::score).reversed().thenComparingLong(Candidate::salt));
List<Parcel> best = List.of();
Candidate chosen = null;
List<Integer> acceptedPerAttempt = new ArrayList<>();
// A bounded fallback is useful when a coarse footprint concealed a fissure.
for (int i = 0; i < Math.min(3, candidates.size()); i++) {
Candidate candidate = candidates.get(i);
if (candidate.score() == 0 || survey.exhausted()) break;
List<Parcel> parcels = build(seed, candidate, cells, survey);
acceptedPerAttempt.add(lotCount(parcels));
if (lotCount(parcels) > lotCount(best)) { best = parcels; chosen = candidate; }
if (lotCount(best) >= (origin ? 5 : Math.min(6, cells * cells))) break;
}
if (chosen == null || lotCount(best) < (cells == 1 ? 1 : 2)
|| best.stream().noneMatch(p -> p.kind().building())) {
if (Boolean.getBoolean("sanctuary.test.lostCity") || Boolean.getBoolean("sanctuary.city.plannerDiagnostics"))
System.getLogger(LostCityPlanner.class.getName()).log(System.Logger.Level.INFO,
"Lost City rejected seed=" + seed + " diameter=" + diameter + " origin=" + origin
+ " coarse=" + candidates.stream().map(Candidate::score).toList()
+ " connected=" + acceptedPerAttempt + " " + survey.diagnostics());
return LostCityPlan.EMPTY;
}
return new LostCityPlan(List.of(new District(Math.floorDiv(chosen.x() + cells * CELL / 2, 16),
Math.floorDiv(chosen.z() + cells * CELL / 2, 16), mix(seed ^ chosen.salt()), best)));
}
private static int lotCount(List<Parcel> parcels) {
return (int) parcels.stream().filter(p -> p.kind() != Kind.STREET && p.kind() != Kind.BRIDGE).count();
}
private static List<Candidate> candidates(long seed, int cx, int cz, int diameter,
boolean origin, int cells, TerrainCache survey) {
List<Candidate> result = new ArrayList<>();
int attempts = origin ? 12 : 9;
double angle = unit(mix(seed ^ 0xC174A14L)) * Math.PI * 2;
for (int attempt = 0; attempt < attempts; attempt++) {
long salt = mix(seed ^ (attempt * 0x9E3779B97F4A7C15L) ^ 0xC17D157L);
double radius = origin ? Math.min(diameter * .20, 150) * (attempt < 8 ? 1 : .48)
: attempt == 0 ? 0 : diameter * .13;
double a = angle + attempt * Math.PI * 2 / 8;
int x = cx + (int) Math.round(Math.cos(a) * radius) - cells * CELL / 2;
int z = cz + (int) Math.round(Math.sin(a) * radius) - cells * CELL / 2;
int score = 0;
for (int row = 0; row < cells; row++) for (int col = 0; col < cells; col++) {
if (coarseLot(x + col * CELL + 4, z + row * CELL + 4, survey)) score++;
}
result.add(new Candidate(x, z, score, salt));
}
return result;
}
private static boolean coarseLot(int x, int z, TerrainCache survey) {
int low = Integer.MAX_VALUE, high = Integer.MIN_VALUE;
for (int[] point : new int[][] {{0, 0}, {16, 0}, {0, 16}, {16, 16}, {8, 8}, {8, 0}}) {
Ground g = survey.at(x + point[0], z + point[1]);
if (!supported(g)) { survey.reject("coarse-support"); return false; }
low = Math.min(low, g.top()); high = Math.max(high, g.top());
}
int floor = lotFloor(low, high);
if (low < floor - 10 || high > floor + 2) { survey.reject("coarse-slope"); return false; }
if (floor < MIN_FLOOR || floor > 350) { survey.reject("coarse-height"); return false; }
return true;
}
private static List<Parcel> build(long seed, Candidate site, int cells, TerrainCache survey) {
int[][] heights = new int[cells + 1][cells + 1];
for (int row = 0; row <= cells; row++) for (int col = 0; col <= cells; col++) {
Ground g = survey.at(site.x() + col * CELL, site.z() + row * CELL);
heights[row][col] = supported(g) && g.top() < 360 ? g.top() + 1 : -1;
}
List<Edge> edges = new ArrayList<>();
for (int row = 0; row < cells; row++) for (int col = 0; col < cells; col++) {
int x = site.x() + col * CELL, z = site.z() + row * CELL;
long parcelSeed = mix(seed ^ site.salt() ^ col * 0xD1342543DE82EF95L ^ row * 0xC6BC279692B5CC83L);
int kindOffset = (int) Long.remainderUnsigned(mix(seed ^ 0xC174CA7L), KINDS.length);
Kind kind = cells == 1 ? Kind.APARTMENT : KINDS[Math.floorMod(row * cells + col + kindOffset, KINDS.length)];
Parcel lot = lot(kind, x + 4, z + 4, parcelSeed, survey);
int middleY = lot == null ? survey.at(x + CELL / 2, z).top() + 1 : lot.floorY();
Parcel road = road(x, z, true, heights[row][col], middleY, heights[row][col + 1], parcelSeed ^ 31, survey);
if (road == null) continue;
Parcel entrance = lot == null ? null : entrance(lot, survey);
if (entrance == null) lot = null;
List<Parcel> pieces = new ArrayList<>();
pieces.add(road);
if (lot != null) { pieces.add(lot); pieces.add(entrance); }
edges.add(new Edge(node(col, row, cells), node(col + 1, row, cells), List.copyOf(pieces)));
}
// Cross streets join the rows; short unsupported runs can be bridge decks.
// Endpoints and the first/last two columns must have real, dry abutments.
for (int row = 0; row < cells - 1; row++) for (int col = 0; col <= cells; col++) {
int y0 = heights[row][col], y1 = heights[row + 1][col];
Parcel road = road(site.x() + col * CELL, site.z() + row * CELL, false,
y0, y0 < 0 || y1 < 0 ? -1 : (y0 + y1) / 2, y1,
mix(seed ^ site.salt() ^ row * 479L ^ col * 1861L), survey);
if (road != null) edges.add(new Edge(node(col, row, cells), node(col, row + 1, cells), List.of(road)));
}
return largestConnectedDistrict(edges);
}
private static Parcel lot(Kind kind, int x, int z, long seed, TerrainCache survey) {
if (!coarseLot(x, z, survey)) return null;
int low = Integer.MAX_VALUE, high = Integer.MIN_VALUE;
for (int dz = 0; dz < LOT; dz++) for (int dx = 0; dx < LOT; dx++) {
Ground g = survey.at(x + dx, z + dz);
if (!supported(g)) { survey.reject("lot-support"); return null; }
low = Math.min(low, g.top()); high = Math.max(high, g.top());
}
int floor = lotFloor(low, high);
if (low < floor - 10 || high > floor + 2) { survey.reject("lot-slope"); return null; }
if (floor < MIN_FLOOR || floor > 350) { survey.reject("lot-height"); return null; }
int floors = switch (kind) {
case APARTMENT, HOTEL -> 3 + (int) Long.remainderUnsigned(seed, 3);
case LIBRARY, RESTAURANT, BARRACKS, HALL -> 2;
case PARKING, MARKET -> 1;
default -> 0;
};
int foundation = Math.max(2, floor - low + 1);
if (kind == Kind.PARKING || kind == Kind.POOL || kind == Kind.EXCAVATION) {
// >=8 certifies a contained excavation, pool or optional underground garage.
// Every existing surface must cover the ceiling and solid rock must cover its floor.
if (low >= floor - 1 && solidTo(x, z, LOT, LOT, floor - 8, survey)) foundation = 8;
else if (kind != Kind.PARKING) kind = Kind.PARK;
else if (foundation >= 8) return null;
}
if (!solidTo(x, z, LOT, LOT, floor - foundation, survey)) { survey.reject("lot-foundation"); return null; }
// Every parcel faces its street to the north. Variety comes from the seed,
// facade details and building types, never from a door pointed into another building.
return new Parcel(kind, x, z, LOT, LOT, floor, floors, 0, seed,
foundation, new int[0]);
}
private static Parcel entrance(Parcel lot, TerrainCache survey) {
int x = lot.minX() + LOT / 2 - 1, z = lot.minZ() - 2, floor = lot.floorY(), foundation = 2;
for (int dz = 0; dz < 2; dz++) for (int dx = 0; dx < 3; dx++) {
Ground g = survey.at(x + dx, z + dz);
if (!supported(g) || g.top() < floor - 10 || g.top() > floor + 2) return null;
foundation = Math.max(foundation, floor - g.top() + 1);
}
if (!solidTo(x, z, 3, 2, floor - foundation, survey)) return null;
return new Parcel(Kind.STREET, x, z, 3, 2, floor, 0, 0, lot.seed() ^ 63, foundation, new int[0]);
}
private static Parcel road(int x, int z, boolean alongX, int startY, int middleY, int endY,
long seed, TerrainCache survey) {
if (startY < MIN_FLOOR || middleY < MIN_FLOOR || endY < MIN_FLOOR || startY > 360 || middleY > 360 || endY > 360
|| Math.abs(startY - middleY) > CELL / 2 - 2 || Math.abs(endY - middleY) > CELL / 2 - 2) {
survey.reject("road-endpoint-height"); return null;
}
int width = alongX ? CELL + 1 : 5, depth = alongX ? 5 : CELL + 1;
int minX = alongX ? x : x - 2, minZ = alongX ? z - 2 : z;
int[] surface = new int[width * depth];
int foundation = 2, gapRun = 0;
boolean bridge = false;
for (int step = 0; step <= CELL; step++) {
// Flat 5x5 intersections agree in both directions; ramps start beyond them.
int y = step < 2 ? startY : step > CELL - 2 ? endY : step <= CELL / 2
? lerp(startY, middleY, step - 2, CELL / 2 - 2)
: lerp(middleY, endY, step - CELL / 2, CELL / 2 - 2);
boolean gap = false;
for (int across = 0; across < 5; across++) {
int dx = alongX ? step : across, dz = alongX ? across : step;
Ground g = survey.at(minX + dx, minZ + dz);
// No deck cuts a protected river, spring or basin, even if an adjacent bank is solid.
if (g.protectedTerrain()) { survey.reject("road-protected"); return null; }
if (g.top() > y + 2) { survey.reject("road-cut"); return null; }
boolean support = supported(g) && g.top() >= y - 10;
if (!support) {
if (g.top() >= y - 2) { survey.reject("road-weak-surface"); return null; }
gap = true;
} else foundation = Math.max(foundation, y - g.top() + 1);
surface[dz * width + dx] = y;
}
if (gap) {
if (step < 2 || step > CELL - 2 || ++gapRun > 8) { survey.reject("road-bridge-support"); return null; }
bridge = true;
} else gapRun = 0;
}
if (!bridge) for (int dz = 0; dz < depth; dz++) for (int dx = 0; dx < width; dx++) {
Ground g = survey.at(minX + dx, minZ + dz);
if (g.top() - (surface[dz * width + dx] - foundation) + 1 > g.solidDepth()) {
survey.reject("road-foundation"); return null;
}
}
return new Parcel(bridge ? Kind.BRIDGE : Kind.STREET, minX, minZ, width, depth,
startY, 0, 0, seed, bridge ? 0 : foundation, surface);
}
private static List<Parcel> largestConnectedDistrict(List<Edge> edges) {
Map<Integer, List<Edge>> neighbors = new HashMap<>();
for (Edge edge : edges) {
neighbors.computeIfAbsent(edge.a(), ignored -> new ArrayList<>()).add(edge);
neighbors.computeIfAbsent(edge.b(), ignored -> new ArrayList<>()).add(edge);
}
Set<Integer> visited = new HashSet<>();
List<Parcel> best = List.of();
// Stable insertion order comes from edges, never HashMap iteration.
for (Edge start : edges) {
if (visited.contains(start.a())) continue;
Set<Edge> component = new HashSet<>();
ArrayDeque<Integer> queue = new ArrayDeque<>();
queue.add(start.a()); visited.add(start.a());
while (!queue.isEmpty()) {
int node = queue.removeFirst();
for (Edge edge : neighbors.getOrDefault(node, List.of())) {
component.add(edge);
int other = edge.a() == node ? edge.b() : edge.a();
if (visited.add(other)) queue.addLast(other);
}
}
List<Parcel> parcels = new ArrayList<>();
for (Edge edge : edges) if (component.contains(edge)) parcels.addAll(edge.parcels());
if (lotCount(parcels) > lotCount(best)) best = List.copyOf(parcels);
}
return best;
}
private static boolean supported(Ground g) {
return g.dry() && !g.protectedTerrain() && g.solidDepth() >= 4 && g.top() >= MIN_FLOOR - 12;
}
/** A terrace follows the entire footprint rather than privileging its north entrance.
* Flat sites stay one block above their natural floor; modest uphill sites may cut at
* most two surface blocks. Foundation thickness and continuous rock are checked later. */
private static int lotFloor(int low, int high) {
return high + 1 - Math.min(2, (high - low) / 3);
}
private static boolean solidTo(int x, int z, int width, int depth, int bottomY, TerrainCache survey) {
for (int dz = 0; dz < depth; dz++) for (int dx = 0; dx < width; dx++) {
Ground g = survey.at(x + dx, z + dz);
if (g.top() < bottomY || g.top() - bottomY + 1 > g.solidDepth()) return false;
}
return true;
}
private static int lerp(int a, int b, int step, int length) { return a + Math.round((b - a) * (step / (float) length)); }
private static int node(int col, int row, int cells) { return row * (cells + 1) + col; }
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);
}
private record Candidate(int x, int z, int score, long salt) {}
private record Edge(int a, int b, List<Parcel> parcels) {}
private static final class TerrainCache {
private final int centerX, centerZ;
private final double radius;
private final GroundSampler sampler;
private final Map<Long, Ground> columns = new HashMap<>();
private final Map<String, Integer> rejections = new java.util.TreeMap<>();
TerrainCache(int centerX, int centerZ, int diameter, GroundSampler sampler) {
this.centerX = centerX; this.centerZ = centerZ; this.radius = diameter / 2.0 - 6;
this.sampler = java.util.Objects.requireNonNull(sampler);
}
boolean exhausted() { return columns.size() >= MAX_COLUMNS; }
void reject(String reason) { rejections.merge(reason, 1, Integer::sum); }
String diagnostics() {
return "columns=" + columns.size() + " heights=" + columns.values().stream().mapToInt(Ground::top).summaryStatistics()
+ " dry=" + columns.values().stream().filter(Ground::dry).count()
+ " solid4=" + columns.values().stream().filter(g -> g.solidDepth() >= 4).count()
+ " protected=" + columns.values().stream().filter(Ground::protectedTerrain).count()
+ " rejected=" + rejections;
}
Ground at(int x, int z) {
if (Math.hypot((double) x - centerX, (double) z - centerZ) > radius) return INVALID;
long key = ((long) x << 32) ^ (z & 0xffffffffL);
Ground cached = columns.get(key);
if (cached != null) return cached;
if (exhausted()) return INVALID;
Ground value = java.util.Objects.requireNonNull(sampler.sample(x, z));
columns.put(key, value);
return value;
}
}
}
@@ -0,0 +1,456 @@
package fr.koka.sanctuary.worldgen.city;
import fr.koka.sanctuary.worldgen.city.LostCityPlan.District;
import fr.koka.sanctuary.worldgen.city.LostCityPlan.Kind;
import fr.koka.sanctuary.worldgen.city.LostCityPlan.Parcel;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Generation 15 only: one compact town, certified before any terrain is changed.
* A 22-block grid consists entirely of 17-block lots, five-block streets and junctions.
* Streets touch facades directly; no leftover strip of natural soil separates their entrances. */
public final class LostCityPlanner15 {
public static final int MAX_COLUMNS = 16_000;
public static final int MAX_VOLUME_CHECKS = 900_000;
public static final int MAX_CUT = 6;
private static final int CELL = 22, LOT = 17, MIN_FLOOR = 176;
private static final LostCityPlanner.Ground INVALID = new LostCityPlanner.Ground(0, 0, false, true);
private static final Kind[] BUILDINGS = {Kind.HOTEL, Kind.LIBRARY, Kind.RESTAURANT, Kind.BARRACKS, Kind.HALL};
private static final Kind[] PUBLIC_SPACES = {Kind.POOL, Kind.PARKING, Kind.EXCAVATION,
Kind.MARKET, Kind.PARK_GARDEN, Kind.PARK_PLAYGROUND, Kind.PARK_MEMORIAL, Kind.PARK};
private LostCityPlanner15() {}
public interface Terrain {
LostCityPlanner.Ground ground(int x, int z);
boolean solid(int x, int y, int z);
boolean protectedAt(int x, int y, int z);
boolean owns(int x, int z);
}
/** A short three-by-three pier with an embedded footing, never a column to the void. */
public record Support(int startChunkX, int startChunkZ, int minX, int minY, int minZ,
int maxX, int maxY, int maxZ, long seed) {
public Support {
if (maxX - minX != 2 || maxZ - minZ != 2 || minY < 1 || maxY > 382
|| maxY < minY || maxY - minY >= 16
|| Math.abs((minX >> 4) - startChunkX) > 7 || Math.abs((maxX >> 4) - startChunkX) > 7
|| Math.abs((minZ >> 4) - startChunkZ) > 7 || Math.abs((maxZ >> 4) - startChunkZ) > 7)
throw new IllegalArgumentException("Invalid generation-15 viaduct support");
}
}
public record Result(LostCityPlan surface, List<Support> supports, int columns, int volumeChecks) {
public Result {
java.util.Objects.requireNonNull(surface); supports = List.copyOf(supports);
if (columns < 0 || columns > MAX_COLUMNS || volumeChecks < 0 || volumeChecks > MAX_VOLUME_CHECKS)
throw new IllegalArgumentException("Invalid generation-15 planning budget");
}
}
public static Result plan(long seed, int centerX, int centerZ, int diameter, boolean origin, Terrain terrain) {
if (diameter < 64 || diameter > 1024) throw new IllegalArgumentException("Unsupported city island size");
int cells = origin ? 3 : diameter < 128 ? 1 : diameter < 256 ? 2 : diameter < 512 ? 3 : 4;
Probe probe = new Probe(terrain, centerX, centerZ, diameter);
Town best = null;
List<Integer> accepted = new ArrayList<>();
try {
var candidates = candidates(seed, centerX, centerZ, diameter, origin, cells, probe);
for (int i = 0; i < Math.min(4, candidates.size()); i++) {
Town town = build(seed, candidates.get(i), cells, probe);
accepted.add(town == null ? 0 : town.lots().size());
if (town != null && (best == null || town.lots().size() > best.lots().size())) best = town;
if (best != null && best.lots().size() >= Math.max(1, cells * cells - 1)) break;
}
} catch (BudgetExceeded exhausted) { probe.reject("budget"); }
int minimum = origin ? 3 : cells == 1 ? 1 : 2;
LostCityPlan surface = LostCityPlan.EMPTY;
List<Support> supports = List.of();
if (best != null && best.lots().size() >= minimum) {
var pieces = new ArrayList<Parcel>();
best.nodes().forEach(n -> pieces.add(n.parcel()));
best.edges().forEach(e -> pieces.addAll(e.pieces()));
pieces.addAll(assign(seed, best.lots()));
surface = new LostCityPlan(List.of(new District(best.startX(), best.startZ(), best.seed(), pieces)));
supports = best.edges().stream().flatMap(e -> e.supports().stream()).toList();
}
if (Boolean.getBoolean("sanctuary.test.lostCity") || Boolean.getBoolean("sanctuary.test.lostCity15")
|| Boolean.getBoolean("sanctuary.city.plannerDiagnostics"))
System.getLogger(LostCityPlanner15.class.getName()).log(System.Logger.Level.INFO,
"Lost City15 compact district seed=" + seed + " origin=" + origin + " diameter=" + diameter
+ " lots=" + (surface.isEmpty() ? 0 : best.lots().size()) + " candidates=" + accepted
+ " piers=" + supports.size() + " columns=" + probe.columns.size()
+ " volumeChecks=" + probe.volumeChecks + " rejected=" + probe.rejections);
return new Result(surface, supports, probe.columns.size(), probe.volumeChecks);
}
private static List<Candidate> candidates(long seed, int cx, int cz, int diameter, boolean origin, int cells, Probe probe) {
var result = new ArrayList<Candidate>();
double bearing = unit(mix(seed ^ 0xC17517L)) * Math.PI * 2;
for (int attempt = 0; attempt < (origin ? 16 : diameter < 128 ? 5 : 12); attempt++) {
long salt = mix(seed ^ attempt * 0x9e3779b97f4a7c15L ^ 0x15C04A7L);
double radius = origin ? Math.min(150, diameter * .17) * (attempt < 8 ? 1 : .48)
: attempt == 0 ? 0 : Math.min(diameter * .15, 95);
double angle = bearing + attempt * Math.PI * 2 / 8;
int x = cx + (int) Math.round(Math.cos(angle) * radius) - cells * CELL / 2;
int z = cz + (int) Math.round(Math.sin(angle) * radius) - cells * CELL / 2;
int score = 0, spread = 0;
for (int row = 0; row < cells; row++) for (int col = 0; col < cells; col++) {
int low = Integer.MAX_VALUE, high = Integer.MIN_VALUE;
boolean valid = true;
for (int[] point : new int[][] {{0, 0}, {16, 0}, {0, 16}, {16, 16}, {8, 8}}) {
var g = probe.at(x + col * CELL + 3 + point[0], z + row * CELL + 3 + point[1]);
if (!land(g)) { valid = false; break; }
low = Math.min(low, g.top()); high = Math.max(high, g.top());
}
if (valid && high - MAX_CUT <= low + 9 && high >= MIN_FLOOR && low <= 350) {
score++; spread += high - low;
}
}
if (score > 0) result.add(new Candidate(x, z, score, spread, salt));
else probe.reject("coarse-no-lot");
}
result.sort(Comparator.comparingInt(Candidate::score).reversed().thenComparingInt(Candidate::spread).thenComparingLong(Candidate::seed));
return result;
}
private static Town build(long seed, Candidate site, int cells, Probe probe) {
int startX = Math.floorDiv(site.x() + cells * CELL / 2, 16), startZ = Math.floorDiv(site.z() + cells * CELL / 2, 16);
Node[][] nodes = new Node[cells + 1][cells + 1];
for (int row = 0; row <= cells; row++) for (int col = 0; col <= cells; col++) {
int x = site.x() + col * CELL, z = site.z() + row * CELL;
Footprint certificate = certify(x - 2, z - 2, 5, 5, MIN_FLOOR, 350, 8, probe);
if (certificate != null) nodes[row][col] = new Node(node(col, row, cells), x, z, certificate.floor(),
new Parcel(Kind.STREET, x - 2, z - 2, 5, 5, certificate.floor(), 0, 0,
mix(seed ^ site.seed() ^ node(col, row, cells)), certificate.foundation(), new int[0]));
}
Lot[][] lots = new Lot[cells][cells];
for (int row = 0; row < cells; row++) for (int col = 0; col < cells; col++) {
Node a = nodes[row][col], b = nodes[row][col + 1];
if (a == null || b == null) { probe.reject("lot-north-junction"); continue; }
int x = site.x() + col * CELL + 3, z = site.z() + row * CELL + 3;
int low = Math.max(MIN_FLOOR, Math.max(a.floor(), b.floor()) - 7);
int high = Math.min(350, Math.min(a.floor(), b.floor()) + 7);
Footprint certificate = certify(x, z, LOT, LOT, low, high, 29, probe);
if (certificate == null) continue;
boolean deep = solidTo(x, z, LOT, LOT, certificate.floor() - 8, probe)
&& minTop(x, z, LOT, LOT, probe) >= certificate.floor() - 1
&& unprotected(x, certificate.floor() - 8, z, x + 16, certificate.floor() - certificate.foundation() - 1, z + 16, probe);
lots[row][col] = new Lot(row, col, x, z, certificate.floor(), certificate.foundation(), deep,
mix(seed ^ site.seed() ^ col * 0xD1342543DE82EF95L ^ row * 0xC6BC279692B5CC83L));
}
Edge[][] horizontal = new Edge[cells + 1][cells], vertical = new Edge[cells][cells + 1];
var edges = new ArrayList<Edge>();
for (int row = 0; row <= cells; row++) for (int col = 0; col < cells; col++) {
Node a = nodes[row][col], b = nodes[row][col + 1];
if (a == null || b == null) continue;
Lot lot = row < cells ? lots[row][col] : null;
int mid = lot == null ? (a.floor() + b.floor()) / 2 : lot.floor();
Edge edge = shortRoad(a, b, mid, mix(seed ^ site.seed() ^ a.id() * 67L ^ b.id()), probe);
if (edge != null) { edges.add(edge); horizontal[row][col] = edge; }
}
for (int row = 0; row < cells; row++) for (int col = 0; col <= cells; col++) {
Node a = nodes[row][col], b = nodes[row + 1][col];
if (a == null || b == null) continue;
Edge edge = shortRoad(a, b, (a.floor() + b.floor()) / 2, mix(seed ^ site.seed() ^ a.id() * 163L ^ b.id()), probe);
if (edge != null) { edges.add(edge); vertical[row][col] = edge; }
}
// Long links may replace missing junctions across a fissure, never cross a lot or another street.
int bridges = 0;
for (boolean eastWest : new boolean[] {true, false}) for (int line = 0; line <= cells; line++)
for (int from = 0; from <= cells - 2; from++) for (int to = from + 2; to <= cells; to++) {
if (bridges >= 4) break;
Node a = eastWest ? nodes[line][from] : nodes[from][line];
Node b = eastWest ? nodes[line][to] : nodes[to][line];
if (a == null || b == null) continue;
boolean intermediate = false;
for (int n = from + 1; n < to; n++) if ((eastWest ? nodes[line][n] : nodes[n][line]) != null) intermediate = true;
if (intermediate) continue;
Edge bridge = viaduct(a, b, startX, startZ, mix(seed ^ site.seed() ^ a.id() * 479L ^ b.id()), probe);
if (bridge == null || edges.stream().anyMatch(e -> overlaps(e, bridge))) continue;
edges.add(bridge); bridges++;
}
var eligible = new ArrayList<Lot>();
for (int row = 0; row < cells; row++) for (int col = 0; col < cells; col++) {
if (lots[row][col] == null) continue;
if (horizontal[row][col] == null || horizontal[row + 1][col] == null
|| vertical[row][col] == null || vertical[row][col + 1] == null) {
probe.reject("lot-incomplete-streets"); continue;
}
// A bridge's intact side railing must never become a barrier in front of a doorway.
if (horizontal[row][col].pieces().stream().anyMatch(p -> p.kind() == Kind.BRIDGE)) {
probe.reject("lot-front-bridge-rail"); continue;
}
eligible.add(lots[row][col]);
}
return connected(startX, startZ, mix(seed ^ site.seed()), nodes, edges, eligible, cells);
}
private static Footprint certify(int x, int z, int width, int depth, int min, int max, int clearHeight, Probe probe) {
var elevations = new ArrayList<Integer>();
int low = Integer.MAX_VALUE, high = Integer.MIN_VALUE;
for (int dz = 0; dz < depth; dz++) for (int dx = 0; dx < width; dx++) {
var g = probe.at(x + dx, z + dz);
if (!land(g)) { probe.reject("footprint-support"); return null; }
low = Math.min(low, g.top()); high = Math.max(high, g.top()); elevations.add(g.top());
}
min = Math.max(min, high - MAX_CUT); max = Math.min(max, low + 9);
if (min > max) { probe.reject("footprint-grade"); return null; }
elevations.sort(Integer::compareTo);
int floor = Math.clamp(elevations.get(elevations.size() / 2), min, max);
int foundation = Math.max(2, floor - low + 1);
if (!solidTo(x, z, width, depth, floor - foundation, probe)) { probe.reject("footprint-rock"); return null; }
if (!unprotected(x, floor - foundation, z, x + width - 1, floor + clearHeight, z + depth - 1, probe)) {
probe.reject("footprint-protected"); return null;
}
return new Footprint(floor, foundation);
}
private static Edge shortRoad(Node a, Node b, int middle, long seed, Probe probe) {
if (Math.abs(a.floor() - middle) > 7 || Math.abs(b.floor() - middle) > 7) { probe.reject("street-grade"); return null; }
boolean alongX = a.z() == b.z();
int x = a.x() + (alongX ? 3 : -2), z = a.z() + (alongX ? -2 : 3);
int width = alongX ? 17 : 5, depth = alongX ? 5 : 17;
int[] heights = new int[width * depth];
int foundation = 2, lowRun = 0, longestLow = 0;
boolean fillable = true;
for (int step = 3; step <= 19; step++) {
int floor = streetY(a.floor(), middle, b.floor(), step);
boolean low = false;
for (int across = 0; across < 5; across++) {
int lx = alongX ? step - 3 : across, lz = alongX ? across : step - 3;
var g = probe.at(x + lx, z + lz);
if (g == INVALID || g.top() > floor + MAX_CUT) { probe.reject("street-cut"); return null; }
if (!land(g) || g.top() < floor - 9) fillable = false;
if (g.top() < floor - 3) low = true;
if (g.dry()) foundation = Math.max(foundation, floor - g.top() + 1);
heights[lz * width + lx] = floor;
if (!unprotected(x + lx, floor, z + lz, x + lx, floor + 8, z + lz, probe)) { probe.reject("street-water"); return null; }
}
lowRun = low ? lowRun + 1 : 0; longestLow = Math.max(longestLow, lowRun);
}
if (fillable && foundation <= 10) {
for (int dz = 0; dz < depth; dz++) for (int dx = 0; dx < width; dx++) {
int floor = heights[dz * width + dx];
var g = probe.at(x + dx, z + dz);
if (g.top() - (floor - foundation) + 1 > g.solidDepth()
|| !unprotected(x + dx, floor - foundation, z + dz, x + dx, floor - 1, z + dz, probe)) fillable = false;
}
} else fillable = false;
if (!fillable && longestLow < 4) { probe.reject("street-foundation"); return null; }
Kind kind = fillable ? Kind.STREET : Kind.BRIDGE;
return new Edge(a.id(), b.id(), List.of(new Parcel(kind, x, z, width, depth, middle, 0, 0, seed,
fillable ? foundation : 0, heights)), List.of());
}
/** Flat five-wide nodes and a three-column central landing make stair direction unambiguous. */
private static int streetY(int a, int middle, int b, int step) {
if (step <= 3) return a;
if (step < 10) return a + Math.round((middle - a) * ((step - 3) / 7.0f));
if (step <= 12) return middle;
if (step < 19) return middle + Math.round((b - middle) * ((step - 12) / 7.0f));
return b;
}
private static Edge viaduct(Node a, Node b, int startX, int startZ, long seed, Probe probe) {
if (Math.abs(a.floor() - b.floor()) > 6) { probe.reject("viaduct-grade"); return null; }
boolean alongX = a.z() == b.z();
int distance = alongX ? b.x() - a.x() : b.z() - a.z();
int middle = (a.floor() + b.floor()) / 2;
int lowRun = 0, longest = 0;
for (int step = 3; step <= distance - 3; step++) {
int floor = bridgeY(a.floor(), middle, b.floor(), step, distance);
boolean low = false;
for (int across = -2; across <= 2; across++) {
int x = a.x() + (alongX ? step : across), z = a.z() + (alongX ? across : step);
var g = probe.at(x, z);
if (g == INVALID || g.top() > floor + MAX_CUT) { probe.reject("viaduct-cut"); return null; }
if (g.top() < floor - 3) low = true;
if (!unprotected(x, floor, z, x, floor + 8, z, probe)) { probe.reject("viaduct-water"); return null; }
}
lowRun = low ? lowRun + 1 : 0; longest = Math.max(longest, lowRun);
}
if (longest < 4) { probe.reject("viaduct-no-gap"); return null; }
var supports = new ArrayList<Support>();
int last = 2;
while (distance - 2 - last > 24) {
int found = -1;
for (int offset : new int[] {0, -1, 1, -2, 2, -3, 3, -4, 4}) {
int step = Math.min(last + 20, distance - 12) + offset;
if (step < 12 || step > distance - 12 || step <= last || step - last > 24) continue;
int x = a.x() + (alongX ? step : 0), z = a.z() + (alongX ? 0 : step);
if (naturalPier(x, z, middle, probe)) { found = step; break; }
Support support = pier(startX, startZ, x, z, middle, mix(seed ^ step), probe);
if (support != null) { supports.add(support); found = step; break; }
}
if (found < 0) { probe.reject("viaduct-pier"); return null; }
last = found;
}
var pieces = new ArrayList<Parcel>();
int length = distance - 5, count = (length + 23) / 24, consumed = 0;
for (int i = 0; i < count; i++) {
int part = (length - consumed + count - i - 1) / (count - i);
int minX = a.x() + (alongX ? consumed + 3 : -2), minZ = a.z() + (alongX ? -2 : consumed + 3);
int width = alongX ? part : 5, depth = alongX ? 5 : part;
int[] heights = new int[width * depth];
for (int z = 0; z < depth; z++) for (int x = 0; x < width; x++)
heights[z * width + x] = bridgeY(a.floor(), middle, b.floor(), consumed + 3 + (alongX ? x : z), distance);
pieces.add(new Parcel(Kind.BRIDGE, minX, minZ, width, depth, middle, 0, 0, mix(seed ^ i), 0, heights));
consumed += part;
}
return new Edge(a.id(), b.id(), pieces, supports);
}
private static int bridgeY(int a, int mid, int b, int step, int distance) {
if (step <= 3) return a;
if (step < 10) return a + Math.round((mid - a) * ((step - 3) / 7.0f));
if (step <= distance - 10) return mid;
if (step < distance - 3) return mid + Math.round((b - mid) * ((step - (distance - 10)) / 7.0f));
return b;
}
private static Town connected(int startX, int startZ, long seed, Node[][] nodes, List<Edge> edges, List<Lot> lots, int cells) {
Map<Integer, List<Edge>> graph = new HashMap<>();
for (Edge e : edges) { graph.computeIfAbsent(e.a(), n -> new ArrayList<>()).add(e); graph.computeIfAbsent(e.b(), n -> new ArrayList<>()).add(e); }
Set<Integer> visited = new HashSet<>(); Town best = null;
for (Edge first : edges) {
if (!visited.add(first.a())) continue;
Set<Integer> component = new HashSet<>(); component.add(first.a());
ArrayDeque<Integer> queue = new ArrayDeque<>(); queue.add(first.a());
while (!queue.isEmpty()) {
int id = queue.removeFirst();
for (Edge e : graph.getOrDefault(id, List.of())) {
int next = e.a() == id ? e.b() : e.a();
if (visited.add(next)) { component.add(next); queue.addLast(next); }
}
}
List<Lot> admitted = lots.stream().filter(l -> component.contains(node(l.col(), l.row(), cells))).toList();
if (admitted.isEmpty() || best != null && admitted.size() <= best.lots().size()) continue;
var retainedNodes = new ArrayList<Node>();
for (Node[] row : nodes) for (Node n : row) if (n != null && component.contains(n.id())) retainedNodes.add(n);
best = new Town(startX, startZ, seed, retainedNodes,
edges.stream().filter(e -> component.contains(e.a()) && component.contains(e.b())).toList(), admitted);
}
return best;
}
private static List<Parcel> assign(long seed, List<Lot> lots) {
Kind[] kinds = new Kind[lots.size()];
int buildingCount = Math.max(1, (lots.size() * 3 + 4) / 5);
int buildingOffset = (int) Long.remainderUnsigned(mix(seed ^ 0x15B017DL), BUILDINGS.length);
for (int i = 0; i < buildingCount; i++) {
int archetype = i % 6;
Kind kind = archetype == 0 ? Kind.APARTMENT : BUILDINGS[(buildingOffset + archetype - 1) % BUILDINGS.length];
assign(kind, false, lots, kinds);
}
// A small town is predominantly buildings; its scarce public sites vary across seeds.
if (buildingCount < lots.size()) assign(Kind.PLAZA, false, lots, kinds);
int publicOffset = (int) Long.remainderUnsigned(mix(seed ^ 0x15A2C4L), PUBLIC_SPACES.length);
for (int i = 0; i < PUBLIC_SPACES.length; i++) {
Kind kind = PUBLIC_SPACES[(publicOffset + i) % PUBLIC_SPACES.length];
assign(kind, kind == Kind.POOL || kind == Kind.EXCAVATION, lots, kinds);
}
var result = new ArrayList<Parcel>();
for (int i = 0; i < lots.size(); i++) {
Lot lot = lots.get(i); if (kinds[i] == null) kinds[i] = Kind.PARK;
Kind kind = kinds[i];
int floors = switch (kind) {
case APARTMENT, HOTEL -> 3 + (int) Long.remainderUnsigned(lot.seed(), 3);
case LIBRARY, RESTAURANT, BARRACKS, HALL -> 2;
case PARKING, MARKET -> 1;
default -> 0;
};
int foundation = lot.deep() && (kind == Kind.POOL || kind == Kind.PARKING || kind == Kind.EXCAVATION) ? 8 : lot.foundation();
result.add(new Parcel(kind, lot.x(), lot.z(), LOT, LOT, lot.floor(), floors, 0, lot.seed(), foundation, new int[0]));
}
return result;
}
private static void assign(Kind kind, boolean deepOnly, List<Lot> lots, Kind[] kinds) {
int chosen = -1;
for (int i = 0; i < lots.size(); i++) {
Lot lot = lots.get(i);
if (kinds[i] != null || deepOnly && !lot.deep() || kind == Kind.PARKING && !lot.deep() && lot.foundation() >= 8) continue;
if (chosen < 0 || !deepOnly && kind != Kind.PARKING && lots.get(chosen).deep() && !lot.deep()
|| kind == Kind.PARKING && !lots.get(chosen).deep() && lot.deep()) chosen = i;
}
if (chosen >= 0) kinds[chosen] = kind;
}
private static boolean overlaps(Edge a, Edge b) {
for (Parcel p : a.pieces()) for (Parcel q : b.pieces()) if (p.minX() <= q.maxX() && p.maxX() >= q.minX()
&& p.minZ() <= q.maxZ() && p.maxZ() >= q.minZ()) return true;
return false;
}
private static boolean naturalPier(int x, int z, int floor, Probe probe) {
for (int dz = -1; dz <= 1; dz++) for (int dx = -1; dx <= 1; dx++) {
var g = probe.at(x + dx, z + dz);
if (!g.dry() || g.solidDepth() < 4 || g.top() < floor - 1 || g.top() > floor + 1) return false;
}
return unprotected(x - 1, floor - 2, z - 1, x + 1, floor - 1, z + 1, probe);
}
private static Support pier(int startX, int startZ, int x, int z, int floor, long seed, Probe probe) {
int low = Integer.MAX_VALUE, high = Integer.MIN_VALUE;
for (int dz = -1; dz <= 1; dz++) for (int dx = -1; dx <= 1; dx++) {
var g = probe.at(x + dx, z + dz);
if (!g.dry() || g.solidDepth() < 4 || g.top() < floor - 15 || g.top() > floor - 2) return null;
low = Math.min(low, g.top()); high = Math.max(high, g.top());
}
if (high - low > 2 || !solidTo(x - 1, z - 1, 3, 3, low - 2, probe)
|| !unprotected(x - 1, low - 2, z - 1, x + 1, floor - 1, z + 1, probe)) return null;
return new Support(startX, startZ, x - 1, low - 1, z - 1, x + 1, floor - 1, z + 1, seed);
}
private static boolean land(LostCityPlanner.Ground g) { return g.dry() && !g.protectedTerrain() && g.solidDepth() >= 4 && g.top() >= MIN_FLOOR - 10; }
private static boolean solidTo(int x, int z, int width, int depth, int bottom, Probe probe) {
for (int dz = 0; dz < depth; dz++) for (int dx = 0; dx < width; dx++) {
var g = probe.at(x + dx, z + dz);
if (!g.dry() || g.top() < bottom || g.top() - bottom + 1 > g.solidDepth()) return false;
}
return true;
}
private static int minTop(int x, int z, int width, int depth, Probe probe) {
int min = Integer.MAX_VALUE;
for (int dz = 0; dz < depth; dz++) for (int dx = 0; dx < width; dx++) min = Math.min(min, probe.at(x + dx, z + dz).top());
return min;
}
private static boolean unprotected(int x0, int y0, int z0, int x1, int y1, int z1, Probe probe) {
for (int x = x0; x <= x1; x++) for (int z = z0; z <= z1; z++)
for (int y = y0; y <= y1; y++) if (probe.protectedAt(x, y, z)) return false;
return true;
}
private static int node(int col, int row, int cells) { return row * (cells + 1) + col; }
private static long key(int x, int z) { return ((long) x << 32) ^ (z & 0xffffffffL); }
private static double unit(long value) { return (value >>> 11) * 0x1.0p-53; }
private static long mix(long v) { v = (v ^ (v >>> 30)) * 0xbf58476d1ce4e5b9L; v = (v ^ (v >>> 27)) * 0x94d049bb133111ebL; return v ^ (v >>> 31); }
private record Candidate(int x, int z, int score, int spread, long seed) {}
private record Footprint(int floor, int foundation) {}
private record Node(int id, int x, int z, int floor, Parcel parcel) {}
private record Lot(int row, int col, int x, int z, int floor, int foundation, boolean deep, long seed) {}
private record Edge(int a, int b, List<Parcel> pieces, List<Support> supports) {}
private record Town(int startX, int startZ, long seed, List<Node> nodes, List<Edge> edges, List<Lot> lots) {}
private static final class BudgetExceeded extends RuntimeException {}
private static final class Probe {
final Terrain terrain;
final int centerX, centerZ;
final double radius;
final Map<Long, LostCityPlanner.Ground> columns = new HashMap<>();
final Map<String, Integer> rejections = new java.util.TreeMap<>();
int volumeChecks;
Probe(Terrain terrain, int centerX, int centerZ, int diameter) {
this.terrain = java.util.Objects.requireNonNull(terrain); this.centerX = centerX; this.centerZ = centerZ; radius = diameter / 2.0 - 6;
}
LostCityPlanner.Ground at(int x, int z) {
if (Math.hypot((double) x - centerX, (double) z - centerZ) > radius || !terrain.owns(x, z)) return INVALID;
long key = key(x, z); var cached = columns.get(key); if (cached != null) return cached;
if (columns.size() >= MAX_COLUMNS) throw new BudgetExceeded();
var value = java.util.Objects.requireNonNull(terrain.ground(x, z)); columns.put(key, value); return value;
}
boolean protectedAt(int x, int y, int z) {
if (at(x, z) == INVALID || y < 0 || y >= 384) return true;
if (volumeChecks >= MAX_VOLUME_CHECKS) throw new BudgetExceeded();
volumeChecks++; return terrain.protectedAt(x, y, z);
}
void reject(String reason) { rejections.merge(reason, 1, Integer::sum); }
}
}
@@ -0,0 +1,590 @@
package fr.koka.sanctuary.worldgen.city;
import fr.koka.sanctuary.worldgen.city.LostCityPlan.District;
import fr.koka.sanctuary.worldgen.city.LostCityPlan.Kind;
import fr.koka.sanctuary.worldgen.city.LostCityPlan.Parcel;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Generation 16 only: one compact town, with an explicit architectural reservation as a last resort.
* A 22-block grid consists entirely of 17-block lots, five-block streets and junctions.
* Streets touch facades directly; no leftover strip of natural soil separates their entrances. */
public final class LostCityPlanner16 {
public static final int MAX_COLUMNS = 16_000;
public static final int MAX_VOLUME_CHECKS = 900_000;
public static final int MAX_CUT = 6;
private static final int CELL = 22, LOT = 17, MIN_FLOOR = 176;
private static final LostCityPlanner.Ground INVALID = new LostCityPlanner.Ground(0, 0, false, true);
private static final Kind[] BUILDINGS = {Kind.HOTEL, Kind.LIBRARY, Kind.RESTAURANT, Kind.BARRACKS, Kind.HALL};
private static final Kind[] PUBLIC_SPACES = {Kind.POOL, Kind.PARKING, Kind.EXCAVATION,
Kind.MARKET, Kind.PARK_GARDEN, Kind.PARK_PLAYGROUND, Kind.PARK_MEMORIAL, Kind.PARK};
private LostCityPlanner16() {}
public interface Terrain {
LostCityPlanner.Ground ground(int x, int z);
boolean solid(int x, int y, int z);
boolean protectedAt(int x, int y, int z);
boolean owns(int x, int z);
}
/** A short three-by-three pier with an embedded footing, never a column to the void. */
public record Support(int startChunkX, int startChunkZ, int minX, int minY, int minZ,
int maxX, int maxY, int maxZ, long seed) {
public Support {
if (maxX - minX != 2 || maxZ - minZ != 2 || minY < 1 || maxY > 382
|| maxY < minY || maxY - minY >= 16
|| Math.abs((minX >> 4) - startChunkX) > 7 || Math.abs((maxX >> 4) - startChunkX) > 7
|| Math.abs((minZ >> 4) - startChunkZ) > 7 || Math.abs((maxZ >> 4) - startChunkZ) > 7)
throw new IllegalArgumentException("Invalid generation-16 viaduct support");
}
}
/** A certified three-by-three foundation column; two blocks at its foot are native solid rock.
* Unlike a viaduct pier, an urban foundation may descend farther within its own narrow footprint. */
public record Anchor(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {
public Anchor {
if (maxX - minX != 2 || maxZ - minZ != 2 || minY < 8 || maxY < minY || maxY > 267)
throw new IllegalArgumentException("Invalid generation-16 foundation anchor");
}
}
/** Explicit permission for NEW generation-16 starter terrain only. The main volume may be
* cleared before native pieces, and hydrology excluded within it. Anchors reserve only their
* narrow columns below the basement, never the enclosing cuboid down to the lowest footing.
* An all-air test fixture can still receive a self-supporting deck, but has no certified anchor. */
public record Fallback(int minX, int minZ, int width, int depth, int floorY, int minY, int maxY,
List<Anchor> anchors) {
public Fallback {
anchors = List.copyOf(anchors);
if (width != 49 || depth != 49 || floorY < MIN_FLOOR || floorY > 280
|| minY != floorY - 12 || maxY < floorY + 29 || maxY > 383 || anchors.size() > 25)
throw new IllegalArgumentException("Invalid generation-16 city reservation");
for (Anchor a : anchors) if (a.minX() < minX || a.maxX() >= minX + width
|| a.minZ() < minZ || a.maxZ() >= minZ + depth || a.maxY() != minY - 1)
throw new IllegalArgumentException("Foundation anchor escaped its reservation");
}
public int maxX() { return minX + width - 1; }
public int maxZ() { return minZ + depth - 1; }
public boolean contains(int x, int y, int z) {
if (x < minX || x > maxX() || z < minZ || z > maxZ()) return false;
if (y >= minY && y <= maxY) return true;
for (Anchor a : anchors) if (x >= a.minX() && x <= a.maxX() && z >= a.minZ() && z <= a.maxZ()
&& y >= a.minY() && y <= a.maxY()) return true;
return false;
}
}
public record Result(LostCityPlan surface, List<Support> supports, int columns, int volumeChecks,
Fallback reservation) {
public Result(LostCityPlan surface, List<Support> supports, int columns, int volumeChecks) {
this(surface, supports, columns, volumeChecks, null);
}
public Result {
java.util.Objects.requireNonNull(surface); supports = List.copyOf(supports);
if (columns < 0 || columns > MAX_COLUMNS || volumeChecks < 0 || volumeChecks > MAX_VOLUME_CHECKS)
throw new IllegalArgumentException("Invalid generation-16 planning budget");
}
}
public static Result plan(long seed, int centerX, int centerZ, int diameter, boolean origin, Terrain terrain) {
if (diameter < 64 || diameter > 1024 || origin && diameter < 512)
throw new IllegalArgumentException("Unsupported city island size");
int cells = origin ? 3 : diameter < 128 ? 1 : diameter < 256 ? 2 : diameter < 512 ? 3 : 4;
Probe probe = new Probe(terrain, centerX, centerZ, diameter);
Town best = null;
List<Integer> accepted = new ArrayList<>();
Candidate fallback = origin ? fallbackSite(seed, centerX, centerZ, probe) : null;
probe.columnLimit = origin ? 10_500 : MAX_COLUMNS;
probe.volumeLimit = origin ? 700_000 : MAX_VOLUME_CHECKS;
try {
var candidates = candidates(seed, centerX, centerZ, diameter, origin, cells, probe);
for (int i = 0; i < Math.min(4, candidates.size()); i++) {
Town town = build(seed, candidates.get(i), cells, probe);
accepted.add(town == null ? 0 : town.lots().size());
if (town != null && (best == null || town.lots().size() > best.lots().size())) best = town;
if (best != null && best.lots().size() >= Math.max(1, cells * cells - 1)) break;
}
} catch (BudgetExceeded exhausted) { probe.reject("natural-budget"); }
// A smaller complete block fits folds and narrow terraces which reject the 71x71 layout.
if (origin && (best == null || best.lots().size() < 3)) {
probe.columnLimit = 13_500; probe.volumeLimit = 750_000;
try {
var smaller = candidates(seed ^ 0x160249L, centerX, centerZ, diameter, true, 2, probe);
for (int i = 0; i < Math.min(4, smaller.size()); i++) {
Town town = build(seed, smaller.get(i), 2, probe);
accepted.add(town == null ? 0 : town.lots().size());
if (town != null && (best == null || town.lots().size() > best.lots().size())) best = town;
if (best != null && best.lots().size() >= 3) break;
}
} catch (BudgetExceeded exhausted) { probe.reject("small-natural-budget"); }
}
probe.columnLimit = MAX_COLUMNS; probe.volumeLimit = MAX_VOLUME_CHECKS;
int minimum = origin ? 3 : cells == 1 ? 1 : 2;
LostCityPlan surface = LostCityPlan.EMPTY;
List<Support> supports = List.of();
if (best != null && best.lots().size() >= minimum) {
var pieces = new ArrayList<Parcel>();
best.nodes().forEach(n -> pieces.add(n.parcel()));
best.edges().forEach(e -> pieces.addAll(e.pieces()));
pieces.addAll(assign(seed, best.lots()));
surface = new LostCityPlan(List.of(new District(best.startX(), best.startZ(), best.seed(), pieces)));
supports = best.edges().stream().flatMap(e -> e.supports().stream()).toList();
}
Fallback reservation = null;
if (origin && surface.isEmpty()) {
var reserved = reserve(seed, fallback, probe);
surface = reserved.surface(); reservation = reserved.reservation();
}
if (Boolean.getBoolean("sanctuary.test.lostCity") || Boolean.getBoolean("sanctuary.test.lostCity16")
|| Boolean.getBoolean("sanctuary.city.plannerDiagnostics"))
System.getLogger(LostCityPlanner16.class.getName()).log(System.Logger.Level.INFO,
"Lost City16 compact district seed=" + seed + " origin=" + origin + " diameter=" + diameter
+ " lots=" + (surface.isEmpty() ? 0 : reservation != null ? 4 : best.lots().size()) + " candidates=" + accepted
+ " fallback=" + (reservation != null) + " anchors=" + (reservation == null ? 0 : reservation.anchors().size())
+ " piers=" + supports.size() + " columns=" + probe.columns.size()
+ " volumeChecks=" + probe.volumeChecks + " rejected=" + probe.rejections);
return new Result(surface, supports, probe.columns.size(), probe.volumeChecks, reservation);
}
/** Coarse comparison is budgeted before the natural searches, so exhausting those searches
* cannot accidentally remove the starter's reserved city. This examines only nine local sites. */
private static Candidate fallbackSite(long seed, int cx, int cz, Probe probe) {
Candidate best = null;
double bearing = unit(mix(seed ^ 0x16FA11L)) * Math.PI * 2;
for (int i = 0; i < 9; i++) {
double angle = bearing + i * Math.PI / 4;
int x = cx - 22 + (i == 0 ? 0 : (int) Math.round(Math.cos(angle) * 48));
int z = cz - 22 + (i == 0 ? 0 : (int) Math.round(Math.sin(angle) * 48));
int valid = 0, low = 384, high = 0; boolean owned = true;
for (int dz = -2; dz <= 46; dz += 8) for (int dx = -2; dx <= 46; dx += 8) {
var g = probe.at(x + dx, z + dz);
if (g == INVALID) { owned = false; break; }
if (g.top() > 0) { low = Math.min(low, g.top()); high = Math.max(high, g.top()); }
if (g.dry() && g.solidDepth() >= 4 && g.top() >= MIN_FLOOR - 12) valid++;
}
if (!owned) continue;
Candidate site = new Candidate(x, z, valid, Math.max(0, high - low), mix(seed ^ i ^ 0xFA1116L));
if (best == null || site.score() > best.score() || site.score() == best.score() && site.spread() < best.spread()) best = site;
}
// A real starter owns its complete central envelope. This checks ownership, not terrain
// quality; no terrain-dependent throw is used as a substitute for the guarantee.
if (best == null) throw new IllegalArgumentException("Starter terrain does not own a central 49x49 city envelope");
return best;
}
private static Result reserve(long seed, Candidate site, Probe probe) {
int x = site.x(), z = site.z();
var elevations = new ArrayList<Integer>(); int highest = 0;
for (int dz = -2; dz <= 46; dz++) for (int dx = -2; dx <= 46; dx++) {
var g = probe.at(x + dx, z + dz);
if (g == INVALID) throw new IllegalArgumentException("Starter city reservation crosses another island's ownership");
if (g.top() > 0) { elevations.add(g.top()); highest = Math.max(highest, g.top()); }
}
elevations.sort(Integer::compareTo);
int floor = elevations.isEmpty() ? 220 : Math.clamp(elevations.get(elevations.size() / 2), MIN_FLOOR, 280);
int startX = (x + 22) >> 4, startZ = (z + 22) >> 4;
var pieces = new ArrayList<Parcel>();
var lots = new ArrayList<Lot>();
for (int row = 0; row <= 2; row++) for (int col = 0; col <= 2; col++) {
pieces.add(new Parcel(Kind.STREET, x + col * CELL - 2, z + row * CELL - 2,
5, 5, floor, 0, 0, mix(site.seed() ^ row * 13L ^ col), 2, new int[0]));
if (col < 2) pieces.add(new Parcel(Kind.STREET, x + col * CELL + 3, z + row * CELL - 2,
17, 5, floor, 0, 0, mix(site.seed() ^ row * 59L ^ col ^ 0x16EAL), 2, new int[0]));
if (row < 2) pieces.add(new Parcel(Kind.STREET, x + col * CELL - 2, z + row * CELL + 3,
5, 17, floor, 0, 0, mix(site.seed() ^ row * 71L ^ col ^ 0x16EEL), 2, new int[0]));
if (row < 2 && col < 2) lots.add(new Lot(row, col, x + col * CELL + 3, z + row * CELL + 3,
floor, 2, false, mix(site.seed() ^ row * 307L ^ col ^ 0x1607L)));
}
pieces.addAll(assign(seed, lots));
var anchors = new ArrayList<Anchor>();
try {
for (int dz = 0; dz <= 44; dz += 11) for (int dx = 0; dx <= 44; dx += 11) {
int top = floor - 13;
for (int y = top; y >= 9; y--) {
boolean rock = true;
for (int az = -1; az <= 1 && rock; az++) for (int ax = -1; ax <= 1; ax++)
if (!probe.solidAt(x + dx + ax, y, z + dz + az) || !probe.solidAt(x + dx + ax, y - 1, z + dz + az)) { rock = false; break; }
if (rock) { anchors.add(new Anchor(x + dx - 1, y - 1, z + dz - 1, x + dx + 1, top, z + dz + 1)); break; }
}
}
} catch (BudgetExceeded exhausted) { probe.reject("anchor-budget"); }
Fallback reservation = new Fallback(x - 2, z - 2, 49, 49, floor, floor - 12,
Math.min(383, Math.max(floor + 29, highest + 8)), anchors);
return new Result(new LostCityPlan(List.of(new District(startX, startZ, site.seed(), pieces))),
List.of(), probe.columns.size(), probe.volumeChecks, reservation);
}
private static List<Candidate> candidates(long seed, int cx, int cz, int diameter, boolean origin, int cells, Probe probe) {
var result = new ArrayList<Candidate>();
double bearing = unit(mix(seed ^ 0xC17517L)) * Math.PI * 2;
for (int attempt = 0; attempt < (origin ? 16 : diameter < 128 ? 5 : 12); attempt++) {
long salt = mix(seed ^ attempt * 0x9e3779b97f4a7c15L ^ 0x15C04A7L);
double radius = origin ? Math.min(150, diameter * .17) * (attempt < 8 ? 1 : .48)
: attempt == 0 ? 0 : Math.min(diameter * .15, 95);
double angle = bearing + attempt * Math.PI * 2 / 8;
int x = cx + (int) Math.round(Math.cos(angle) * radius) - cells * CELL / 2;
int z = cz + (int) Math.round(Math.sin(angle) * radius) - cells * CELL / 2;
int score = 0, spread = 0;
for (int row = 0; row < cells; row++) for (int col = 0; col < cells; col++) {
int low = Integer.MAX_VALUE, high = Integer.MIN_VALUE;
boolean valid = true;
for (int[] point : new int[][] {{0, 0}, {16, 0}, {0, 16}, {16, 16}, {8, 8}}) {
var g = probe.at(x + col * CELL + 3 + point[0], z + row * CELL + 3 + point[1]);
if (!land(g)) { valid = false; break; }
low = Math.min(low, g.top()); high = Math.max(high, g.top());
}
if (valid && high - MAX_CUT <= low + 9 && high >= MIN_FLOOR && low <= 350) {
score++; spread += high - low;
}
}
if (score > 0) result.add(new Candidate(x, z, score, spread, salt));
else probe.reject("coarse-no-lot");
}
result.sort(Comparator.comparingInt(Candidate::score).reversed().thenComparingInt(Candidate::spread).thenComparingLong(Candidate::seed));
return result;
}
private static Town build(long seed, Candidate site, int cells, Probe probe) {
int startX = Math.floorDiv(site.x() + cells * CELL / 2, 16), startZ = Math.floorDiv(site.z() + cells * CELL / 2, 16);
Node[][] nodes = new Node[cells + 1][cells + 1];
for (int row = 0; row <= cells; row++) for (int col = 0; col <= cells; col++) {
int x = site.x() + col * CELL, z = site.z() + row * CELL;
Footprint certificate = certify(x - 2, z - 2, 5, 5, MIN_FLOOR, 350, 8, probe);
if (certificate != null) nodes[row][col] = new Node(node(col, row, cells), x, z, certificate.floor(),
new Parcel(Kind.STREET, x - 2, z - 2, 5, 5, certificate.floor(), 0, 0,
mix(seed ^ site.seed() ^ node(col, row, cells)), certificate.foundation(), new int[0]));
}
Lot[][] lots = new Lot[cells][cells];
for (int row = 0; row < cells; row++) for (int col = 0; col < cells; col++) {
Node a = nodes[row][col], b = nodes[row][col + 1];
if (a == null || b == null) { probe.reject("lot-north-junction"); continue; }
int x = site.x() + col * CELL + 3, z = site.z() + row * CELL + 3;
int low = Math.max(MIN_FLOOR, Math.max(a.floor(), b.floor()) - 7);
int high = Math.min(350, Math.min(a.floor(), b.floor()) + 7);
Footprint certificate = certify(x, z, LOT, LOT, low, high, 29, probe);
if (certificate == null) continue;
boolean deep = solidTo(x, z, LOT, LOT, certificate.floor() - 8, probe)
&& minTop(x, z, LOT, LOT, probe) >= certificate.floor() - 1
&& unprotected(x, certificate.floor() - 8, z, x + 16, certificate.floor() - certificate.foundation() - 1, z + 16, probe);
lots[row][col] = new Lot(row, col, x, z, certificate.floor(), certificate.foundation(), deep,
mix(seed ^ site.seed() ^ col * 0xD1342543DE82EF95L ^ row * 0xC6BC279692B5CC83L));
}
Edge[][] horizontal = new Edge[cells + 1][cells], vertical = new Edge[cells][cells + 1];
var edges = new ArrayList<Edge>();
for (int row = 0; row <= cells; row++) for (int col = 0; col < cells; col++) {
Node a = nodes[row][col], b = nodes[row][col + 1];
if (a == null || b == null) continue;
Lot lot = row < cells ? lots[row][col] : null;
int mid = lot == null ? (a.floor() + b.floor()) / 2 : lot.floor();
Edge edge = shortRoad(a, b, mid, mix(seed ^ site.seed() ^ a.id() * 67L ^ b.id()), probe);
if (edge != null) { edges.add(edge); horizontal[row][col] = edge; }
}
for (int row = 0; row < cells; row++) for (int col = 0; col <= cells; col++) {
Node a = nodes[row][col], b = nodes[row + 1][col];
if (a == null || b == null) continue;
Edge edge = shortRoad(a, b, (a.floor() + b.floor()) / 2, mix(seed ^ site.seed() ^ a.id() * 163L ^ b.id()), probe);
if (edge != null) { edges.add(edge); vertical[row][col] = edge; }
}
// Long links may replace missing junctions across a fissure, never cross a lot or another street.
int bridges = 0;
for (boolean eastWest : new boolean[] {true, false}) for (int line = 0; line <= cells; line++)
for (int from = 0; from <= cells - 2; from++) for (int to = from + 2; to <= cells; to++) {
if (bridges >= 4) break;
Node a = eastWest ? nodes[line][from] : nodes[from][line];
Node b = eastWest ? nodes[line][to] : nodes[to][line];
if (a == null || b == null) continue;
boolean intermediate = false;
for (int n = from + 1; n < to; n++) if ((eastWest ? nodes[line][n] : nodes[n][line]) != null) intermediate = true;
if (intermediate) continue;
Edge bridge = viaduct(a, b, startX, startZ, mix(seed ^ site.seed() ^ a.id() * 479L ^ b.id()), probe);
if (bridge == null || edges.stream().anyMatch(e -> overlaps(e, bridge))) continue;
edges.add(bridge); bridges++;
}
var eligible = new ArrayList<Lot>();
for (int row = 0; row < cells; row++) for (int col = 0; col < cells; col++) {
if (lots[row][col] == null) continue;
if (horizontal[row][col] == null || horizontal[row + 1][col] == null
|| vertical[row][col] == null || vertical[row][col + 1] == null) {
probe.reject("lot-incomplete-streets"); continue;
}
// A bridge's intact side railing must never become a barrier in front of a doorway.
if (horizontal[row][col].pieces().stream().anyMatch(p -> p.kind() == Kind.BRIDGE)) {
probe.reject("lot-front-bridge-rail"); continue;
}
eligible.add(lots[row][col]);
}
return connected(startX, startZ, mix(seed ^ site.seed()), nodes, edges, eligible, cells);
}
private static Footprint certify(int x, int z, int width, int depth, int min, int max, int clearHeight, Probe probe) {
var elevations = new ArrayList<Integer>();
int low = Integer.MAX_VALUE, high = Integer.MIN_VALUE;
for (int dz = 0; dz < depth; dz++) for (int dx = 0; dx < width; dx++) {
var g = probe.at(x + dx, z + dz);
if (!land(g)) { probe.reject("footprint-support"); return null; }
low = Math.min(low, g.top()); high = Math.max(high, g.top()); elevations.add(g.top());
}
min = Math.max(min, high - MAX_CUT); max = Math.min(max, low + 9);
if (min > max) { probe.reject("footprint-grade"); return null; }
elevations.sort(Integer::compareTo);
int floor = Math.clamp(elevations.get(elevations.size() / 2), min, max);
int foundation = Math.max(2, floor - low + 1);
if (!solidTo(x, z, width, depth, floor - foundation, probe)) { probe.reject("footprint-rock"); return null; }
if (!unprotected(x, floor - foundation, z, x + width - 1, floor + clearHeight, z + depth - 1, probe)) {
probe.reject("footprint-protected"); return null;
}
return new Footprint(floor, foundation);
}
private static Edge shortRoad(Node a, Node b, int middle, long seed, Probe probe) {
if (Math.abs(a.floor() - middle) > 7 || Math.abs(b.floor() - middle) > 7) { probe.reject("street-grade"); return null; }
boolean alongX = a.z() == b.z();
int x = a.x() + (alongX ? 3 : -2), z = a.z() + (alongX ? -2 : 3);
int width = alongX ? 17 : 5, depth = alongX ? 5 : 17;
int[] heights = new int[width * depth];
int foundation = 2, lowRun = 0, longestLow = 0;
boolean fillable = true;
for (int step = 3; step <= 19; step++) {
int floor = streetY(a.floor(), middle, b.floor(), step);
boolean low = false;
for (int across = 0; across < 5; across++) {
int lx = alongX ? step - 3 : across, lz = alongX ? across : step - 3;
var g = probe.at(x + lx, z + lz);
if (g == INVALID || g.top() > floor + MAX_CUT) { probe.reject("street-cut"); return null; }
if (!land(g) || g.top() < floor - 9) fillable = false;
if (g.top() < floor - 3) low = true;
if (g.dry()) foundation = Math.max(foundation, floor - g.top() + 1);
heights[lz * width + lx] = floor;
if (!unprotected(x + lx, floor, z + lz, x + lx, floor + 8, z + lz, probe)) { probe.reject("street-water"); return null; }
}
lowRun = low ? lowRun + 1 : 0; longestLow = Math.max(longestLow, lowRun);
}
if (fillable && foundation <= 10) {
for (int dz = 0; dz < depth; dz++) for (int dx = 0; dx < width; dx++) {
int floor = heights[dz * width + dx];
var g = probe.at(x + dx, z + dz);
if (g.top() - (floor - foundation) + 1 > g.solidDepth()
|| !unprotected(x + dx, floor - foundation, z + dz, x + dx, floor - 1, z + dz, probe)) fillable = false;
}
} else fillable = false;
if (!fillable && longestLow < 4) { probe.reject("street-foundation"); return null; }
Kind kind = fillable ? Kind.STREET : Kind.BRIDGE;
return new Edge(a.id(), b.id(), List.of(new Parcel(kind, x, z, width, depth, middle, 0, 0, seed,
fillable ? foundation : 0, heights)), List.of());
}
/** Flat five-wide nodes and a three-column central landing make stair direction unambiguous. */
private static int streetY(int a, int middle, int b, int step) {
if (step <= 3) return a;
if (step < 10) return a + Math.round((middle - a) * ((step - 3) / 7.0f));
if (step <= 12) return middle;
if (step < 19) return middle + Math.round((b - middle) * ((step - 12) / 7.0f));
return b;
}
private static Edge viaduct(Node a, Node b, int startX, int startZ, long seed, Probe probe) {
if (Math.abs(a.floor() - b.floor()) > 6) { probe.reject("viaduct-grade"); return null; }
boolean alongX = a.z() == b.z();
int distance = alongX ? b.x() - a.x() : b.z() - a.z();
int middle = (a.floor() + b.floor()) / 2;
int lowRun = 0, longest = 0;
for (int step = 3; step <= distance - 3; step++) {
int floor = bridgeY(a.floor(), middle, b.floor(), step, distance);
boolean low = false;
for (int across = -2; across <= 2; across++) {
int x = a.x() + (alongX ? step : across), z = a.z() + (alongX ? across : step);
var g = probe.at(x, z);
if (g == INVALID || g.top() > floor + MAX_CUT) { probe.reject("viaduct-cut"); return null; }
if (g.top() < floor - 3) low = true;
if (!unprotected(x, floor, z, x, floor + 8, z, probe)) { probe.reject("viaduct-water"); return null; }
}
lowRun = low ? lowRun + 1 : 0; longest = Math.max(longest, lowRun);
}
if (longest < 4) { probe.reject("viaduct-no-gap"); return null; }
var supports = new ArrayList<Support>();
int last = 2;
while (distance - 2 - last > 24) {
int found = -1;
for (int offset : new int[] {0, -1, 1, -2, 2, -3, 3, -4, 4}) {
int step = Math.min(last + 20, distance - 12) + offset;
if (step < 12 || step > distance - 12 || step <= last || step - last > 24) continue;
int x = a.x() + (alongX ? step : 0), z = a.z() + (alongX ? 0 : step);
if (naturalPier(x, z, middle, probe)) { found = step; break; }
Support support = pier(startX, startZ, x, z, middle, mix(seed ^ step), probe);
if (support != null) { supports.add(support); found = step; break; }
}
if (found < 0) { probe.reject("viaduct-pier"); return null; }
last = found;
}
var pieces = new ArrayList<Parcel>();
int length = distance - 5, count = (length + 23) / 24, consumed = 0;
for (int i = 0; i < count; i++) {
int part = (length - consumed + count - i - 1) / (count - i);
int minX = a.x() + (alongX ? consumed + 3 : -2), minZ = a.z() + (alongX ? -2 : consumed + 3);
int width = alongX ? part : 5, depth = alongX ? 5 : part;
int[] heights = new int[width * depth];
for (int z = 0; z < depth; z++) for (int x = 0; x < width; x++)
heights[z * width + x] = bridgeY(a.floor(), middle, b.floor(), consumed + 3 + (alongX ? x : z), distance);
pieces.add(new Parcel(Kind.BRIDGE, minX, minZ, width, depth, middle, 0, 0, mix(seed ^ i), 0, heights));
consumed += part;
}
return new Edge(a.id(), b.id(), pieces, supports);
}
private static int bridgeY(int a, int mid, int b, int step, int distance) {
if (step <= 3) return a;
if (step < 10) return a + Math.round((mid - a) * ((step - 3) / 7.0f));
if (step <= distance - 10) return mid;
if (step < distance - 3) return mid + Math.round((b - mid) * ((step - (distance - 10)) / 7.0f));
return b;
}
private static Town connected(int startX, int startZ, long seed, Node[][] nodes, List<Edge> edges, List<Lot> lots, int cells) {
Map<Integer, List<Edge>> graph = new HashMap<>();
for (Edge e : edges) { graph.computeIfAbsent(e.a(), n -> new ArrayList<>()).add(e); graph.computeIfAbsent(e.b(), n -> new ArrayList<>()).add(e); }
Set<Integer> visited = new HashSet<>(); Town best = null;
for (Edge first : edges) {
if (!visited.add(first.a())) continue;
Set<Integer> component = new HashSet<>(); component.add(first.a());
ArrayDeque<Integer> queue = new ArrayDeque<>(); queue.add(first.a());
while (!queue.isEmpty()) {
int id = queue.removeFirst();
for (Edge e : graph.getOrDefault(id, List.of())) {
int next = e.a() == id ? e.b() : e.a();
if (visited.add(next)) { component.add(next); queue.addLast(next); }
}
}
List<Lot> admitted = lots.stream().filter(l -> component.contains(node(l.col(), l.row(), cells))).toList();
if (admitted.isEmpty() || best != null && admitted.size() <= best.lots().size()) continue;
var retainedNodes = new ArrayList<Node>();
for (Node[] row : nodes) for (Node n : row) if (n != null && component.contains(n.id())) retainedNodes.add(n);
best = new Town(startX, startZ, seed, retainedNodes,
edges.stream().filter(e -> component.contains(e.a()) && component.contains(e.b())).toList(), admitted);
}
return best;
}
private static List<Parcel> assign(long seed, List<Lot> lots) {
Kind[] kinds = new Kind[lots.size()];
int buildingCount = Math.max(1, (lots.size() * 3 + 4) / 5);
int buildingOffset = (int) Long.remainderUnsigned(mix(seed ^ 0x15B017DL), BUILDINGS.length);
for (int i = 0; i < buildingCount; i++) {
int archetype = i % 6;
Kind kind = archetype == 0 ? Kind.APARTMENT : BUILDINGS[(buildingOffset + archetype - 1) % BUILDINGS.length];
assign(kind, false, lots, kinds);
}
// A small town is predominantly buildings; its scarce public sites vary across seeds.
if (buildingCount < lots.size()) assign(Kind.PLAZA, false, lots, kinds);
int publicOffset = (int) Long.remainderUnsigned(mix(seed ^ 0x15A2C4L), PUBLIC_SPACES.length);
for (int i = 0; i < PUBLIC_SPACES.length; i++) {
Kind kind = PUBLIC_SPACES[(publicOffset + i) % PUBLIC_SPACES.length];
assign(kind, kind == Kind.POOL || kind == Kind.EXCAVATION, lots, kinds);
}
var result = new ArrayList<Parcel>();
for (int i = 0; i < lots.size(); i++) {
Lot lot = lots.get(i); if (kinds[i] == null) kinds[i] = Kind.PARK;
Kind kind = kinds[i];
int floors = switch (kind) {
case APARTMENT, HOTEL -> 3 + (int) Long.remainderUnsigned(lot.seed(), 3);
case LIBRARY, RESTAURANT, BARRACKS, HALL -> 2;
case PARKING, MARKET -> 1;
default -> 0;
};
int foundation = lot.deep() && (kind == Kind.POOL || kind == Kind.PARKING || kind == Kind.EXCAVATION) ? 8 : lot.foundation();
result.add(new Parcel(kind, lot.x(), lot.z(), LOT, LOT, lot.floor(), floors, 0, lot.seed(), foundation, new int[0]));
}
return result;
}
private static void assign(Kind kind, boolean deepOnly, List<Lot> lots, Kind[] kinds) {
int chosen = -1;
for (int i = 0; i < lots.size(); i++) {
Lot lot = lots.get(i);
if (kinds[i] != null || deepOnly && !lot.deep() || kind == Kind.PARKING && !lot.deep() && lot.foundation() >= 8) continue;
if (chosen < 0 || !deepOnly && kind != Kind.PARKING && lots.get(chosen).deep() && !lot.deep()
|| kind == Kind.PARKING && !lots.get(chosen).deep() && lot.deep()) chosen = i;
}
if (chosen >= 0) kinds[chosen] = kind;
}
private static boolean overlaps(Edge a, Edge b) {
for (Parcel p : a.pieces()) for (Parcel q : b.pieces()) if (p.minX() <= q.maxX() && p.maxX() >= q.minX()
&& p.minZ() <= q.maxZ() && p.maxZ() >= q.minZ()) return true;
return false;
}
private static boolean naturalPier(int x, int z, int floor, Probe probe) {
for (int dz = -1; dz <= 1; dz++) for (int dx = -1; dx <= 1; dx++) {
var g = probe.at(x + dx, z + dz);
if (!g.dry() || g.solidDepth() < 4 || g.top() < floor - 1 || g.top() > floor + 1) return false;
}
return unprotected(x - 1, floor - 2, z - 1, x + 1, floor - 1, z + 1, probe);
}
private static Support pier(int startX, int startZ, int x, int z, int floor, long seed, Probe probe) {
int low = Integer.MAX_VALUE, high = Integer.MIN_VALUE;
for (int dz = -1; dz <= 1; dz++) for (int dx = -1; dx <= 1; dx++) {
var g = probe.at(x + dx, z + dz);
if (!g.dry() || g.solidDepth() < 4 || g.top() < floor - 15 || g.top() > floor - 2) return null;
low = Math.min(low, g.top()); high = Math.max(high, g.top());
}
if (high - low > 2 || !solidTo(x - 1, z - 1, 3, 3, low - 2, probe)
|| !unprotected(x - 1, low - 2, z - 1, x + 1, floor - 1, z + 1, probe)) return null;
return new Support(startX, startZ, x - 1, low - 1, z - 1, x + 1, floor - 1, z + 1, seed);
}
private static boolean land(LostCityPlanner.Ground g) { return g.dry() && !g.protectedTerrain() && g.solidDepth() >= 4 && g.top() >= MIN_FLOOR - 10; }
private static boolean solidTo(int x, int z, int width, int depth, int bottom, Probe probe) {
for (int dz = 0; dz < depth; dz++) for (int dx = 0; dx < width; dx++) {
var g = probe.at(x + dx, z + dz);
if (!g.dry() || g.top() < bottom || g.top() - bottom + 1 > g.solidDepth()) return false;
}
return true;
}
private static int minTop(int x, int z, int width, int depth, Probe probe) {
int min = Integer.MAX_VALUE;
for (int dz = 0; dz < depth; dz++) for (int dx = 0; dx < width; dx++) min = Math.min(min, probe.at(x + dx, z + dz).top());
return min;
}
private static boolean unprotected(int x0, int y0, int z0, int x1, int y1, int z1, Probe probe) {
for (int x = x0; x <= x1; x++) for (int z = z0; z <= z1; z++)
for (int y = y0; y <= y1; y++) if (probe.protectedAt(x, y, z)) return false;
return true;
}
private static int node(int col, int row, int cells) { return row * (cells + 1) + col; }
private static long key(int x, int z) { return ((long) x << 32) ^ (z & 0xffffffffL); }
private static double unit(long value) { return (value >>> 11) * 0x1.0p-53; }
private static long mix(long v) { v = (v ^ (v >>> 30)) * 0xbf58476d1ce4e5b9L; v = (v ^ (v >>> 27)) * 0x94d049bb133111ebL; return v ^ (v >>> 31); }
private record Candidate(int x, int z, int score, int spread, long seed) {}
private record Footprint(int floor, int foundation) {}
private record Node(int id, int x, int z, int floor, Parcel parcel) {}
private record Lot(int row, int col, int x, int z, int floor, int foundation, boolean deep, long seed) {}
private record Edge(int a, int b, List<Parcel> pieces, List<Support> supports) {}
private record Town(int startX, int startZ, long seed, List<Node> nodes, List<Edge> edges, List<Lot> lots) {}
private static final class BudgetExceeded extends RuntimeException {}
private static final class Probe {
final Terrain terrain;
final int centerX, centerZ;
final double radius;
final Map<Long, LostCityPlanner.Ground> columns = new HashMap<>();
final Map<String, Integer> rejections = new java.util.TreeMap<>();
int volumeChecks, columnLimit = MAX_COLUMNS, volumeLimit = MAX_VOLUME_CHECKS;
Probe(Terrain terrain, int centerX, int centerZ, int diameter) {
this.terrain = java.util.Objects.requireNonNull(terrain); this.centerX = centerX; this.centerZ = centerZ; radius = diameter / 2.0 - 6;
}
LostCityPlanner.Ground at(int x, int z) {
if (Math.hypot((double) x - centerX, (double) z - centerZ) > radius || !terrain.owns(x, z)) return INVALID;
long key = key(x, z); var cached = columns.get(key); if (cached != null) return cached;
if (columns.size() >= columnLimit) throw new BudgetExceeded();
var value = java.util.Objects.requireNonNull(terrain.ground(x, z)); columns.put(key, value); return value;
}
boolean protectedAt(int x, int y, int z) {
if (at(x, z) == INVALID || y < 0 || y >= 384) return true;
if (volumeChecks >= volumeLimit) throw new BudgetExceeded();
volumeChecks++; return terrain.protectedAt(x, y, z);
}
boolean solidAt(int x, int y, int z) {
if (at(x, z) == INVALID || y < 0 || y >= 384) return false;
if (volumeChecks >= volumeLimit) throw new BudgetExceeded();
volumeChecks++; return terrain.solid(x, y, z);
}
void reject(String reason) { rejections.merge(reason, 1, Integer::sum); }
}
}
@@ -0,0 +1,76 @@
package fr.koka.sanctuary.worldgen.city;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** Immutable three-dimensional construction mask; underground works never widen the surface biome. */
public final class LostCityProtection15 {
public static final LostCityProtection15 EMPTY = new Builder().build();
private final Map<Long, List<Box>> chunks;
private LostCityProtection15(Map<Long, List<Box>> chunks) { this.chunks = chunks; }
public boolean contains(int x, int y, int z) {
var boxes = chunks.get(key(x >> 4, z >> 4));
if (boxes == null) return false;
for (var box : boxes) if (x >= box.x0 && x <= box.x1 && y >= box.y0 && y <= box.y1 && z >= box.z0 && z <= box.z1) return true;
return false;
}
public boolean intersects(int x0, int y0, int z0, int x1, int y1, int z1) {
if (x0 > x1 || y0 > y1 || z0 > z1) throw new IllegalArgumentException("Reversed city query bounds");
if (y1 < 0 || y0 > 383) return false;
for (int cx = x0 >> 4; cx <= x1 >> 4; cx++) for (int cz = z0 >> 4; cz <= z1 >> 4; cz++) {
var boxes = chunks.get(key(cx, cz));
if (boxes == null) continue;
for (var box : boxes) if (x0 <= box.x1 && x1 >= box.x0 && y0 <= box.y1
&& y1 >= box.y0 && z0 <= box.z1 && z1 >= box.z0) return true;
}
return false;
}
private static long key(int x, int z) { return ((long) x << 32) ^ (z & 0xffffffffL); }
private record Box(int x0, int y0, int z0, int x1, int y1, int z1) {}
public static final class Builder {
private final Map<Long, List<Box>> chunks = new HashMap<>();
public Builder add(int x0, int y0, int z0, int x1, int y1, int z1) {
if (x0 > x1 || y0 > y1 || z0 > z1 || x1 - x0 > 256 || z1 - z0 > 256 || y0 < 0 || y1 > 383)
throw new IllegalArgumentException("Invalid city construction mask");
var box = new Box(x0, y0, z0, x1, y1, z1);
for (int x = x0 >> 4; x <= x1 >> 4; x++) for (int z = z0 >> 4; z <= z1 >> 4; z++)
chunks.computeIfAbsent(key(x, z), ignored -> new ArrayList<>()).add(box);
return this;
}
public Builder add(LostCityPlan surface, LostCityUnderground15.Plan underground) {
for (var district : surface.districts()) for (var parcel : district.parcels()) {
add(parcel.minX(), parcel.minY(), parcel.minZ(), parcel.maxX(), parcel.maxY(), parcel.maxZ());
if (parcel.kind() == LostCityPlan.Kind.BRIDGE) for (int x = 0; x < parcel.width(); x++) for (int z = 0; z < parcel.depth(); z++) {
int y = parcel.surfaceY(x, z);
// Abutments and natural intermediate bearings are retained, never filled in.
add(parcel.minX() + x, y - 2, parcel.minZ() + z, parcel.minX() + x, y - 1, parcel.minZ() + z);
}
}
for (var network : underground.networks()) for (var module : network.modules()) {
add(module.boundsMinX(), module.boundsMinY(), module.boundsMinZ(), module.boundsMaxX(), module.boundsMaxY(), module.boundsMaxZ());
if (module.kind() == LostCityUnderground15.Kind.EXPANSION_HALL)
add(module.minX(), module.minY() - 2, module.minZ(), module.maxX(), module.minY() - 1, module.maxZ());
for (var support : module.supports()) {
int y = support.type() == LostCityUnderground15.Type.PILLAR ? support.minY() - 1 : support.maxY() + 1;
// Retain the untouched rock that carries the painted anchor.
add(support.x(), y, support.z(), support.x(), y, support.z());
}
}
return this;
}
public Builder addViaductSupport(LostCityPlanner15.Support support) {
return add(support.minX(), support.minY() - 1, support.minZ(), support.maxX(), support.maxY(), support.maxZ());
}
public LostCityProtection15 build() {
var immutable = new HashMap<Long, List<Box>>();
chunks.forEach((key, value) -> immutable.put(key, List.copyOf(value)));
return new LostCityProtection15(Map.copyOf(immutable));
}
}
}
@@ -0,0 +1,81 @@
package fr.koka.sanctuary.worldgen.city;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** Immutable three-dimensional construction mask; underground works never widen the surface biome. */
public final class LostCityProtection16 {
public static final LostCityProtection16 EMPTY = new Builder().build();
private final Map<Long, List<Box>> chunks;
private LostCityProtection16(Map<Long, List<Box>> chunks) { this.chunks = chunks; }
public boolean contains(int x, int y, int z) {
var boxes = chunks.get(key(x >> 4, z >> 4));
if (boxes == null) return false;
for (var box : boxes) if (x >= box.x0 && x <= box.x1 && y >= box.y0 && y <= box.y1 && z >= box.z0 && z <= box.z1) return true;
return false;
}
public boolean intersects(int x0, int y0, int z0, int x1, int y1, int z1) {
if (x0 > x1 || y0 > y1 || z0 > z1) throw new IllegalArgumentException("Reversed city query bounds");
if (y1 < 0 || y0 > 383) return false;
for (int cx = x0 >> 4; cx <= x1 >> 4; cx++) for (int cz = z0 >> 4; cz <= z1 >> 4; cz++) {
var boxes = chunks.get(key(cx, cz));
if (boxes == null) continue;
for (var box : boxes) if (x0 <= box.x1 && x1 >= box.x0 && y0 <= box.y1
&& y1 >= box.y0 && z0 <= box.z1 && z1 >= box.z0) return true;
}
return false;
}
private static long key(int x, int z) { return ((long) x << 32) ^ (z & 0xffffffffL); }
private record Box(int x0, int y0, int z0, int x1, int y1, int z1) {}
public static final class Builder {
private final Map<Long, List<Box>> chunks = new HashMap<>();
public Builder add(int x0, int y0, int z0, int x1, int y1, int z1) {
if (x0 > x1 || y0 > y1 || z0 > z1 || x1 - x0 > 256 || z1 - z0 > 256 || y0 < 0 || y1 > 383)
throw new IllegalArgumentException("Invalid city construction mask");
var box = new Box(x0, y0, z0, x1, y1, z1);
for (int x = x0 >> 4; x <= x1 >> 4; x++) for (int z = z0 >> 4; z <= z1 >> 4; z++)
chunks.computeIfAbsent(key(x, z), ignored -> new ArrayList<>()).add(box);
return this;
}
public Builder add(LostCityPlan surface, LostCityUnderground16.Plan underground) {
for (var district : surface.districts()) for (var parcel : district.parcels()) {
add(parcel.minX(), parcel.minY(), parcel.minZ(), parcel.maxX(), parcel.maxY(), parcel.maxZ());
if (parcel.kind() == LostCityPlan.Kind.BRIDGE) for (int x = 0; x < parcel.width(); x++) for (int z = 0; z < parcel.depth(); z++) {
int y = parcel.surfaceY(x, z);
// Abutments and natural intermediate bearings are retained, never filled in.
add(parcel.minX() + x, y - 2, parcel.minZ() + z, parcel.minX() + x, y - 1, parcel.minZ() + z);
}
}
for (var network : underground.networks()) for (var module : network.modules()) {
add(module.boundsMinX(), module.boundsMinY(), module.boundsMinZ(), module.boundsMaxX(), module.boundsMaxY(), module.boundsMaxZ());
if (module.kind() == LostCityUnderground16.Kind.EXPANSION_HALL)
add(module.minX(), module.minY() - 2, module.minZ(), module.maxX(), module.minY() - 1, module.maxZ());
for (var support : module.supports()) {
int y = support.type() == LostCityUnderground16.Type.PILLAR ? support.minY() - 1 : support.maxY() + 1;
// Retain the untouched rock that carries the painted anchor.
add(support.x(), y, support.z(), support.x(), y, support.z());
}
}
return this;
}
public Builder addReservation(LostCityPlanner16.Fallback r) {
add(r.minX(), r.minY(), r.minZ(), r.maxX(), r.maxY(), r.maxZ());
for (var a : r.anchors()) add(a.minX(), a.minY() - 2, a.minZ(), a.maxX(), a.maxY(), a.maxZ());
return this;
}
public Builder addViaductSupport(LostCityPlanner16.Support support) {
return add(support.minX(), support.minY() - 1, support.minZ(), support.maxX(), support.maxY(), support.maxZ());
}
public LostCityProtection16 build() {
var immutable = new HashMap<Long, List<Box>>();
chunks.forEach((key, value) -> immutable.put(key, List.copyOf(value)));
return new LostCityProtection16(Map.copyOf(immutable));
}
}
}
@@ -0,0 +1,633 @@
package fr.koka.sanctuary.worldgen.city;
import fr.koka.sanctuary.worldgen.city.LostCityPlan.Kind;
import fr.koka.sanctuary.worldgen.city.LostCityPlan.Parcel;
import net.minecraft.core.Direction;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.Rotation;
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.storage.loot.BuiltInLootTables;
import net.minecraft.world.level.storage.loot.LootTable;
/** Original, chunk-independent contemporary ruins. The saved parcel is the complete random input. */
public final class LostCityRenderer {
private static final BlockState AIR = LostCityPalette.AIR;
private static final BlockState SMOOTH = LostCityPalette.SMOOTH;
private static final BlockState STONE = LostCityPalette.STONE;
private static final BlockState WOOD = Blocks.DARK_OAK_PLANKS.defaultBlockState();
private static final int FLOOR_HEIGHT = 5;
/** The structure piece owns world translation, clipping and deferred loot persistence. */
public interface BlockWriter {
void set(int x, int y, int z, BlockState state);
void chest(int x, int y, int z, ResourceKey<LootTable> table, long lootSeed);
}
private LostCityRenderer() {}
public static void render(Parcel parcel, BlockWriter output) {
boolean building = switch (parcel.kind()) {
case APARTMENT, HOTEL, LIBRARY, RESTAURANT, BARRACKS, HALL, PARKING,
EXCAVATION, POOL, PARK_GARDEN, PARK_PLAYGROUND, PARK_MEMORIAL -> true;
default -> false;
};
BlockWriter writer = building ? rotated(output, parcel) : output;
Painter paint = new Painter(parcel, writer);
switch (parcel.kind()) {
case STREET -> paint.street(false);
case BRIDGE -> paint.street(true);
case PARK -> paint.park();
case PARK_GARDEN -> paint.garden();
case PARK_PLAYGROUND -> paint.playground();
case PARK_MEMORIAL -> paint.memorial();
case PLAZA -> paint.plaza(false);
case MARKET -> paint.plaza(true);
case PARKING -> paint.parking();
case EXCAVATION -> paint.excavation();
case POOL -> paint.pool();
case APARTMENT, HOTEL, LIBRARY, RESTAURANT, BARRACKS, HALL -> paint.building();
}
}
private static BlockWriter rotated(BlockWriter output, Parcel parcel) {
if (parcel.facing() == 0) return output;
Rotation rotation = switch (parcel.facing()) {
case 1 -> Rotation.CLOCKWISE_90;
case 2 -> Rotation.CLOCKWISE_180;
case 3 -> Rotation.COUNTERCLOCKWISE_90;
default -> throw new IllegalArgumentException("Invalid Lost City facing");
};
return new BlockWriter() {
private int x(int x, int z) {
return switch (parcel.facing()) {
case 1 -> parcel.width() - 1 - z;
case 2 -> parcel.width() - 1 - x;
default -> z;
};
}
private int z(int x, int z) {
return switch (parcel.facing()) {
case 1 -> x;
case 2 -> parcel.depth() - 1 - z;
default -> parcel.depth() - 1 - x;
};
}
@Override public void set(int x, int y, int z, BlockState state) {
output.set(x(x, z), y, z(x, z), state.rotate(rotation));
}
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long lootSeed) {
output.chest(x(x, z), y, z(x, z), table, lootSeed);
}
};
}
private static final class Painter {
private final Parcel parcel;
private final BlockWriter writer;
private final int width;
private final int depth;
private final int middle;
Painter(Parcel parcel, BlockWriter writer) {
this.parcel = parcel;
this.writer = writer;
width = parcel.width();
depth = parcel.depth();
middle = width / 2;
}
private void set(int x, int y, int z, BlockState state) {
writer.set(x, y, z, state);
}
private void set(int x, int y, int z, Block block) {
set(x, y, z, block.defaultBlockState());
}
private void box(int x0, int y0, int z0, int x1, int y1, int z1, BlockState state) {
for (int z = z0; z <= z1; z++) {
for (int x = x0; x <= x1; x++) {
for (int y = y0; y <= y1; y++) set(x, y, z, state);
}
}
}
private int roll(int x, int y, int z, int bound) {
return LostCityPalette.roll(parcel.seed(), x, y, z, bound);
}
private BlockState brick(int x, int y, int z) {
return LostCityPalette.masonry(parcel.seed(), x, y, z);
}
private void foundation() {
box(0, -parcel.foundationDepth(), 0, width - 1, -1, depth - 1, STONE);
box(0, 0, 0, width - 1, 0, depth - 1, SMOOTH);
}
private void street(boolean bridge) {
boolean eastWest = width > depth;
int length = eastWest ? width : depth;
int breadth = eastWest ? depth : width;
for (int along = 0; along < length; along++) {
for (int across = 0; across < breadth; across++) {
int x = eastWest ? along : across;
int z = eastWest ? across : along;
int y = parcel.surfaceY(x, z) - parcel.floorY();
if (!bridge) {
for (int support = 1; support <= parcel.foundationDepth(); support++) set(x, y - support, z, STONE);
}
// Bridge banks are verified by the planner; the deck never grows support pillars.
boolean edge = across == 0 || across == breadth - 1;
set(x, y, z, edge ? SMOOTH : LostCityPalette.pavement(parcel.seed(), x, z));
for (int air = 1; air <= 5; air++) set(x, y + air, z, AIR);
if (bridge && edge) set(x, y + 1, z, bars(eastWest));
if (!edge && along > 0 && along < length - 1 && across == breadth / 2
&& Math.floorMod(along, 7) < 3) set(x, y, z, SMOOTH);
}
}
}
private void building() {
foundation();
int floors = parcel.floors();
int roof = floors * FLOOR_HEIGHT;
box(0, 1, 0, width - 1, roof + 2, depth - 1, AIR);
for (int y = 1; y <= roof; y++) {
for (int z = 0; z < depth; z++) {
for (int x = 0; x < width; x++) {
boolean wall = x == 0 || x == width - 1 || z == 0 || z == depth - 1;
boolean slab = y % FLOOR_HEIGHT == 0;
boolean hallVoid = parcel.kind() == Kind.HALL && y < roof
&& x > 3 && x < width - 4 && z > 3 && z < depth - 4;
if (slab && !hallVoid) {
set(x, y, z, wall || y == roof ? SMOOTH : WOOD);
} else if (wall) {
boolean pillar = x % 4 == 0 && (z == 0 || z == depth - 1)
|| z % 4 == 0 && (x == 0 || x == width - 1);
boolean window = !pillar && (y % FLOOR_HEIGHT == 2 || y % FLOOR_HEIGHT == 3);
if (pillar) set(x, y, z, STONE);
else if (window) {
if (roll(x, y / 5, z, 7) != 0) set(x, y, z, panes(z == 0 || z == depth - 1));
} else if (!damagedCorner(x, y, z, roof)) set(x, y, z, brick(x, y, z));
}
}
}
}
// Low parapets and rooftop ventilation distinguish flat-roofed modern buildings.
for (int x = 0; x < width; x++) {
if (!damagedCorner(x, roof, 0, roof)) set(x, roof + 1, 0, brick(x, roof, 0));
if (!damagedCorner(x, roof, depth - 1, roof)) set(x, roof + 1, depth - 1, brick(x, roof, depth - 1));
}
for (int z = 1; z < depth - 1; z++) {
set(0, roof + 1, z, brick(0, roof, z));
if (!damagedCorner(width - 1, roof, z, roof)) set(width - 1, roof + 1, z, brick(width - 1, roof, z));
}
box(width - 5, roof + 1, depth - 5, width - 3, roof + 1, depth - 3, SMOOTH);
set(width - 4, roof + 2, depth - 4, Blocks.IRON_TRAPDOOR);
for (int floor = 0; floor < floors; floor++) furnish(floor);
staircase(floors);
// Doorway, central lobby and stair approach are always restored after furnishings.
box(middle - 1, 1, 0, middle + 1, 3, 3, AIR);
box(1, 1, 1, middle + 1, 3, 1, AIR);
box(middle - 2, 4, 0, middle + 2, 4, 1, SMOOTH);
for (int y = 1; y <= 3; y++) {
set(middle - 2, y, 0, SMOOTH);
set(middle + 2, y, 0, SMOOTH);
}
for (int floor = 0; floor < floors; floor++) {
int y = floor * FLOOR_HEIGHT;
if (floor > 0 && parcel.kind() != Kind.HALL) {
box(middle - 1, y + 1, 1, middle + 1, y + 3, depth - 2, AIR);
}
set(width - 2, y + 4, depth - 2, Blocks.COBWEB);
}
// A small breach on the far corner leaves stairs, doors and all floor plates intact.
if (roll(18, 0, 29, 3) == 0) {
box(width - 3, roof, depth - 3, width - 2, roof, depth - 2, AIR);
box(width - 2, roof - 3, depth - 2, width - 2, roof - 3, depth - 2, brick(width - 2, 0, depth - 2));
}
}
private boolean damagedCorner(int x, int y, int z, int roof) {
return x >= width - 4 && z >= depth - 4 && y > roof - 4
&& roll(x / 2, y / 2, z / 2, 4) != 0;
}
private void staircase(int floors) {
for (int floor = 0; floor < floors; floor++) {
int base = floor * FLOOR_HEIGHT;
for (int step = 1; step <= FLOOR_HEIGHT; step++) {
int z = step + 1;
box(1, base + step, z, 2, base + step + 3, z, AIR);
for (int x = 1; x <= 2; x++) set(x, base + step, z, stairs(Direction.SOUTH));
}
box(1, base + FLOOR_HEIGHT, 7, 3, base + FLOOR_HEIGHT, 8, WOOD);
box(1, base + FLOOR_HEIGHT + 1, 7, parcel.kind() == Kind.HALL ? 2 : 3,
base + FLOOR_HEIGHT + 3, 8, AIR);
// West stairwell is accessible from every floor through the rear landing.
if (parcel.kind() != Kind.HALL || floor == 0) {
box(3, base + 1, 7, middle - 1, base + 3, 8, AIR);
}
}
}
private void furnish(int floor) {
int y = floor * FLOOR_HEIGHT;
switch (parcel.kind()) {
case LIBRARY -> library(y);
case RESTAURANT -> restaurant(y, floor == 0);
case HOTEL -> hotel(y, floor == 0);
case BARRACKS -> barracks(y);
case HALL -> hall(y, floor == 0);
case APARTMENT -> apartment(y);
default -> throw new IllegalStateException("Not a building: " + parcel.kind());
}
}
private void library(int y) {
// Large wall shelves, reading tables and stacks with walkable aisles.
box(width - 2, y + 1, 2, width - 2, y + 3, depth - 3, Blocks.BOOKSHELF.defaultBlockState());
box(4, y + 1, depth - 2, width - 3, y + 3, depth - 2, Blocks.BOOKSHELF.defaultBlockState());
for (int z = 4; z < depth - 3; z += 5) {
box(4, y + 1, z, 5, y + 2, z, Blocks.BOOKSHELF.defaultBlockState());
table(width - 6, y + 1, z);
}
set(middle + 3, y + 1, 2, facing(Blocks.LECTERN, Direction.NORTH));
if (y == 0) loot(width - 3, 1, depth - 3, BuiltInLootTables.VILLAGE_CARTOGRAPHER);
}
private void restaurant(int y, boolean kitchen) {
if (kitchen) {
for (int x = 4; x < width - 2; x++) set(x, y + 1, depth - 3, SMOOTH);
set(4, y + 1, depth - 2, facing(Blocks.FURNACE, Direction.NORTH));
set(5, y + 1, depth - 2, facing(Blocks.SMOKER, Direction.NORTH));
set(6, y + 1, depth - 2, Blocks.CAULDRON);
set(width - 3, y + 1, depth - 2, Blocks.BARREL);
box(4, y + 3, depth - 2, 6, y + 3, depth - 2, SMOOTH);
loot(width - 4, y + 1, depth - 2, BuiltInLootTables.VILLAGE_BUTCHER);
}
for (int z = 4; z < depth - 5; z += 4) {
table(middle + 4, y + 1, z);
if (z > 7) table(4, y + 1, z);
}
if (!kitchen) {
box(4, y + 1, depth - 3, width - 3, y + 1, depth - 3, WOOD);
set(width - 3, y + 2, depth - 3, Blocks.FLOWER_POT);
}
}
private void hotel(int y, boolean reception) {
if (reception) {
box(middle + 3, y + 1, 3, width - 3, y + 1, 3, WOOD);
set(width - 3, y + 2, 3, Blocks.POTTED_DEAD_BUSH);
table(middle + 4, y + 1, 8);
loot(width - 3, y + 1, 2, BuiltInLootTables.VILLAGE_PLAINS_HOUSE);
bedroom(width - 5, y, depth - 6);
return;
}
// A central corridor, actual room partitions and doors into the bedrooms.
for (int x : new int[] { middle - 2, middle + 2 }) {
box(x, y + 1, 2, x, y + 4, depth - 2, WOOD);
box(x, y + 1, 4, x, y + 2, 5, AIR);
box(x, y + 1, 10, x, y + 2, 11, AIR);
}
box(middle + 3, y + 1, 8, width - 2, y + 4, 8, WOOD);
bedroom(width - 5, y, 3);
bedroom(width - 5, y, 10);
bedroom(3, y, 10);
}
private void apartment(int y) {
bedroom(width - 5, y, depth - 6);
box(middle + 2, y + 1, depth - 8, width - 2, y + 3, depth - 8, WOOD);
box(middle + 2, y + 1, depth - 7, middle + 2, y + 2, depth - 5, AIR);
table(middle + 4, y + 1, 4);
set(width - 3, y + 1, 2, facing(Blocks.FURNACE, Direction.SOUTH));
set(width - 4, y + 1, 2, SMOOTH);
set(width - 5, y + 1, 2, Blocks.CAULDRON);
box(4, y + 1, depth - 3, 5, y + 1, depth - 3, facing(Blocks.DARK_OAK_STAIRS, Direction.NORTH));
if (y == 0) loot(width - 3, y + 1, depth - 3, BuiltInLootTables.VILLAGE_PLAINS_HOUSE);
}
private void barracks(int y) {
for (int z : new int[] { 3, 8, 12 }) {
bedroom(width - 5, y, z);
if (z > 8) bedroom(4, y, z);
}
box(4, y + 1, 2, 5, y + 1, 2, Blocks.BARREL.defaultBlockState());
box(4, y + 2, 2, 5, y + 2, 2, bars(true));
if (y == 0) loot(width - 3, y + 1, depth - 2, BuiltInLootTables.VILLAGE_FLETCHER);
}
private void hall(int y, boolean groundFloor) {
if (!groundFloor) {
for (int x = 4; x < width - 4; x++) {
set(x, y + 1, 3, bars(true));
set(x, y + 1, depth - 4, bars(true));
}
for (int z = 4; z < depth - 4; z++) {
set(3, y + 1, z, bars(false));
set(width - 4, y + 1, z, bars(false));
}
return;
}
// Double-height meeting hall: an aisle, rows of seats, a stage and lectern.
for (int z = 4; z < depth - 6; z += 3) {
for (int x = 4; x <= 5; x++) set(x, y + 1, z, facing(Blocks.DARK_OAK_STAIRS, Direction.SOUTH));
for (int x = width - 6; x < width - 3; x++) set(x, y + 1, z, facing(Blocks.DARK_OAK_STAIRS, Direction.SOUTH));
}
box(4, 1, depth - 5, width - 3, 1, depth - 2, WOOD);
set(middle, 2, depth - 3, facing(Blocks.LECTERN, Direction.NORTH));
set(middle + 3, 2, depth - 3, Blocks.POTTED_DEAD_BUSH);
loot(width - 3, 1, 2, BuiltInLootTables.VILLAGE_CARTOGRAPHER);
}
private void bedroom(int x, int y, int z) {
if (z + 1 >= depth - 1) return;
BlockState bed = Blocks.BED.white().defaultBlockState()
.setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.SOUTH);
set(x, y + 1, z, bed.setValue(BlockStateProperties.BED_PART, BedPart.FOOT));
set(x, y + 1, z + 1, bed.setValue(BlockStateProperties.BED_PART, BedPart.HEAD));
set(x + 1, y + 1, z + 1, Blocks.BARREL);
}
private void table(int x, int y, int z) {
set(x, y, z, Blocks.DARK_OAK_FENCE);
set(x, y + 1, z, Blocks.DARK_OAK_PRESSURE_PLATE);
set(x - 1, y, z, facing(Blocks.DARK_OAK_STAIRS, Direction.EAST));
set(x + 1, y, z, facing(Blocks.DARK_OAK_STAIRS, Direction.WEST));
}
private void loot(int x, int y, int z, ResourceKey<LootTable> table) {
writer.chest(x, y, z, table, parcel.seed() ^ (x * 0x9e3779b9L) ^ (z * 0xc2b2ae35L));
}
private void park() {
parkGround();
for (int z : new int[] { 3, depth - 4 }) {
for (int x = middle + 3; x <= middle + 5; x++) set(x, 1, z, facing(Blocks.DARK_OAK_STAIRS, Direction.NORTH));
}
tree(3, 3);
if (roll(0, 0, 0, 3) != 0) tree(width - 4, depth - 4);
set(3, 1, depth - 4, Blocks.COMPOSTER);
}
private void parkGround() {
foundation();
box(0, 1, 0, width - 1, 8, depth - 1, AIR);
for (int z = 0; z < depth; z++) {
for (int x = 0; x < width; x++) {
if (Math.abs(x - middle) <= 1 || Math.abs(z - depth / 2) <= 1) {
set(x, 0, z, LostCityPalette.pavement(parcel.seed(), x, z));
} else {
set(x, 0, z, Blocks.GRASS_BLOCK);
if (roll(x, 0, z, 9) == 0) set(x, 1, z, Blocks.SHORT_GRASS);
}
}
}
}
private void garden() {
parkGround();
BlockState hedge = Blocks.OAK_LEAVES.defaultBlockState().setValue(BlockStateProperties.PERSISTENT, true);
for (int z0 : new int[] { 2, depth - 7 }) {
for (int x0 : new int[] { 2, width - 7 }) {
for (int z = z0; z <= z0 + 4; z++) {
for (int x = x0; x <= x0 + 4; x++) {
boolean edge = x == x0 || z == z0 || x == x0 + 4 || z == z0 + 4;
set(x, 1, z, edge ? hedge : (roll(x, 0, z, 2) == 0 ? Blocks.POPPY : Blocks.OXEYE_DAISY).defaultBlockState());
}
}
}
}
for (int x = middle - 1; x <= middle + 1; x++) set(x, 1, depth - 2, facing(Blocks.DARK_OAK_STAIRS, Direction.NORTH));
set(1, 1, depth - 2, Blocks.COMPOSTER);
}
private void playground() {
parkGround();
box(2, 0, 2, 6, 0, 6, Blocks.SAND.defaultBlockState());
box(2, 1, 2, 6, 5, 6, AIR);
// An abandoned swing frame, with a suspended wooden seat.
box(2, 1, 4, 2, 4, 4, Blocks.DARK_OAK_FENCE.defaultBlockState());
box(6, 1, 4, 6, 4, 4, Blocks.DARK_OAK_FENCE.defaultBlockState());
box(2, 5, 4, 6, 5, 4, WOOD);
box(4, 3, 4, 4, 4, 4, Blocks.IRON_CHAIN.defaultBlockState());
set(4, 2, 4, Blocks.DARK_OAK_SLAB);
// A small timber climbing platform and a stone slide, accessible by steps.
box(width - 6, 3, depth - 6, width - 4, 3, depth - 4, WOOD);
box(width - 6, 1, depth - 4, width - 6, 2, depth - 4, WOOD);
box(width - 4, 1, depth - 4, width - 4, 2, depth - 4, WOOD);
for (int step = 0; step < 3; step++) {
set(width - 5, step, depth - 9 + step, stairs(Direction.SOUTH));
box(width - 5, step + 1, depth - 9 + step, width - 5, step + 3, depth - 9 + step, AIR);
}
for (int x = 3; x <= 5; x++) set(x, 1, depth - 4, facing(Blocks.DARK_OAK_STAIRS, Direction.NORTH));
}
private void memorial() {
plaza(false);
box(middle - 2, 1, depth / 2 - 2, middle + 2, 3, depth / 2 + 2, AIR);
box(middle - 1, 1, depth / 2 - 1, middle + 1, 1, depth / 2 + 1, SMOOTH);
set(middle, 2, depth / 2, Blocks.STONE_BRICKS);
set(middle, 3, depth / 2, Blocks.CHISELED_STONE_BRICKS);
set(middle, 4, depth / 2, Blocks.CHISELED_STONE_BRICKS);
set(middle, 5, depth / 2, Blocks.STONE_BRICK_SLAB);
for (int x : new int[] { middle - 3, middle + 3 }) {
set(x, 0, depth / 2, Blocks.GRASS_BLOCK);
set(x, 1, depth / 2, Blocks.OXEYE_DAISY);
}
}
private void excavation() {
if (parcel.foundationDepth() < 8) { park(); return; }
foundation();
box(0, 1, 0, width - 1, 7, depth - 1, AIR);
box(3, -4, 3, width - 4, 0, depth - 4, AIR);
for (int z = 3; z <= depth - 4; z++) {
for (int x = 3; x <= width - 4; x++) set(x, -5, z,
roll(x / 2, 0, z / 2, 4) == 0 ? Blocks.GRAVEL.defaultBlockState() : STONE);
}
// Intact temporary barriers surround the pit; only the street-facing stair gate is open.
for (int x = 2; x < width - 2; x++) {
if (Math.abs(x - middle) > 1) set(x, 1, 2, bars(true));
set(x, 1, depth - 3, bars(true));
}
for (int z = 3; z < depth - 3; z++) {
set(2, 1, z, bars(false));
set(width - 3, 1, z, bars(false));
}
for (int step = 1; step <= 5; step++) {
for (int x = middle - 1; x <= middle + 1; x++) {
box(x, -step + 1, step + 2, x, 2, step + 2, AIR);
set(x, -step, step + 2, stairs(Direction.NORTH));
}
}
// Unfinished footings and construction supplies remain outside the stair route.
box(4, -4, depth - 6, 5, -3, depth - 5, SMOOTH);
box(width - 6, -4, depth - 6, width - 5, -2, depth - 5, brick(11, 0, 11));
set(1, 1, depth - 3, Blocks.BARREL);
set(1, 1, depth - 4, Blocks.CRAFTING_TABLE);
}
private void pool() {
if (parcel.foundationDepth() < 8) { park(); return; }
foundation();
box(0, 1, 0, width - 1, 7, depth - 1, AIR);
// Continuous floor and walls seal three layers of water. Weathering is above the waterline.
box(3, -4, 4, width - 4, 0, depth - 4, SMOOTH);
box(4, -3, 5, width - 5, -1, depth - 5, Blocks.WATER.defaultBlockState());
for (int z = 5; z < depth - 4; z++) {
for (int x = 4; x < width - 4; x++) {
set(x, 0, z, AIR);
if (roll(x, 0, z, 13) == 0) set(x, -4, z, Blocks.MOSS_BLOCK);
}
}
// Broad submerged steps connect the deck to the bottom without ladders or a jump trap.
for (int step = 1; step <= 3; step++) {
for (int x = middle - 1; x <= middle + 1; x++) {
set(x, -step, step + 4, stairs(Direction.NORTH)
.setValue(BlockStateProperties.WATERLOGGED, true));
for (int y = -step + 1; y <= 0; y++) set(x, y, step + 4, AIR);
}
}
for (int z = 5; z < depth - 5; z += 4) {
set(1, 1, z, facing(Blocks.DARK_OAK_STAIRS, Direction.NORTH));
set(1, 1, z + 1, Blocks.DARK_OAK_SLAB);
set(width - 2, 1, z, facing(Blocks.DARK_OAK_STAIRS, Direction.NORTH));
}
set(width - 3, 1, 2, Blocks.CAULDRON);
set(width - 4, 1, 2, Blocks.BARREL);
}
private void tree(int x, int z) {
box(x, 1, z, x, 5, z, Blocks.OAK_LOG.defaultBlockState());
for (int y = 4; y <= 7; y++) {
int radius = y == 7 ? 1 : 2;
for (int dz = -radius; dz <= radius; dz++) {
for (int dx = -radius; dx <= radius; dx++) {
if (dx == 0 && dz == 0 && y <= 5) continue;
if (Math.abs(dx) + Math.abs(dz) > radius + 1) continue;
set(x + dx, y, z + dz, Blocks.OAK_LEAVES.defaultBlockState()
.setValue(BlockStateProperties.PERSISTENT, true));
}
}
}
}
private void plaza(boolean market) {
foundation();
box(0, 1, 0, width - 1, 7, depth - 1, AIR);
for (int z = 0; z < depth; z++) {
for (int x = 0; x < width; x++) set(x, 0, z,
x % 4 == 0 || z % 4 == 0 ? SMOOTH : brick(x, 0, z));
}
if (market) {
stall(2, 2);
stall(width - 6, 2);
stall(2, depth - 6);
stall(width - 6, depth - 6);
loot(width - 4, 1, depth - 4, BuiltInLootTables.VILLAGE_PLAINS_HOUSE);
} else {
// A dry fountain avoids adding uncontrolled water to the hydrology.
for (int z = depth / 2 - 2; z <= depth / 2 + 2; z++) {
for (int x = middle - 2; x <= middle + 2; x++) {
set(x, 1, z, Math.abs(x - middle) == 2 || Math.abs(z - depth / 2) == 2 ? SMOOTH : AIR);
}
}
set(middle, 1, depth / 2, Blocks.CHISELED_STONE_BRICKS);
set(middle, 2, depth / 2, Blocks.STONE_BRICK_WALL);
for (int x = 3; x <= 5; x++) {
set(x, 1, 3, facing(Blocks.DARK_OAK_STAIRS, Direction.SOUTH));
set(width - 1 - x, 1, depth - 4, facing(Blocks.DARK_OAK_STAIRS, Direction.NORTH));
}
}
}
private void stall(int x, int z) {
box(x, 1, z + 2, x + 3, 1, z + 2, WOOD);
for (int dx : new int[] { 0, 3 }) {
box(x + dx, 1, z, x + dx, 3, z, Blocks.DARK_OAK_FENCE.defaultBlockState());
}
for (int dx = 0; dx <= 3; dx++) {
for (int dz = 0; dz <= 2; dz++) {
if (roll(x + dx, 4, z + dz, 8) != 0) set(x + dx, 4, z + dz,
(dx & 1) == 0 ? Blocks.WOOL.lightGray() : Blocks.WOOL.green());
}
}
set(x + 1, 1, z, Blocks.BARREL);
set(x + 2, 2, z + 2, Blocks.FLOWER_POT);
}
private void parking() {
foundation();
// Depth >=8 is the planner's certificate of continuous rock under the whole garage.
boolean underground = parcel.foundationDepth() >= 8;
int base = underground ? -5 : 0;
box(0, base + 1, 0, width - 1, 6, depth - 1, AIR);
box(0, base, 0, width - 1, base, depth - 1, SMOOTH);
box(0, base + 5, 0, width - 1, base + 5, depth - 1, SMOOTH);
for (int z = 0; z < depth; z++) {
for (int x = 0; x < width; x++) {
if (x == 0 || x == width - 1 || z == depth - 1) {
for (int y = base + 1; y < base + 5; y++) {
if (underground || y == base + 1 || x % 4 == 0 || z % 4 == 0) set(x, y, z, brick(x, y, z));
}
}
if (Math.abs(x - middle) <= 2) set(x, base, z, STONE);
else if (z % 4 == 0) set(x, base, z, Blocks.CALCITE);
}
}
for (int x : new int[] { 3, width - 4 }) {
for (int z : new int[] { 3, depth - 4 }) box(x, base + 1, z, x, base + 4, z, STONE);
}
box(0, base + 1, 0, middle - 3, base + 4, 0, SMOOTH);
box(middle + 3, base + 1, 0, width - 1, base + 4, 0, SMOOTH);
if (underground) {
for (int z = 0; z <= 5; z++) {
int y = -z;
box(middle - 2, y + 1, z, middle + 2, 3, z, AIR);
for (int x = middle - 2; x <= middle + 2; x++) {
set(x, y, z, z == 0 ? SMOOTH : stairs(Direction.NORTH));
}
}
} else {
box(middle - 2, 1, 0, middle + 2, 3, 1, AIR);
}
set(width - 3, base + 1, depth - 3, Blocks.BARREL);
loot(width - 3, base + 1, depth - 2, BuiltInLootTables.VILLAGE_MASON);
}
}
private static BlockState facing(Block block, Direction direction) {
return block.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, direction);
}
private static BlockState stairs(Direction direction) {
return facing(Blocks.STONE_BRICK_STAIRS, direction);
}
private static BlockState panes(boolean eastWest) {
return Blocks.GLASS_PANE.defaultBlockState()
.setValue(BlockStateProperties.EAST, eastWest)
.setValue(BlockStateProperties.WEST, eastWest)
.setValue(BlockStateProperties.NORTH, !eastWest)
.setValue(BlockStateProperties.SOUTH, !eastWest);
}
private static BlockState bars(boolean eastWest) {
return Blocks.IRON_BARS.defaultBlockState()
.setValue(BlockStateProperties.EAST, eastWest)
.setValue(BlockStateProperties.WEST, eastWest)
.setValue(BlockStateProperties.NORTH, !eastWest)
.setValue(BlockStateProperties.SOUTH, !eastWest);
}
}
@@ -0,0 +1,66 @@
package fr.koka.sanctuary.worldgen.city;
import com.mojang.serialization.MapCodec;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import java.util.Optional;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructureType;
/** A small native structure start per district, bounded by vanilla's eight-chunk reference reach. */
public final class LostCityStructure extends Structure {
public static final MapCodec<LostCityStructure> CODEC = simpleCodec(LostCityStructure::new);
public LostCityStructure(StructureSettings settings) { super(settings); }
@Override protected Optional<GenerationStub> findGenerationPoint(GenerationContext context) {
if (!(context.chunkGenerator() instanceof SanctuaryChunkGenerator generator)
|| !fr.koka.sanctuary.expansion.ExpansionIsland.spatialGeneration(generator.rootGeneration())) return Optional.empty();
var island = generator.islandForChunk(context.chunkPos().x(), context.chunkPos().z());
if (island == null || island.generation() != generator.rootGeneration()) return Optional.empty();
for (var district : generator.lostCityPlan(island).districts()) {
if (district.startChunkX() != context.chunkPos().x() || district.startChunkZ() != context.chunkPos().z()) continue;
if (district.parcels().isEmpty()) return Optional.empty();
for (var parcel : district.parcels()) {
// Never save a start that vanilla cannot reference in every touched chunk.
if (Math.abs((parcel.minX() >> 4) - district.startChunkX()) > 8
|| Math.abs((parcel.maxX() >> 4) - district.startChunkX()) > 8
|| Math.abs((parcel.minZ() >> 4) - district.startChunkZ()) > 8
|| Math.abs((parcel.maxZ() >> 4) - district.startChunkZ()) > 8)
throw new IllegalStateException("Lost City district exceeds native structure reference range");
}
return Optional.of(new GenerationStub(LostCityStructures.locator(district), pieces -> {
if ((generator.rootGeneration() == 16 || generator.rootGeneration() == 17) && generator.cityReservation16(island) != null)
pieces.addPiece(new LostCityFoundationPiece16(generator.cityReservation16(island), district.seed()));
if (generator.rootGeneration() == 15) for (var support : generator.citySupports(island)) {
if (support.startChunkX() == district.startChunkX() && support.startChunkZ() == district.startChunkZ())
pieces.addPiece(new LostCitySupportPiece15(support));
}
if ((generator.rootGeneration() == 16 || generator.rootGeneration() == 17)) for (var support : generator.citySupports16(island)) {
if (support.startChunkX() == district.startChunkX() && support.startChunkZ() == district.startChunkZ())
pieces.addPiece(new LostCitySupportPiece16(support));
}
district.parcels().forEach(parcel -> pieces.addPiece((generator.rootGeneration() == 16 || generator.rootGeneration() == 17) ? new LostCityPiece16(parcel) : generator.rootGeneration() == 15
? new LostCityPiece15(parcel) : new LostCityPiece(parcel)));
if (generator.rootGeneration() == 15) for (var network : generator.undergroundPlan(island).networks()) {
if (network.startChunkX() != district.startChunkX() || network.startChunkZ() != district.startChunkZ()) continue;
// Surface comes first, rooms/corridors next, shafts last so every entrance remains open.
network.modules().stream().filter(m -> m.kind() != LostCityUnderground15.Kind.ACCESS)
.forEach(m -> pieces.addPiece(new LostCityUndergroundPiece15(m)));
network.modules().stream().filter(m -> m.kind() == LostCityUnderground15.Kind.ACCESS)
.forEach(m -> pieces.addPiece(new LostCityUndergroundPiece15(m)));
}
if ((generator.rootGeneration() == 16 || generator.rootGeneration() == 17)) for (var network : generator.undergroundPlan16(island).networks()) {
if (network.startChunkX() != district.startChunkX() || network.startChunkZ() != district.startChunkZ()) continue;
// Surface comes first, rooms/corridors next, shafts last so every entrance remains open.
network.modules().stream().filter(m -> m.kind() != LostCityUnderground16.Kind.ACCESS)
.forEach(m -> pieces.addPiece(new LostCityUndergroundPiece16(m)));
network.modules().stream().filter(m -> m.kind() == LostCityUnderground16.Kind.ACCESS)
.forEach(m -> pieces.addPiece(new LostCityUndergroundPiece16(m)));
}
}));
}
return Optional.empty();
}
@Override public StructureType<?> type() { return LostCityStructures.TYPE; }
}
@@ -0,0 +1,122 @@
package fr.koka.sanctuary.worldgen.city;
import com.mojang.datafixers.util.Pair;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.expansion.ExpansionChunkGenerator;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import java.util.ArrayList;
import java.util.Comparator;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderSet;
import net.minecraft.core.Registry;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.StructureManager;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.StructureType;
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceType;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
/** Native starts, pieces, references and deferred loot; only the placement plan is Sanctuary-owned. */
public final class LostCityStructures {
public static final ResourceKey<Structure> KEY = ResourceKey.create(Registries.STRUCTURE, SanctuaryMod.id("lost_city"));
public static final StructureType<LostCityStructure> TYPE = Registry.register(
BuiltInRegistries.STRUCTURE_TYPE, SanctuaryMod.id("lost_city"), () -> LostCityStructure.CODEC);
public static final StructurePieceType PIECE = Registry.register(
BuiltInRegistries.STRUCTURE_PIECE, SanctuaryMod.id("lost_city_parcel"), LostCityPiece::new);
public static final StructurePieceType PIECE15 = Registry.register(
BuiltInRegistries.STRUCTURE_PIECE, SanctuaryMod.id("lost_city_parcel_v15"), LostCityPiece15::new);
public static final StructurePieceType UNDERGROUND15 = Registry.register(
BuiltInRegistries.STRUCTURE_PIECE, SanctuaryMod.id("lost_city_underground_v15"), LostCityUndergroundPiece15::new);
public static final StructurePieceType SUPPORT15 = Registry.register(
BuiltInRegistries.STRUCTURE_PIECE, SanctuaryMod.id("lost_city_support_v15"), LostCitySupportPiece15::new);
public static final StructurePieceType PIECE16 = Registry.register(
BuiltInRegistries.STRUCTURE_PIECE, SanctuaryMod.id("lost_city_parcel_v16"), LostCityPiece16::new);
public static final StructurePieceType UNDERGROUND16 = Registry.register(
BuiltInRegistries.STRUCTURE_PIECE, SanctuaryMod.id("lost_city_underground_v16"), LostCityUndergroundPiece16::new);
public static final StructurePieceType SUPPORT16 = Registry.register(
BuiltInRegistries.STRUCTURE_PIECE, SanctuaryMod.id("lost_city_support_v16"), LostCitySupportPiece16::new);
public static final StructurePieceType FOUNDATION16 = Registry.register(
BuiltInRegistries.STRUCTURE_PIECE, SanctuaryMod.id("lost_city_foundation_v16"), LostCityFoundationPiece16::new);
private static final ThreadLocal<Integer> PLACEMENT_DEPTH = ThreadLocal.withInitial(() -> 0);
private LostCityStructures() {}
public static void register() { /* Load static serializers before dynamic registries. */ }
public static boolean isPlacing() { return PLACEMENT_DEPTH.get() > 0; }
static void placing(Runnable renderer) {
int previous = PLACEMENT_DEPTH.get();
PLACEMENT_DEPTH.set(previous + 1);
try { renderer.run(); }
finally {
if (previous == 0) PLACEMENT_DEPTH.remove();
else PLACEMENT_DEPTH.set(previous);
}
}
public static void generate(ExpansionChunkGenerator generator, RegistryAccess registries,
ChunkGeneratorStructureState state, StructureManager manager, ChunkAccess chunk,
StructureTemplateManager templates, ResourceKey<Level> level) {
if (!manager.shouldGenerateStructures() || !(generator instanceof SanctuaryChunkGenerator sanctuary)
|| !fr.koka.sanctuary.expansion.ExpansionIsland.spatialGeneration(generator.rootGeneration())) return;
var island = generator.islandForChunk(chunk.getPos().x(), chunk.getPos().z());
if (island == null || island.generation() != generator.rootGeneration()) return;
var plan = sanctuary.lostCityPlan(island);
if (plan.districts().stream().noneMatch(d -> d.startChunkX() == chunk.getPos().x()
&& d.startChunkZ() == chunk.getPos().z())) return;
Holder<Structure> holder = registries.lookupOrThrow(Registries.STRUCTURE).getOrThrow(KEY);
Structure structure = holder.value();
StructureStart previous = manager.getStartForStructure(structure, chunk);
if (previous != null && previous.isValid()) return;
var random = generator.randomState(island);
var sampler = random.createClimateSampler(SamplerContext.builder().enableCaches().build());
StructureStart start = structure.generate(holder, level, registries, generator, generator.getBiomeSource(),
sampler, random, templates, island.seed(), chunk.getPos(), previous == null ? 0 : previous.getReferences(),
chunk, structure.biomes()::contains);
if (start.isValid()) manager.setStartForStructure(structure, start, chunk);
}
/** Search only activated, ready islands. Native starts are loaded at STRUCTURE_STARTS, never regenerated. */
public static Pair<BlockPos, Holder<Structure>> findNearest(ServerLevel level, HolderSet<Structure> structures,
BlockPos origin, int radius, boolean skipReferenced) {
if (!(level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator generator)
|| !fr.koka.sanctuary.expansion.ExpansionIsland.spatialGeneration(generator.rootGeneration()) || !level.structureManager().shouldGenerateStructures()) return null;
Holder<Structure> holder = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(KEY);
if (!structures.contains(holder)) return null;
var candidates = new ArrayList<LostCityPlan.District>();
int originChunkX = origin.getX() >> 4, originChunkZ = origin.getZ() >> 4;
for (var island : generator.source().islands()) {
if (island.generation() != generator.rootGeneration() || !island.status().equals("ready")) continue;
for (var district : generator.lostCityPlan(island).districts()) {
if (Math.abs((long) district.startChunkX() - originChunkX) <= radius
&& Math.abs((long) district.startChunkZ() - originChunkZ) <= radius) candidates.add(district);
}
}
candidates.sort(Comparator.comparingDouble(d -> origin.distSqr(locator(d))));
for (var district : candidates) {
var chunk = level.getChunk(district.startChunkX(), district.startChunkZ(), ChunkStatus.STRUCTURE_STARTS, true);
StructureStart start = level.structureManager().getStartForStructure(holder.value(), chunk);
if (start == null || !start.isValid() || skipReferenced && !start.canBeReferenced()) continue;
if (skipReferenced) level.structureManager().addReference(start);
return Pair.of(start.getPieces().getFirst().getLocatorPosition(), holder);
}
return null;
}
static BlockPos locator(LostCityPlan.District district) {
var parcel = district.parcels().getFirst();
int x = parcel.width() / 2, z = parcel.depth() / 2;
return new BlockPos(parcel.minX() + x, parcel.surfaceY(x, z) + 1, parcel.minZ() + z);
}
}
@@ -0,0 +1,68 @@
package fr.koka.sanctuary.worldgen.city;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.StructureManager;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.StructurePiece;
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
/** A short, terrain-certified viaduct pier, saved separately from the road deck. */
public final class LostCitySupportPiece15 extends StructurePiece {
private final LostCityPlanner15.Support support;
public LostCitySupportPiece15(LostCityPlanner15.Support support) {
super(LostCityStructures.SUPPORT15, 0, bounds(support));
this.support = support;
setOrientation(null);
}
public LostCitySupportPiece15(StructurePieceSerializationContext context, CompoundTag tag) {
super(LostCityStructures.SUPPORT15, tag);
if (tag.getInt("SanctuarySchema").orElseThrow() != 1)
throw new IllegalArgumentException("Unsupported city support schema");
support = new LostCityPlanner15.Support(tag.getInt("StartX").orElseThrow(), tag.getInt("StartZ").orElseThrow(),
tag.getInt("MinX").orElseThrow(), tag.getInt("MinY").orElseThrow(), tag.getInt("MinZ").orElseThrow(),
tag.getInt("MaxX").orElseThrow(), tag.getInt("MaxY").orElseThrow(), tag.getInt("MaxZ").orElseThrow(),
tag.getLong("SupportSeed").orElseThrow());
if (!getBoundingBox().equals(bounds(support))) throw new IllegalArgumentException("City support bounds disagree with its saved geometry");
setOrientation(null);
}
public LostCityPlanner15.Support support() { return support; }
@Override public BlockPos getLocatorPosition() {
return new BlockPos((support.minX() + support.maxX()) / 2, support.maxY() + 2,
(support.minZ() + support.maxZ()) / 2);
}
@Override protected void addAdditionalSaveData(StructurePieceSerializationContext context, CompoundTag tag) {
tag.putInt("SanctuarySchema", 1);
tag.putInt("StartX", support.startChunkX()); tag.putInt("StartZ", support.startChunkZ());
tag.putInt("MinX", support.minX()); tag.putInt("MinY", support.minY()); tag.putInt("MinZ", support.minZ());
tag.putInt("MaxX", support.maxX()); tag.putInt("MaxY", support.maxY()); tag.putInt("MaxZ", support.maxZ());
tag.putLong("SupportSeed", support.seed());
}
@Override public void postProcess(WorldGenLevel level, StructureManager manager, ChunkGenerator generator,
RandomSource random, BoundingBox clip, ChunkPos chunk, BlockPos pivot) {
LostCityStructures.placing(() -> {
for (int x = Math.max(clip.minX(), support.minX()); x <= Math.min(clip.maxX(), support.maxX()); x++)
for (int z = Math.max(clip.minZ(), support.minZ()); z <= Math.min(clip.maxZ(), support.maxZ()); z++)
for (int y = Math.max(clip.minY(), support.minY()); y <= Math.min(clip.maxY(), support.maxY()); y++) {
var block = y == support.minY() || y == support.maxY() ? Blocks.SMOOTH_STONE.defaultBlockState()
: LostCityPalette.masonry(support.seed(), x - support.minX(), y - support.minY(), z - support.minZ());
placeBlock(level, block, x, y, z, clip);
}
});
}
public static BoundingBox bounds(LostCityPlanner15.Support support) {
return new BoundingBox(support.minX(), support.minY(), support.minZ(), support.maxX(), support.maxY(), support.maxZ());
}
}
@@ -0,0 +1,68 @@
package fr.koka.sanctuary.worldgen.city;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.StructureManager;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.StructurePiece;
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
/** A short, terrain-certified viaduct pier, saved separately from the road deck. */
public final class LostCitySupportPiece16 extends StructurePiece {
private final LostCityPlanner16.Support support;
public LostCitySupportPiece16(LostCityPlanner16.Support support) {
super(LostCityStructures.SUPPORT16, 0, bounds(support));
this.support = support;
setOrientation(null);
}
public LostCitySupportPiece16(StructurePieceSerializationContext context, CompoundTag tag) {
super(LostCityStructures.SUPPORT16, tag);
if (tag.getInt("SanctuarySchema").orElseThrow() != 1)
throw new IllegalArgumentException("Unsupported city support schema");
support = new LostCityPlanner16.Support(tag.getInt("StartX").orElseThrow(), tag.getInt("StartZ").orElseThrow(),
tag.getInt("MinX").orElseThrow(), tag.getInt("MinY").orElseThrow(), tag.getInt("MinZ").orElseThrow(),
tag.getInt("MaxX").orElseThrow(), tag.getInt("MaxY").orElseThrow(), tag.getInt("MaxZ").orElseThrow(),
tag.getLong("SupportSeed").orElseThrow());
if (!getBoundingBox().equals(bounds(support))) throw new IllegalArgumentException("City support bounds disagree with its saved geometry");
setOrientation(null);
}
public LostCityPlanner16.Support support() { return support; }
@Override public BlockPos getLocatorPosition() {
return new BlockPos((support.minX() + support.maxX()) / 2, support.maxY() + 2,
(support.minZ() + support.maxZ()) / 2);
}
@Override protected void addAdditionalSaveData(StructurePieceSerializationContext context, CompoundTag tag) {
tag.putInt("SanctuarySchema", 1);
tag.putInt("StartX", support.startChunkX()); tag.putInt("StartZ", support.startChunkZ());
tag.putInt("MinX", support.minX()); tag.putInt("MinY", support.minY()); tag.putInt("MinZ", support.minZ());
tag.putInt("MaxX", support.maxX()); tag.putInt("MaxY", support.maxY()); tag.putInt("MaxZ", support.maxZ());
tag.putLong("SupportSeed", support.seed());
}
@Override public void postProcess(WorldGenLevel level, StructureManager manager, ChunkGenerator generator,
RandomSource random, BoundingBox clip, ChunkPos chunk, BlockPos pivot) {
LostCityStructures.placing(() -> {
for (int x = Math.max(clip.minX(), support.minX()); x <= Math.min(clip.maxX(), support.maxX()); x++)
for (int z = Math.max(clip.minZ(), support.minZ()); z <= Math.min(clip.maxZ(), support.maxZ()); z++)
for (int y = Math.max(clip.minY(), support.minY()); y <= Math.min(clip.maxY(), support.maxY()); y++) {
var block = y == support.minY() || y == support.maxY() ? Blocks.SMOOTH_STONE.defaultBlockState()
: LostCityPalette.masonry(support.seed(), x - support.minX(), y - support.minY(), z - support.minZ());
placeBlock(level, block, x, y, z, clip);
}
});
}
public static BoundingBox bounds(LostCityPlanner16.Support support) {
return new BoundingBox(support.minX(), support.minY(), support.minZ(), support.maxX(), support.maxY(), support.maxZ());
}
}
@@ -0,0 +1,90 @@
package fr.koka.sanctuary.worldgen.city;
import net.minecraft.core.Direction;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.Rotation;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.storage.loot.LootTable;
/** Generation15 surface corrections; the saved generation14 renderer remains unchanged. */
public final class LostCitySurface15 {
private static final Direction[] HORIZONTAL = {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST};
private LostCitySurface15() {}
public static void render(LostCityPlan.Parcel parcel, LostCityRenderer.BlockWriter output) {
if (parcel.kind().building()) {
LostCityInteriors15.render(parcel, output);
return;
}
LostCityRenderer.render(parcel, new LostCityRenderer.BlockWriter() {
@Override public void set(int x, int y, int z, BlockState state) {
if (state.is(Blocks.DARK_OAK_STAIRS)) state = seat(parcel, x, z, state);
output.set(x, y, z, state);
}
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long seed) {
output.chest(x, y, z, table, seed);
}
});
if (parcel.kind() == LostCityPlan.Kind.STREET || parcel.kind() == LostCityPlan.Kind.BRIDGE) {
// The compact planner certifies cuts of up to six blocks. Clear their whole
// headroom, including the surface of the cut, without widening the road.
for (int z = 0; z < parcel.depth(); z++) for (int x = 0; x < parcel.width(); x++) {
int y = parcel.surfaceY(x, z) - parcel.floorY();
for (int air = 6; air <= 8; air++) output.set(x, y + air, z, LostCityPalette.AIR);
boolean eastWest = parcel.width() > parcel.depth();
int across = eastWest ? z : x;
int breadth = eastWest ? parcel.depth() : parcel.width();
// Keep the intact bridge parapet on its flat stone curb.
if (parcel.kind() == LostCityPlan.Kind.BRIDGE && (across == 0 || across == breadth - 1)) continue;
for (Direction direction : HORIZONTAL) {
int nx = x + direction.getStepX(), nz = z + direction.getStepZ();
if (nx >= 0 && nx < parcel.width() && nz >= 0 && nz < parcel.depth()
&& parcel.surfaceY(nx, nz) == parcel.surfaceY(x, z) + 1) {
output.set(x, y + 1, z, Blocks.STONE_BRICK_STAIRS.defaultBlockState()
.setValue(BlockStateProperties.HORIZONTAL_FACING, direction));
break;
}
}
}
}
}
private static BlockState seat(LostCityPlan.Parcel p, int x, int z, BlockState state) {
boolean rotated = switch (p.kind()) {
case PARK_GARDEN, PARK_PLAYGROUND, PARK_MEMORIAL, EXCAVATION, POOL -> true;
default -> false;
};
int turn = rotated ? p.facing() : 0;
int localX = switch (turn) {
case 1 -> z;
case 2 -> p.width() - 1 - x;
case 3 -> p.depth() - 1 - z;
default -> x;
};
int localZ = switch (turn) {
case 1 -> p.width() - 1 - x;
case 2 -> p.depth() - 1 - z;
case 3 -> x;
default -> z;
};
// Stair FACING points toward the high half: it is the seat back, not its view.
Direction back = switch (p.kind()) {
case PARK, EXCAVATION, PLAZA, PARK_MEMORIAL -> localZ > p.depth() / 2 ? Direction.SOUTH : Direction.NORTH;
case PARK_GARDEN, PARK_PLAYGROUND -> Direction.SOUTH;
case POOL -> p.foundationDepth() < 8
? (localZ > p.depth() / 2 ? Direction.SOUTH : Direction.NORTH)
: (localX == 1 ? Direction.NORTH : Direction.EAST);
default -> null;
};
if (back == null) return state;
Rotation rotation = switch (turn) {
case 1 -> Rotation.CLOCKWISE_90;
case 2 -> Rotation.CLOCKWISE_180;
case 3 -> Rotation.COUNTERCLOCKWISE_90;
default -> Rotation.NONE;
};
return state.setValue(BlockStateProperties.HORIZONTAL_FACING, rotation.rotate(back));
}
}
@@ -0,0 +1,90 @@
package fr.koka.sanctuary.worldgen.city;
import net.minecraft.core.Direction;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.Rotation;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.storage.loot.LootTable;
/** Generation16 surface corrections; the saved generation14 renderer remains unchanged. */
public final class LostCitySurface16 {
private static final Direction[] HORIZONTAL = {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST};
private LostCitySurface16() {}
public static void render(LostCityPlan.Parcel parcel, LostCityRenderer.BlockWriter output) {
if (parcel.kind().building()) {
LostCityInteriors16.render(parcel, output);
return;
}
LostCityRenderer.render(parcel, new LostCityRenderer.BlockWriter() {
@Override public void set(int x, int y, int z, BlockState state) {
if (state.is(Blocks.DARK_OAK_STAIRS)) state = seat(parcel, x, z, state);
output.set(x, y, z, state);
}
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long seed) {
output.chest(x, y, z, table, seed);
}
});
if (parcel.kind() == LostCityPlan.Kind.STREET || parcel.kind() == LostCityPlan.Kind.BRIDGE) {
// The compact planner certifies cuts of up to six blocks. Clear their whole
// headroom, including the surface of the cut, without widening the road.
for (int z = 0; z < parcel.depth(); z++) for (int x = 0; x < parcel.width(); x++) {
int y = parcel.surfaceY(x, z) - parcel.floorY();
for (int air = 6; air <= 8; air++) output.set(x, y + air, z, LostCityPalette.AIR);
boolean eastWest = parcel.width() > parcel.depth();
int across = eastWest ? z : x;
int breadth = eastWest ? parcel.depth() : parcel.width();
// Keep the intact bridge parapet on its flat stone curb.
if (parcel.kind() == LostCityPlan.Kind.BRIDGE && (across == 0 || across == breadth - 1)) continue;
for (Direction direction : HORIZONTAL) {
int nx = x + direction.getStepX(), nz = z + direction.getStepZ();
if (nx >= 0 && nx < parcel.width() && nz >= 0 && nz < parcel.depth()
&& parcel.surfaceY(nx, nz) == parcel.surfaceY(x, z) + 1) {
output.set(x, y + 1, z, Blocks.STONE_BRICK_STAIRS.defaultBlockState()
.setValue(BlockStateProperties.HORIZONTAL_FACING, direction));
break;
}
}
}
}
}
private static BlockState seat(LostCityPlan.Parcel p, int x, int z, BlockState state) {
boolean rotated = switch (p.kind()) {
case PARK_GARDEN, PARK_PLAYGROUND, PARK_MEMORIAL, EXCAVATION, POOL -> true;
default -> false;
};
int turn = rotated ? p.facing() : 0;
int localX = switch (turn) {
case 1 -> z;
case 2 -> p.width() - 1 - x;
case 3 -> p.depth() - 1 - z;
default -> x;
};
int localZ = switch (turn) {
case 1 -> p.width() - 1 - x;
case 2 -> p.depth() - 1 - z;
case 3 -> x;
default -> z;
};
// Stair FACING points toward the high half: it is the seat back, not its view.
Direction back = switch (p.kind()) {
case PARK, EXCAVATION, PLAZA, PARK_MEMORIAL -> localZ > p.depth() / 2 ? Direction.SOUTH : Direction.NORTH;
case PARK_GARDEN, PARK_PLAYGROUND -> Direction.SOUTH;
case POOL -> p.foundationDepth() < 8
? (localZ > p.depth() / 2 ? Direction.SOUTH : Direction.NORTH)
: (localX == 1 ? Direction.NORTH : Direction.EAST);
default -> null;
};
if (back == null) return state;
Rotation rotation = switch (turn) {
case 1 -> Rotation.CLOCKWISE_90;
case 2 -> Rotation.CLOCKWISE_180;
case 3 -> Rotation.COUNTERCLOCKWISE_90;
default -> Rotation.NONE;
};
return state.setValue(BlockStateProperties.HORIZONTAL_FACING, rotation.rotate(back));
}
}
@@ -0,0 +1,73 @@
package fr.koka.sanctuary.worldgen.city;
import fr.koka.sanctuary.expansion.ExpansionIsland;
import fr.koka.sanctuary.expansion.ExpansionTerrain;
import fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Predicate;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.LevelHeightAccessor;
import net.minecraft.world.level.NoiseColumn;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import net.minecraft.world.level.levelgen.RandomState;
/** Short-lived noise-column cache shared by surface and underground planning, never loaded chunks. */
public final class LostCityTerrain15 implements LostCityUnderground15.Terrain, LostCityPlanner15.Terrain {
public static final int MAX_COLUMNS = 20_096;
private final ExpansionIsland island;
private final NoiseBasedChunkGenerator generator;
private final RandomState random;
private final LevelHeightAccessor height;
private final Predicate<BlockPos> protectedVolume;
private final Map<Long, NoiseColumn> columns = new HashMap<>();
private final BlockPos fluidCandidate;
public LostCityTerrain15(ExpansionIsland island, NoiseBasedChunkGenerator generator, RandomState random,
LevelHeightAccessor height, Predicate<BlockPos> protectedVolume) {
this.island = island; this.generator = generator; this.random = random;
this.height = height; this.protectedVolume = protectedVolume;
fluidCandidate = ExpansionTerrain.pocketCandidate(island);
}
private NoiseColumn column(int x, int z) {
if (!owns(x, z)) return null;
long key = ((long) x << 32) ^ (z & 0xffffffffL);
var cached = columns.get(key);
if (cached != null) return cached;
if (columns.size() >= MAX_COLUMNS) return null;
var result = generator.getBaseColumn(x, z, height, random);
columns.put(key, result);
return result;
}
public LostCityPlanner.Ground ground(int x, int z) {
var column = column(x, z);
if (column == null) return new LostCityPlanner.Ground(0, 0, false, true);
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 water = island.origin() && top >= 168 && dry && top - bottom >= 6
&& PopulationHydrologyRuntime.protects(generator, random, new BlockPos(x, top, z), 1, 30, 12);
return new LostCityPlanner.Ground(top, top - bottom, dry, water);
}
@Override public boolean solid(int x, int y, int z) {
if (y < 0 || y >= 384) return false;
var column = column(x, z);
return column != null && column.getBlock(y).isSolid() && column.getBlock(y).getFluidState().isEmpty();
}
@Override public boolean protectedAt(int x, int y, int z) {
if (y < 0 || y >= 384) return true;
var column = column(x, z);
if (column == null || !column.getBlock(y).getFluidState().isEmpty()) return true;
if (!island.origin() && island.relief().equals("volcano")
&& Math.abs(x - fluidCandidate.getX()) <= 2 && Math.abs(z - fluidCandidate.getZ()) <= 2) return true;
return protectedVolume.test(new BlockPos(x, y, z));
}
@Override public boolean owns(int x, int z) { return island.contains(x, z); }
public int columnsUsed() { return columns.size(); }
}
@@ -0,0 +1,85 @@
package fr.koka.sanctuary.worldgen.city;
import fr.koka.sanctuary.expansion.ExpansionIsland;
import fr.koka.sanctuary.expansion.ExpansionTerrain;
import fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Predicate;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.LevelHeightAccessor;
import net.minecraft.world.level.NoiseColumn;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import net.minecraft.world.level.levelgen.RandomState;
/** Short-lived noise-column cache shared by surface and underground planning, never loaded chunks. */
public final class LostCityTerrain16 implements LostCityUnderground16.Terrain, LostCityPlanner16.Terrain {
public static final int MAX_COLUMNS = 20_096;
private final ExpansionIsland island;
private final NoiseBasedChunkGenerator generator;
private final RandomState random;
private final LevelHeightAccessor height;
private final Predicate<BlockPos> protectedVolume;
private final Map<Long, NoiseColumn> columns = new HashMap<>();
private final BlockPos fluidCandidate;
public LostCityTerrain16(ExpansionIsland island, NoiseBasedChunkGenerator generator, RandomState random,
LevelHeightAccessor height, Predicate<BlockPos> protectedVolume) {
this.island = island; this.generator = generator; this.random = random;
this.height = height; this.protectedVolume = protectedVolume;
fluidCandidate = ExpansionTerrain.pocketCandidate(island);
}
private NoiseColumn column(int x, int z) {
if (!owns(x, z)) return null;
long key = ((long) x << 32) ^ (z & 0xffffffffL);
var cached = columns.get(key);
if (cached != null) return cached;
if (columns.size() >= MAX_COLUMNS) return null;
var result = generator.getBaseColumn(x, z, height, random);
columns.put(key, result);
return result;
}
public LostCityPlanner.Ground ground(int x, int z) {
var column = column(x, z);
if (column == null) return new LostCityPlanner.Ground(0, 0, false, true);
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 water = island.origin() && top >= 168 && dry && top - bottom >= 6
&& PopulationHydrologyRuntime.protects(generator, random, new BlockPos(x, top, z), 1, 30, 12);
return new LostCityPlanner.Ground(top, top - bottom, dry, water);
}
@Override public boolean solid(int x, int y, int z) {
if (y < 0 || y >= 384) return false;
var column = column(x, z);
return column != null && column.getBlock(y).isSolid() && column.getBlock(y).getFluidState().isEmpty();
}
@Override public boolean protectedAt(int x, int y, int z) {
if (y < 0 || y >= 384) return true;
var column = column(x, z);
if (column == null || !column.getBlock(y).getFluidState().isEmpty()) return true;
if (!island.origin() && island.relief().equals("volcano")
&& Math.abs(x - fluidCandidate.getX()) <= 2 && Math.abs(z - fluidCandidate.getZ()) <= 2) return true;
return protectedVolume.test(new BlockPos(x, y, z));
}
@Override public boolean exteriorAir(int x, int y, int z, int dx, int dz) {
if (Math.abs(dx) + Math.abs(dz) != 1 || y < 1 || y >= 380 || !owns(x, z)) return false;
for (int distance = 0; distance <= 32; distance++) {
int px = x + dx * distance, pz = z + dz * distance;
if (!owns(px, pz)) return false; // Never infer exterior air in another present or future owner.
var sample = column(px, pz);
if (sample == null) return false;
for (int dy = -3; dy < 3; dy++) if (!sample.getBlock(y + dy).isAir()
|| protectedVolume.test(new BlockPos(px, y + dy, pz))) return false;
}
return true;
}
@Override public boolean owns(int x, int z) { return island.contains(x, z); }
public int columnsUsed() { return columns.size(); }
}
@@ -0,0 +1,492 @@
package fr.koka.sanctuary.worldgen.city;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import net.minecraft.core.Direction;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
/** Generation 15 only: shared, terrain-certified utility networks. All rendering inputs are saved records. */
public final class LostCityUnderground15 {
public static final int MAX_SAMPLES = 180_000;
private static final int[] DX = {0, 1, 0, -1}, DZ = {-1, 0, 1, 0};
private static final int CELL = 20;
private LostCityUnderground15() {}
public interface Terrain {
boolean solid(int x, int y, int z);
boolean protectedAt(int x, int y, int z);
boolean owns(int x, int z);
}
public enum Kind { ACCESS, SEWER, POOLROOM, GARAGE, PASSAGE, EXPANSION_HALL }
public enum Type { PILLAR, CHAIN }
public record Support(Type type, int x, int minY, int z, int maxY) {
public Support {
if (type == null || minY < 1 || maxY > 382 || minY > maxY || maxY - minY >= 8)
throw new IllegalArgumentException("Invalid underground support");
}
}
/** openings bits: north=1, east=2, south=4, west=8. Body coordinates are absolute; writer coordinates are local. */
public record Module(Kind kind, int minX, int minY, int minZ, int width, int height, int depth,
int openings, long seed, List<Support> supports) {
public Module {
if (kind == null || width < 5 || depth < 5 || width > 31 || depth > 31 || height < 6 || height > 36
|| minY < 8 || minY + height > 383 || openings < 0 || openings > 15 || supports.size() > 24)
throw new IllegalArgumentException("Invalid underground module");
supports = List.copyOf(supports);
for (var support : supports) if (support.x() < minX || support.x() >= minX + width
|| support.z() < minZ || support.z() >= minZ + depth)
throw new IllegalArgumentException("Support is outside its module footprint");
}
public int floorY() { return minY + 1; }
public int maxX() { return minX + width - 1; }
public int maxY() { return minY + height - 1; }
public int maxZ() { return minZ + depth - 1; }
public int centerX() { return minX + width / 2; }
public int centerZ() { return minZ + depth / 2; }
public int boundsMinX() { return minX; }
public int boundsMaxX() { return maxX(); }
public int boundsMinZ() { return minZ; }
public int boundsMaxZ() { return maxZ(); }
public int boundsMinY() { return Math.min(minY, supports.stream().mapToInt(Support::minY).min().orElse(minY)); }
public int boundsMaxY() { return Math.max(maxY(), supports.stream().mapToInt(Support::maxY).max().orElse(maxY())); }
public boolean contains(int x, int y, int z) {
if (x < minX || x > maxX() || z < minZ || z > maxZ()) return false;
if (y >= minY && y <= maxY()) return true;
return supports.stream().anyMatch(s -> s.x() == x && s.z() == z && y >= s.minY() && y <= s.maxY());
}
private Module opening(int direction) { return new Module(kind, minX, minY, minZ, width, height, depth,
openings | 1 << direction, seed, supports); }
private Module supported(List<Support> values) { return new Module(kind, minX, minY, minZ, width, height, depth, openings, seed, values); }
}
public record Network(int startChunkX, int startChunkZ, long seed, List<Module> modules) {
public Network {
modules = List.copyOf(modules);
if (modules.isEmpty() || modules.size() > 21) throw new IllegalArgumentException("Invalid underground network");
for (var module : modules) if (Math.abs(Math.floorDiv(module.boundsMinX(), 16) - startChunkX) > 7
|| Math.abs(Math.floorDiv(module.boundsMaxX(), 16) - startChunkX) > 7
|| Math.abs(Math.floorDiv(module.boundsMinZ(), 16) - startChunkZ) > 7
|| Math.abs(Math.floorDiv(module.boundsMaxZ(), 16) - startChunkZ) > 7)
throw new IllegalArgumentException("Underground network exceeds native reference range");
}
}
public record Plan(List<Network> networks, int samples) {
public Plan { networks = List.copyOf(networks); if (samples < 0 || samples > MAX_SAMPLES) throw new IllegalArgumentException("Invalid sampling budget"); }
public boolean isEmpty() { return networks.isEmpty(); }
public boolean contains(int x, int y, int z) {
return networks.stream().anyMatch(n -> n.modules().stream().anyMatch(m -> m.contains(x, y, z)));
}
}
public static Plan plan(long seed, LostCityPlan surface, Terrain terrain) {
var probe = new Probe(terrain); var networks = new ArrayList<Network>();
for (var district : surface.districts()) {
if (probe.samples >= MAX_SAMPLES) break;
int centerX = (district.minX() + district.maxX()) / 2, centerZ = (district.minZ() + district.maxZ()) / 2;
var anchors = district.parcels().stream().filter(LostCityUnderground15::accessParcel)
.sorted(Comparator.<LostCityPlan.Parcel>comparingInt(p -> Math.abs(p.minX() + p.width() / 2 - centerX)
+ Math.abs(p.minZ() + p.depth() / 2 - centerZ)).thenComparingLong(p -> mix(seed ^ p.seed())))
.limit(8).flatMap(p -> anchors(p).stream()).limit(24).toList();
Network accepted = null;
int bestScore = -1, attempts = 0;
try {
search: for (var anchor : anchors) {
int x = anchor.parcel().minX() + anchor.localX(), z = anchor.parcel().minZ() + anchor.localZ();
int top = anchor.top();
for (int drop : new int[] {22, 26, 18}) {
int floor = top - drop;
if (floor < 32) continue;
long networkSeed = mix(seed ^ district.seed() ^ anchor.parcel().seed() ^ drop);
var access = new Module(Kind.ACCESS, x - 2, floor - 1, z - 2, 5, drop + 4, 5, 0, networkSeed, List.of());
access = certify(access, district, surface, probe, false);
if (access == null) continue;
for (int attempt = 0; attempt < 4; attempt++) {
if (attempts++ >= 8 || probe.samples >= MAX_SAMPLES) break search;
int direction = (roll(networkSeed, 4) + attempt) & 3;
var modules = new ArrayList<Module>(); modules.add(access);
if (!connect(modules, 0, Kind.SEWER, direction, district, surface, probe, networkSeed)) continue;
try {
sharedNetwork(modules, direction, district, surface, probe, networkSeed);
} catch (BudgetExceeded exhausted) { /* Each appended branch is already connected and certified. */ }
var candidate = new Network(district.startChunkX(), district.startChunkZ(), networkSeed, modules);
int score = networkScore(candidate);
if (score > bestScore) { bestScore = score; accepted = candidate; }
if (score >= 12_014 || probe.samples >= MAX_SAMPLES) break search;
}
}
}
} catch (BudgetExceeded exhausted) { /* Keep the best previously completed, connected candidate. */ }
if (accepted != null) {
var candidate = accepted;
if (networks.stream().noneMatch(n -> n.modules().stream().anyMatch(a -> candidate.modules().stream()
.anyMatch(b -> boundsOverlap(a, b) || supportConflict(a, b)))))
networks.add(candidate);
}
}
return new Plan(networks, probe.samples);
}
private static int networkScore(Network network) {
int score = network.modules().size();
for (var module : network.modules()) score += switch (module.kind()) {
case EXPANSION_HALL -> 10_000;
case POOLROOM, GARAGE -> 1_000;
default -> 0;
};
return score;
}
private static void sharedNetwork(List<Module> modules, int direction, LostCityPlan.District district,
LostCityPlan surface, Probe probe, long seed) {
int side = (direction + (roll(seed + 2, 2) == 0 ? 1 : 3)) & 3, back = (direction + 2) & 3;
int hall = attach(modules, 1, Kind.EXPANSION_HALL, direction, district, surface, probe, mix(seed + 10));
int junction = attach(modules, 1, Kind.SEWER, side, district, surface, probe, mix(seed + 11));
int corner = junction < 0 ? -1 : attach(modules, junction, Kind.SEWER, direction, district, surface, probe, mix(seed + 12));
// The central dry hall is one corner of a traversable circuit, with two independent entrances.
if (corner >= 0 && hall >= 0) link(modules, corner, hall, district, surface, probe, mix(seed + 13));
if (hall < 0 && corner >= 0)
hall = attach(modules, corner, Kind.EXPANSION_HALL, direction, district, surface, probe, mix(seed + 14));
int pool = attach(modules, 1, Kind.POOLROOM, (side + 2) & 3, district, surface, probe, mix(seed + 15));
int garage = attach(modules, junction < 0 ? 1 : junction, Kind.GARAGE, side, district, surface, probe, mix(seed + 16));
if (pool >= 0) attach(modules, pool, Kind.SEWER, back, district, surface, probe, mix(seed + 17));
if (garage >= 0) attach(modules, garage, Kind.SEWER, back, district, surface, probe, mix(seed + 18));
if (corner >= 0 && hall >= 0 && modules.size() <= 19)
attach(modules, corner, Kind.SEWER, side, district, surface, probe, mix(seed + 19));
}
private static int attach(List<Module> modules, int parent, Kind kind, int direction,
LostCityPlan.District district, LostCityPlan surface, Probe probe, long seed) {
if (modules.size() > 19) return -1;
int index = modules.size();
return connect(modules, parent, kind, direction, district, surface, probe, seed) ? index : -1;
}
private static boolean accessParcel(LostCityPlan.Parcel p) {
return (p.kind() == LostCityPlan.Kind.STREET || p.kind() == LostCityPlan.Kind.PLAZA)
&& p.width() >= 5 && p.depth() >= 5 && p.floors() == 0;
}
private record Anchor(LostCityPlan.Parcel parcel, int localX, int localZ, int top) {}
private static List<Anchor> anchors(LostCityPlan.Parcel p) {
var values = new ArrayList<Anchor>();
if (p.kind() == LostCityPlan.Kind.PLAZA) {
// Left promenade: clear of the central fountain and the benches at z=3 and depth-4.
if (p.width() >= 17 && p.depth() >= 17) addAnchor(values, p, 3, p.depth() / 2);
} else if (p.width() >= p.depth()) {
for (int x = 2; x <= p.width() - 3; x++) addAnchor(values, p, x, p.depth() / 2);
} else {
for (int z = 2; z <= p.depth() - 3; z++) addAnchor(values, p, p.width() / 2, z);
}
values.sort(Comparator.comparingInt(a -> Math.abs(a.localX() - p.width() / 2) + Math.abs(a.localZ() - p.depth() / 2)));
return values.stream().limit(4).toList();
}
private static void addAnchor(List<Anchor> values, LostCityPlan.Parcel p, int cx, int cz) {
int top = p.surfaceY(cx, cz);
for (int z = cz - 2; z <= cz + 2; z++) for (int x = cx - 2; x <= cx + 2; x++)
if (p.surfaceY(x, z) != top) return;
values.add(new Anchor(p, cx, cz, top));
}
private static boolean connect(List<Module> modules, int parentIndex, Kind kind, int direction,
LostCityPlan.District district, LostCityPlan surface, Probe probe, long seed) {
var parent = modules.get(parentIndex);
int x = parent.centerX() + DX[direction] * CELL, z = parent.centerZ() + DZ[direction] * CELL;
int width = kind == Kind.SEWER ? 9 : kind == Kind.EXPANSION_HALL ? 25 : 17;
int height = kind == Kind.GARAGE ? 8 : kind == Kind.EXPANSION_HALL ? 10 : 7;
var room = new Module(kind, x - width / 2, parent.minY(), z - width / 2, width, height, width,
1 << ((direction + 2) & 3), seed, List.of());
for (var existing : modules) if (overlap(room, existing)) return false;
room = certify(room, district, surface, probe, false);
if (room == null) return false;
for (var existing : modules) if (supportConflict(room, existing)) return false;
var passage = corridor(parent, room, direction, mix(seed));
if (passage == null) return false;
for (int i = 0; i < modules.size(); i++) if (i != parentIndex && overlap(passage, modules.get(i))) return false;
passage = certify(passage, district, surface, probe, roll(seed, 4) == 0);
if (passage == null) return false;
if (supportConflict(passage, room)) return false;
for (var existing : modules) if (supportConflict(passage, existing)) return false;
modules.set(parentIndex, parent.opening(direction)); modules.add(room); modules.add(passage);
return true;
}
private static Module corridor(Module parent, Module room, int direction, long seed) {
int x = room.centerX(), z = room.centerZ();
int minX, minZ, spanX, spanZ;
if (DX[direction] != 0) {
minX = Math.min(parent.maxX(), room.maxX());
int endX = Math.max(parent.minX(), room.minX());
minZ = z - 2; spanX = endX - minX + 1; spanZ = 5;
} else {
minX = x - 2; minZ = Math.min(parent.maxZ(), room.maxZ());
int endZ = Math.max(parent.minZ(), room.minZ());
spanX = 5; spanZ = endZ - minZ + 1;
}
if (spanX < 5 || spanZ < 5 || spanX > 31 || spanZ > 31) return null;
return new Module(Kind.PASSAGE, minX, parent.minY(), minZ, spanX, 6, spanZ,
(1 << direction) | (1 << ((direction + 2) & 3)), seed, List.of());
}
private static boolean link(List<Module> modules, int from, int to, LostCityPlan.District district,
LostCityPlan surface, Probe probe, long seed) {
if (modules.size() >= 21) return false;
var first = modules.get(from); var second = modules.get(to);
if (first.minY() != second.minY() || first.centerX() != second.centerX() && first.centerZ() != second.centerZ()) return false;
int direction = first.centerX() == second.centerX() ? second.centerZ() > first.centerZ() ? 2 : 0
: second.centerX() > first.centerX() ? 1 : 3;
var passage = corridor(first, second, direction, seed);
if (passage == null) return false;
for (int i = 0; i < modules.size(); i++) if (i != from && i != to && overlap(passage, modules.get(i))) return false;
passage = certify(passage, district, surface, probe, false);
if (passage == null) return false;
for (var existing : modules) if (supportConflict(passage, existing)) return false;
modules.set(from, first.opening(direction)); modules.set(to, second.opening((direction + 2) & 3)); modules.add(passage);
return true;
}
private static boolean overlap(Module a, Module b) {
return a.minX() < b.maxX() && a.maxX() > b.minX() && a.minZ() < b.maxZ() && a.maxZ() > b.minZ()
&& a.minY() <= b.maxY() && a.maxY() >= b.minY();
}
private static boolean boundsOverlap(Module a, Module b) {
return a.boundsMinX() <= b.boundsMaxX() && a.boundsMaxX() >= b.boundsMinX()
&& a.boundsMinZ() <= b.boundsMaxZ() && a.boundsMaxZ() >= b.boundsMinZ()
&& a.boundsMinY() <= b.boundsMaxY() && a.boundsMaxY() >= b.boundsMinY();
}
/** Read dependencies include the untouched rock beyond the written anchor. This is independent
* of body overlap: joined rooms deliberately share a doorway wall, but never another room's footing. */
static boolean supportConflict(Module a, Module b) {
if (hallFoundationIntersects(a, b) || hallFoundationIntersects(b, a)) return true;
for (var support : a.supports()) {
if (supportIntersectsBody(support, b)) return true;
for (var other : b.supports()) if (support.x() == other.x() && support.z() == other.z()
&& supportMinY(support) <= supportMaxY(other) && supportMaxY(support) >= supportMinY(other)) return true;
}
for (var support : b.supports()) if (supportIntersectsBody(support, a)) return true;
return false;
}
private static boolean hallFoundationIntersects(Module hall, Module other) {
if (hall.kind() != Kind.EXPANSION_HALL) return false;
int minY = hall.minY() - 2, maxY = hall.minY() - 1;
if (hall.minX() <= other.maxX() && hall.maxX() >= other.minX() && hall.minZ() <= other.maxZ()
&& hall.maxZ() >= other.minZ() && minY <= other.maxY() && maxY >= other.minY()) return true;
for (var support : other.supports()) if (support.x() >= hall.minX() && support.x() <= hall.maxX()
&& support.z() >= hall.minZ() && support.z() <= hall.maxZ()
&& minY <= supportMaxY(support) && maxY >= supportMinY(support)) return true;
return false;
}
private static boolean supportIntersectsBody(Support support, Module module) {
return support.x() >= module.minX() && support.x() <= module.maxX()
&& support.z() >= module.minZ() && support.z() <= module.maxZ()
&& supportMinY(support) <= module.maxY() && supportMaxY(support) >= module.minY();
}
private static int supportMinY(Support support) { return support.minY() - (support.type() == Type.PILLAR ? 1 : 0); }
private static int supportMaxY(Support support) { return support.maxY() + (support.type() == Type.CHAIN ? 1 : 0); }
private static int dependencyMinY(Module module) {
int minY = module.minY() - (module.kind() == Kind.EXPANSION_HALL ? 2 : 0);
for (var support : module.supports()) minY = Math.min(minY, supportMinY(support));
return minY;
}
private static int dependencyMaxY(Module module) {
int maxY = module.maxY();
for (var support : module.supports()) maxY = Math.max(maxY, supportMaxY(support));
return maxY;
}
private static Module certify(Module m, LostCityPlan.District district, LostCityPlan surface, Probe probe, boolean hanging) {
if (Math.abs(Math.floorDiv(m.minX(), 16) - district.startChunkX()) > 7
|| Math.abs(Math.floorDiv(m.maxX(), 16) - district.startChunkX()) > 7
|| Math.abs(Math.floorDiv(m.minZ(), 16) - district.startChunkZ()) > 7
|| Math.abs(Math.floorDiv(m.maxZ(), 16) - district.startChunkZ()) > 7) return null;
int solid = 0, volume = 0;
for (int z = m.minZ(); z <= m.maxZ(); z++) for (int x = m.minX(); x <= m.maxX(); x++) {
if (!probe.owns(x, z)) return null;
for (int y = m.minY(); y <= m.maxY(); y++) {
if (probe.protectedAt(x, y, z)) return null;
if (probe.solid(x, y, z)) solid++;
volume++;
}
}
if (m.kind() != Kind.ACCESS && !hanging && solid * 100L < volume * 48L) return null;
if (m.kind() == Kind.ACCESS && solid * 100L < volume * 60L) return null;
if (m.kind() == Kind.EXPANSION_HALL) {
if (solid * 100L < volume * 60L) return null;
// This large dry room is embedded in rock, never suspended over a guessed foundation.
for (int z = m.minZ(); z <= m.maxZ(); z++) for (int x = m.minX(); x <= m.maxX(); x++) {
if (!probe.solid(x, m.minY() - 1, z) || !probe.solid(x, m.minY() - 2, z)
|| probe.protectedAt(x, m.minY() - 1, z) || probe.protectedAt(x, m.minY() - 2, z)) return null;
}
}
if (surfaceCollision(m, surface)) return null;
var supports = new ArrayList<Support>();
for (int z : lattice(m.minZ(), m.maxZ())) for (int x : lattice(m.minX(), m.maxX())) {
if (probe.solid(x, m.minY() - 1, z) && probe.solid(x, m.minY() - 2, z)) continue;
Support found = support(m, x, z, probe);
if (found == null || supports.size() >= 24) return null;
supports.add(found);
}
var result = m.supported(supports);
if (surfaceCollision(result, surface)) return null;
return result;
}
private static List<Integer> lattice(int min, int max) {
var values = new ArrayList<Integer>(); for (int n = min; n <= max; n += 4) values.add(n);
if (values.getLast() != max) values.add(max); return values;
}
private static Support support(Module m, int x, int z, Probe probe) {
for (int distance = 1; distance <= 8; distance++) {
int y = m.minY() - distance;
if (y <= 1 || probe.protectedAt(x, y, z)) break;
if (probe.solid(x, y, z) && probe.solid(x, y - 1, z)) return new Support(Type.PILLAR, x, y, z, m.minY() - 1);
}
for (int distance = 1; distance <= 8; distance++) {
int y = m.maxY() + distance;
if (y >= 382 || probe.protectedAt(x, y, z)) break;
if (probe.solid(x, y, z) && probe.solid(x, y + 1, z)) return new Support(Type.CHAIN, x, m.maxY() + 1, z, y);
}
return null;
}
private static boolean surfaceCollision(Module m, LostCityPlan surface) {
for (var district : surface.districts()) for (var p : district.parcels()) {
if (m.maxX() < p.minX() || m.minX() > p.maxX() || m.maxZ() < p.minZ() || m.minZ() > p.maxZ()) continue;
if (m.kind() == Kind.ACCESS && accessParcel(p) && m.minX() >= p.minX() && m.maxX() <= p.maxX()
&& m.minZ() >= p.minZ() && m.maxZ() <= p.maxZ()) continue;
int footing = p.minY() - (p.kind() == LostCityPlan.Kind.BRIDGE ? 2 : 1);
if (dependencyMaxY(m) >= footing && dependencyMinY(m) <= p.maxY()) return true;
}
return false;
}
/** Render the entire saved module; the native piece clips writes to its chunk and bounds. */
public static void render(Module m, LostCityRenderer.BlockWriter writer) {
BlockState air = Blocks.AIR.defaultBlockState(), floor = Blocks.SMOOTH_STONE.defaultBlockState();
for (int y = 0; y < m.height(); y++) for (int z = 0; z < m.depth(); z++) for (int x = 0; x < m.width(); x++) {
boolean wall = x == 0 || z == 0 || x == m.width() - 1 || z == m.depth() - 1;
BlockState block = y == 0 ? Blocks.STONE.defaultBlockState() : y == 1 ? floor
: wall || y == m.height() - 1 ? LostCityPalette.masonry(m.seed(), x, y, z) : air;
writer.set(x, y, z, block);
}
for (int direction = 0; direction < 4; direction++) if ((m.openings() & 1 << direction) != 0)
for (int side = -1; side <= 1; side++) for (int y = 2; y <= 4; y++)
writer.set(DX[direction] == 0 ? m.width() / 2 + side : direction == 1 ? m.width() - 1 : 0, y,
DZ[direction] == 0 ? m.depth() / 2 + side : direction == 2 ? m.depth() - 1 : 0, air);
switch (m.kind()) {
case ACCESS -> access(m, writer);
case POOLROOM -> pools(m, writer);
case GARAGE -> garage(m, writer);
case SEWER -> sewer(m, writer);
case EXPANSION_HALL -> expansionHall(m, writer);
case PASSAGE -> { /* Narrow, unlit service passages. */ }
}
for (var support : m.supports()) for (int y = support.minY(); y <= support.maxY(); y++) {
BlockState state = support.type() == Type.PILLAR ? Blocks.STONE_BRICKS.defaultBlockState()
: y == support.maxY() ? Blocks.POLISHED_ANDESITE.defaultBlockState() : Blocks.IRON_CHAIN.defaultBlockState();
writer.set(support.x() - m.minX(), y - m.minY(), support.z() - m.minZ(), state);
}
}
private static void access(Module m, LostCityRenderer.BlockWriter writer) {
int exit = Integer.numberOfTrailingZeros(m.openings() == 0 ? 1 : m.openings());
int wall = (exit + 2) & 3;
int x = wall == 1 ? m.width() - 2 : wall == 3 ? 1 : m.width() / 2;
int z = wall == 2 ? m.depth() - 2 : wall == 0 ? 1 : m.depth() / 2;
Direction facing = switch (wall) { case 0 -> Direction.SOUTH; case 1 -> Direction.WEST; case 2 -> Direction.NORTH; default -> Direction.EAST; };
for (int y = 2; y <= m.height() - 3; y++) writer.set(x, y, z,
Blocks.LADDER.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, facing));
// Restore the street plane around one accessible maintenance hatch, never leave a five-block-wide shaft open.
for (int zz = 0; zz < m.depth(); zz++) for (int xx = 0; xx < m.width(); xx++) {
writer.set(xx, m.height() - 3, zz, xx == x && zz == z
? Blocks.LADDER.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, facing) : Blocks.SMOOTH_STONE.defaultBlockState());
writer.set(xx, m.height() - 2, zz, Blocks.AIR.defaultBlockState());
writer.set(xx, m.height() - 1, zz, Blocks.AIR.defaultBlockState());
}
// A wooden hatch remains usable without redstone, including from below.
writer.set(x, m.height() - 2, z, Blocks.DARK_OAK_TRAPDOOR.defaultBlockState()
.setValue(BlockStateProperties.HORIZONTAL_FACING, facing));
}
private static void sewer(Module m, LostCityRenderer.BlockWriter writer) {
int z = m.depth() / 2;
for (int x = 2; x < m.width() - 2; x++) writer.set(x, 1, z, Blocks.WATER.defaultBlockState());
writer.set(1, 3, 1, Blocks.REDSTONE_WALL_TORCH.defaultBlockState()
.setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.EAST));
}
private static void pools(Module m, LostCityRenderer.BlockWriter writer) {
for (int startX : new int[] {2, 10}) for (int startZ : new int[] {2, 10}) {
for (int x = startX; x <= startX + 4; x++) for (int z = startZ; z <= startZ + 4; z++) {
writer.set(x, 1, z, Blocks.CALCITE.defaultBlockState());
boolean rim = x == startX || x == startX + 4 || z == startZ || z == startZ + 4;
for (int y = 2; y <= 3; y++) writer.set(x, y, z,
rim ? Blocks.SMOOTH_STONE.defaultBlockState() : Blocks.WATER.defaultBlockState());
}
}
for (int z : new int[] {4, 12}) writer.set(7, 2, z, Blocks.STONE_BRICK_STAIRS.defaultBlockState()
.setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.WEST));
writer.set(8, m.height() - 1, 8, Blocks.SEA_LANTERN.defaultBlockState());
}
private static void garage(Module m, LostCityRenderer.BlockWriter writer) {
for (int x = 2; x <= 14; x++) for (int z : new int[] {3, 7, 9, 13})
if (x < 7 || x > 9) writer.set(x, 1, z, Blocks.CONCRETE.white().defaultBlockState());
for (int x : new int[] {3, 13}) for (int z : new int[] {4, 12}) {
for (int y = 2; y < m.height() - 1; y++) writer.set(x, y, z, Blocks.POLISHED_ANDESITE.defaultBlockState());
writer.set(x, 2, z + 1, Blocks.IRON_BARS.defaultBlockState());
}
writer.set(1, 2, 1, Blocks.BARREL.defaultBlockState());
writer.set(m.width() - 2, 2, m.depth() - 2, Blocks.CAULDRON.defaultBlockState());
}
private static void expansionHall(Module m, LostCityRenderer.BlockWriter writer) {
int middle = m.width() / 2;
// An empty architectural plinth reserves a future diegetic machine location. No machine,
// map entity, interaction or progression state is created by world generation.
for (int x = middle - 2; x <= middle + 2; x++) for (int z = middle - 2; z <= middle + 2; z++)
writer.set(x, 2, z, Blocks.SMOOTH_STONE.defaultBlockState());
for (int n = middle - 1; n <= middle + 1; n++) {
writer.set(n, 2, middle - 3, Blocks.STONE_BRICK_STAIRS.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.SOUTH));
writer.set(n, 2, middle + 3, Blocks.STONE_BRICK_STAIRS.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.NORTH));
writer.set(middle - 3, 2, n, Blocks.STONE_BRICK_STAIRS.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.EAST));
writer.set(middle + 3, 2, n, Blocks.STONE_BRICK_STAIRS.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.WEST));
}
for (int x : new int[] {4, m.width() - 5}) for (int z : new int[] {4, m.depth() - 5}) {
for (int y = 2; y < m.height() - 1; y++) writer.set(x, y, z, Blocks.POLISHED_ANDESITE.defaultBlockState());
}
for (int x : new int[] {7, m.width() - 8}) for (int z : new int[] {7, m.depth() - 8}) {
// Every suspended lantern is linked to the complete masonry ceiling, not a slab or pane.
for (int y = m.height() - 3; y < m.height() - 1; y++) writer.set(x, y, z, Blocks.IRON_CHAIN.defaultBlockState());
writer.set(x, m.height() - 4, z, Blocks.LANTERN.defaultBlockState().setValue(BlockStateProperties.HANGING, true));
}
for (int z : new int[] {5, 8, m.depth() - 9, m.depth() - 6}) {
for (int y = 2; y <= 3; y++) {
writer.set(1, y, z, Blocks.BOOKSHELF.defaultBlockState());
writer.set(m.width() - 2, y, z, Blocks.BOOKSHELF.defaultBlockState());
}
writer.set(3, 2, z, Blocks.DARK_OAK_STAIRS.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.WEST));
writer.set(m.width() - 4, 2, z, Blocks.DARK_OAK_STAIRS.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.EAST));
}
for (int n = 2; n < m.width() - 2; n++) {
writer.set(n, 1, middle, Blocks.POLISHED_ANDESITE.defaultBlockState());
writer.set(middle, 1, n, Blocks.POLISHED_ANDESITE.defaultBlockState());
}
}
private record Point(int x, int y, int z) {}
private static final class Probe {
final Terrain terrain;
final Map<Point, Byte> blocks = new HashMap<>(); final Map<Long, Boolean> columns = new HashMap<>();
int samples;
Probe(Terrain terrain) { this.terrain = terrain; }
void count() { if (samples >= MAX_SAMPLES) throw new BudgetExceeded(); samples++; }
boolean owns(int x, int z) {
long key = (long) x << 32 ^ (z & 0xffffffffL);
return columns.computeIfAbsent(key, ignored -> { count(); return terrain.owns(x, z); });
}
byte block(int x, int y, int z) {
return blocks.computeIfAbsent(new Point(x, y, z), ignored -> {
count(); boolean protectedCell = terrain.protectedAt(x, y, z);
count(); return (byte) ((protectedCell ? 2 : 0) | (terrain.solid(x, y, z) ? 1 : 0));
});
}
boolean solid(int x, int y, int z) { return y >= 0 && y < 384 && (block(x, y, z) & 1) != 0; }
boolean protectedAt(int x, int y, int z) { return y < 0 || y >= 384 || (block(x, y, z) & 2) != 0; }
}
private static final class BudgetExceeded extends RuntimeException {}
private static int roll(long value, int bound) { return (int) Long.remainderUnsigned(mix(value), bound); }
private static long mix(long value) { value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L; value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL; return value ^ (value >>> 31); }
}
@@ -0,0 +1,702 @@
package fr.koka.sanctuary.worldgen.city;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import net.minecraft.core.Direction;
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.block.state.properties.BlockStateProperties;
/** Generation 16 only: shared, terrain-certified utility networks. All rendering inputs are saved records. */
public final class LostCityUnderground16 {
public static final int MAX_SAMPLES = 420_000;
private static final int[] DX = {0, 1, 0, -1}, DZ = {-1, 0, 1, 0};
private static final int CELL = 20;
private LostCityUnderground16() {}
public interface Terrain {
boolean solid(int x, int y, int z);
boolean protectedAt(int x, int y, int z);
boolean owns(int x, int z);
boolean exteriorAir(int x, int y, int z, int dx, int dz);
}
public enum Kind { ACCESS, SEWER, POOLROOM, GARAGE, PASSAGE, EXPANSION_HALL, SEWER_OUTLET }
public enum Type { PILLAR, CHAIN }
public record WaterBounds(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {}
private static int floorOffset(Kind kind, int height) {
return kind == Kind.POOLROOM ? 5 : kind == Kind.GARAGE ? height - 9 : 1;
}
public record Support(Type type, int x, int minY, int z, int maxY) {
public Support {
if (type == null || minY < 1 || maxY > 382 || minY > maxY || maxY - minY >= 8)
throw new IllegalArgumentException("Invalid underground support");
}
}
/** openings bits: north=1, east=2, south=4, west=8. Body coordinates are absolute; writer coordinates are local. */
public record Module(Kind kind, int minX, int minY, int minZ, int width, int height, int depth,
int openings, long seed, List<Support> supports) {
public Module {
if (kind == null || width < 5 || depth < 5 || width > 63 || depth > 63 || height < 6 || height > 128
|| minY < 8 || minY + height > 383 || openings < 0 || openings > 15 || supports.size() > 64)
throw new IllegalArgumentException("Invalid underground module");
if (kind == Kind.SEWER_OUTLET && Integer.bitCount(openings) != 1)
throw new IllegalArgumentException("An outlet must retain its unique discharge direction");
if (kind == Kind.POOLROOM && (width != 57 || depth != 31 || height != 13)
|| kind == Kind.GARAGE && (width != 31 || depth != 25 || height != 122 && height != 90 && height != 58 && height != 42))
throw new IllegalArgumentException("Invalid saved large infrastructure dimensions");
supports = List.copyOf(supports);
for (var support : supports) if (support.x() < minX || support.x() >= minX + width
|| support.z() < minZ || support.z() >= minZ + depth)
throw new IllegalArgumentException("Support is outside its module footprint");
}
public int floorY() { return minY + floorOffset(kind, height); }
public WaterBounds poolWaterBounds() {
if (kind != Kind.POOLROOM) throw new IllegalStateException("Not a poolroom");
return new WaterBounds(minX + 3, minY + 1, minZ + 3, minX + 52, minY + 4, minZ + 27);
}
public int outletDirection() {
if (kind != Kind.SEWER_OUTLET) throw new IllegalStateException("Not a sewer outlet");
return Integer.numberOfTrailingZeros(openings);
}
public BlockPos outletMouth() {
int direction = outletDirection();
return new BlockPos(DX[direction] == 0 ? centerX() : direction == 1 ? maxX() : minX(), floorY(),
DZ[direction] == 0 ? centerZ() : direction == 2 ? maxZ() : minZ());
}
public List<Integer> parkingFloors() {
if (kind != Kind.GARAGE) return List.of();
var floors = new ArrayList<Integer>();
for (int y = minY + 1; y <= floorY(); y += 16) floors.add(y);
return List.copyOf(floors);
}
public int maxX() { return minX + width - 1; }
public int maxY() { return minY + height - 1; }
public int maxZ() { return minZ + depth - 1; }
public int centerX() { return minX + width / 2; }
public int centerZ() { return minZ + depth / 2; }
public int boundsMinX() { return minX; }
public int boundsMaxX() { return maxX(); }
public int boundsMinZ() { return minZ; }
public int boundsMaxZ() { return maxZ(); }
public int boundsMinY() { return Math.min(minY, supports.stream().mapToInt(Support::minY).min().orElse(minY)); }
public int boundsMaxY() { return Math.max(maxY(), supports.stream().mapToInt(Support::maxY).max().orElse(maxY())); }
public boolean contains(int x, int y, int z) {
if (x < minX || x > maxX() || z < minZ || z > maxZ()) return false;
if (y >= minY && y <= maxY()) return true;
return supports.stream().anyMatch(s -> s.x() == x && s.z() == z && y >= s.minY() && y <= s.maxY());
}
private Module opening(int direction) { return new Module(kind, minX, minY, minZ, width, height, depth,
openings | 1 << direction, seed, supports); }
private Module supported(List<Support> values) { return new Module(kind, minX, minY, minZ, width, height, depth, openings, seed, values); }
}
public record Network(int startChunkX, int startChunkZ, long seed, List<Module> modules) {
public Network {
modules = List.copyOf(modules);
if (modules.isEmpty() || modules.size() > 25) throw new IllegalArgumentException("Invalid underground network");
for (var module : modules) if (Math.abs(Math.floorDiv(module.boundsMinX(), 16) - startChunkX) > 7
|| Math.abs(Math.floorDiv(module.boundsMaxX(), 16) - startChunkX) > 7
|| Math.abs(Math.floorDiv(module.boundsMinZ(), 16) - startChunkZ) > 7
|| Math.abs(Math.floorDiv(module.boundsMaxZ(), 16) - startChunkZ) > 7)
throw new IllegalArgumentException("Underground network exceeds native reference range");
}
}
public record Plan(List<Network> networks, int samples) {
public Plan { networks = List.copyOf(networks); if (samples < 0 || samples > MAX_SAMPLES) throw new IllegalArgumentException("Invalid sampling budget"); }
public boolean isEmpty() { return networks.isEmpty(); }
public boolean contains(int x, int y, int z) {
return networks.stream().anyMatch(n -> n.modules().stream().anyMatch(m -> m.contains(x, y, z)));
}
}
public static Plan plan(long seed, LostCityPlan surface, Terrain terrain) {
var probe = new Probe(terrain); var networks = new ArrayList<Network>();
for (var district : surface.districts()) {
if (probe.samples >= MAX_SAMPLES) break;
int centerX = (district.minX() + district.maxX()) / 2, centerZ = (district.minZ() + district.maxZ()) / 2;
var anchors = district.parcels().stream().filter(LostCityUnderground16::accessParcel)
.flatMap(p -> anchors(p).stream())
.sorted(Comparator.<Anchor>comparingInt(a -> Math.abs(a.parcel().minX() + a.localX() - centerX)
+ Math.abs(a.parcel().minZ() + a.localZ() - centerZ)).thenComparingLong(a -> mix(seed ^ a.parcel().seed())))
.limit(24).toList();
probe.rejections.merge("usable-accesses", anchors.size(), Integer::sum);
Network accepted = null;
int bestScore = -1, attempts = 0;
try {
search: for (var anchor : anchors) {
int x = anchor.parcel().minX() + anchor.localX(), z = anchor.parcel().minZ() + anchor.localZ();
int top = anchor.top();
for (int drop : new int[] {22, 26, 18, 32, 38}) {
int floor = top - drop;
if (floor < 32) continue;
long networkSeed = mix(seed ^ district.seed() ^ anchor.parcel().seed() ^ drop);
var access = new Module(Kind.ACCESS, x - 2, floor - 1, z - 2, 5, drop + 4, 5, 0, networkSeed, List.of());
access = certify(access, district, surface, probe, false);
if (access == null) continue;
for (int attempt = 0; attempt < 4; attempt++) {
if (attempts++ >= 6 || probe.samples >= MAX_SAMPLES) break search;
int direction = (roll(networkSeed, 4) + attempt) & 3;
var modules = new ArrayList<Module>(); modules.add(access);
if (!connect(modules, 0, Kind.SEWER, direction, district, surface, probe, networkSeed)) continue;
try {
sharedNetwork(modules, direction, district, surface, probe, networkSeed);
} catch (BudgetExceeded exhausted) { /* Each appended branch is already connected and certified. */ }
var candidate = new Network(district.startChunkX(), district.startChunkZ(), networkSeed, modules);
int score = networkScore(candidate);
if (score > bestScore) { bestScore = score; accepted = candidate; }
if (score >= 23_009 || probe.samples >= MAX_SAMPLES) break search;
}
}
}
} catch (BudgetExceeded exhausted) { /* Keep the best previously completed, connected candidate. */ }
if (accepted != null) {
var candidate = accepted;
if (networks.stream().noneMatch(n -> n.modules().stream().anyMatch(a -> candidate.modules().stream()
.anyMatch(b -> boundsOverlap(a, b) || supportConflict(a, b)))))
networks.add(candidate);
}
}
System.getLogger(LostCityUnderground16.class.getName()).log(System.Logger.Level.INFO,
"Lost City16 utilities seed=" + seed + " networks=" + networks.size() + " samples=" + probe.samples
+ " kinds=" + networks.stream().flatMap(n -> n.modules().stream()).map(Module::kind).toList()
+ " admission=" + probe.rejections);
return new Plan(networks, probe.samples);
}
private static int networkScore(Network network) {
int score = network.modules().size();
for (var module : network.modules()) score += switch (module.kind()) {
case POOLROOM -> 10_000;
case EXPANSION_HALL -> 5_000;
case GARAGE -> 5_000 + module.parkingFloors().size() * 500;
case SEWER_OUTLET -> 1_000;
default -> 0;
};
return score;
}
private static void sharedNetwork(List<Module> modules, int direction, LostCityPlan.District district,
LostCityPlan surface, Probe probe, long seed) {
int side = (direction + (roll(seed + 2, 2) == 0 ? 1 : 3)) & 3;
int pool = attach(modules, 1, Kind.POOLROOM, direction, district, surface, probe, mix(seed + 10));
int hall = attach(modules, 1, Kind.EXPANSION_HALL, (side + 2) & 3, district, surface, probe, mix(seed + 11));
int garage = attach(modules, 1, Kind.GARAGE, side, district, surface, probe, mix(seed + 12));
// The lowest parking deck opens onto a deeper sewer. The mouth piece shares only that deck's
// wall and records its own elevation; no unsaved vertical-door state is added to the garage.
if (garage >= 0) for (int floor : modules.get(garage).parkingFloors()) {
if (floor == modules.get(garage).floorY()) continue;
for (int turn : new int[] {0, 1, 3, 2})
if (outlet(modules, garage, (side + turn) & 3, floor, district, surface, probe, mix(seed + 100 + floor + turn))) return;
}
// The outward side of an existing wing cannot cross the central access shaft.
if (pool >= 0 && outlet(modules, pool, direction, district, surface, probe, mix(seed + 13))) return;
if (garage >= 0 && outlet(modules, garage, side, district, surface, probe, mix(seed + 14))) return;
if (hall >= 0 && outlet(modules, hall, (side + 2) & 3, district, surface, probe, mix(seed + 15))) return;
for (int turn = 0; turn < 4; turn++)
if (outlet(modules, 1, turn, district, surface, probe, mix(seed + 16 + turn))) return;
}
private static int attach(List<Module> modules, int parent, Kind kind, int direction,
LostCityPlan.District district, LostCityPlan surface, Probe probe, long seed) {
if (modules.size() > 23) return -1;
int index = modules.size();
return connect(modules, parent, kind, direction, district, surface, probe, seed) ? index : -1;
}
/** The outlet direction is persisted as its single opening bit; the opposite end is its entrance. */
private static boolean outlet(List<Module> modules, int parentIndex, int direction,
LostCityPlan.District district, LostCityPlan surface, Probe probe, long seed) {
return outlet(modules, parentIndex, direction, modules.get(parentIndex).floorY(), district, surface, probe, seed);
}
private static boolean outlet(List<Module> modules, int parentIndex, int direction, int entryFloor,
LostCityPlan.District district, LostCityPlan surface, Probe probe, long seed) {
if (modules.size() >= 25) return false;
var parent = modules.get(parentIndex);
int startX = DX[direction] == 0 ? parent.centerX() : direction == 1 ? parent.maxX() : parent.minX();
int startZ = DZ[direction] == 0 ? parent.centerZ() : direction == 2 ? parent.maxZ() : parent.minZ();
// Three clear aperture columns, a two-block head space, and the supplied exterior ray distinguish
// a genuine island edge from a pocket cave. No ownership boundary is used as an air proof.
for (int length = 17; length <= 63; length++) {
int endX = startX + DX[direction] * (length - 1), endZ = startZ + DZ[direction] * (length - 1);
if (Math.abs(Math.floorDiv(endX, 16) - district.startChunkX()) > 7
|| Math.abs(Math.floorDiv(endZ, 16) - district.startChunkZ()) > 7) break;
int outsideX = endX + DX[direction], outsideZ = endZ + DZ[direction];
boolean exterior = true;
for (int across = -1; across <= 1 && exterior; across++) for (int y = entryFloor; y <= entryFloor + 3; y++)
if (!probe.exteriorAir(outsideX + DZ[direction] * across, y,
outsideZ + DX[direction] * across, DX[direction], DZ[direction])) { exterior = false; break; }
if (!exterior) continue;
int minX = DX[direction] == 0 ? startX - 3 : Math.min(startX, endX);
int minZ = DZ[direction] == 0 ? startZ - 3 : Math.min(startZ, endZ);
var draft = new Module(Kind.SEWER_OUTLET, minX, entryFloor - 1, minZ,
DX[direction] == 0 ? 7 : length, 7, DZ[direction] == 0 ? 7 : length,
1 << direction, seed, List.of());
boolean collides = false;
for (int i = 0; i < modules.size(); i++) if (i != parentIndex && overlap(draft, modules.get(i))) { collides = true; break; }
if (collides) return false;
var certified = certify(draft, district, surface, probe, true);
if (certified == null) continue;
for (var existing : modules) if (supportConflict(certified, existing)) { collides = true; break; }
if (collides) continue;
if (entryFloor == parent.floorY()) modules.set(parentIndex, parent.opening(direction));
modules.add(certified);
return true;
}
return false;
}
private static boolean accessParcel(LostCityPlan.Parcel p) {
return (p.kind() == LostCityPlan.Kind.STREET || p.kind() == LostCityPlan.Kind.PLAZA)
&& p.width() >= 5 && p.depth() >= 5 && p.floors() == 0;
}
private record Anchor(LostCityPlan.Parcel parcel, int localX, int localZ, int top) {}
private static List<Anchor> anchors(LostCityPlan.Parcel p) {
var values = new ArrayList<Anchor>();
if (p.kind() == LostCityPlan.Kind.PLAZA) {
// Left promenade: clear of the central fountain and the benches at z=3 and depth-4.
if (p.width() >= 17 && p.depth() >= 17) addAnchor(values, p, 3, p.depth() / 2);
} else if (p.width() >= p.depth()) {
for (int x = 2; x <= p.width() - 3; x++) addAnchor(values, p, x, p.depth() / 2);
} else {
for (int z = 2; z <= p.depth() - 3; z++) addAnchor(values, p, p.width() / 2, z);
}
values.sort(Comparator.comparingInt(a -> Math.abs(a.localX() - p.width() / 2) + Math.abs(a.localZ() - p.depth() / 2)));
return values.stream().limit(4).toList();
}
private static void addAnchor(List<Anchor> values, LostCityPlan.Parcel p, int cx, int cz) {
int top = p.surfaceY(cx, cz);
for (int z = cz - 2; z <= cz + 2; z++) for (int x = cx - 2; x <= cx + 2; x++)
if (p.surfaceY(x, z) != top) return;
values.add(new Anchor(p, cx, cz, top));
}
private static boolean connect(List<Module> modules, int parentIndex, Kind kind, int direction,
LostCityPlan.District district, LostCityPlan surface, Probe probe, long seed) {
var parent = modules.get(parentIndex);
int width = kind == Kind.SEWER ? 9 : kind == Kind.EXPANSION_HALL ? 25 : kind == Kind.POOLROOM ? 57 : 31;
int depth = kind == Kind.POOLROOM ? 31 : kind == Kind.GARAGE ? 25 : width;
int height = kind == Kind.EXPANSION_HALL ? 10 : kind == Kind.POOLROOM ? 13 : 7;
int parentHalf = DX[direction] == 0 ? parent.depth() / 2 : parent.width() / 2;
int roomHalf = DX[direction] == 0 ? depth / 2 : width / 2;
int distance = Math.max(CELL, parentHalf + roomHalf + (kind == Kind.POOLROOM ? 12 : 7));
int x = parent.centerX() + DX[direction] * distance, z = parent.centerZ() + DZ[direction] * distance;
Module room = null;
for (int candidateHeight : kind == Kind.GARAGE ? new int[] {122, 90, 58, 42} : new int[] {height}) {
int minY = parent.floorY() - floorOffset(kind, candidateHeight);
if (minY < 8 || minY + candidateHeight > 383) continue;
var draft = new Module(kind, x - width / 2, minY, z - depth / 2, width, candidateHeight, depth,
1 << ((direction + 2) & 3), seed, List.of());
for (var existing : modules) if (overlap(draft, existing)) return false;
room = certify(draft, district, surface, probe, false);
if (room != null) break;
}
if (room == null) return false;
for (var existing : modules) if (supportConflict(room, existing)) return false;
var passage = corridor(parent, room, direction, mix(seed));
if (passage == null) return false;
for (int i = 0; i < modules.size(); i++) if (i != parentIndex && overlap(passage, modules.get(i))) return false;
passage = certify(passage, district, surface, probe, roll(seed, 4) == 0);
if (passage == null) return false;
if (supportConflict(passage, room)) return false;
for (var existing : modules) if (supportConflict(passage, existing)) return false;
modules.set(parentIndex, parent.opening(direction)); modules.add(room); modules.add(passage);
return true;
}
private static Module corridor(Module parent, Module room, int direction, long seed) {
int x = room.centerX(), z = room.centerZ();
int minX, minZ, spanX, spanZ;
if (DX[direction] != 0) {
minX = Math.min(parent.maxX(), room.maxX());
int endX = Math.max(parent.minX(), room.minX());
minZ = z - 2; spanX = endX - minX + 1; spanZ = 5;
} else {
minX = x - 2; minZ = Math.min(parent.maxZ(), room.maxZ());
int endZ = Math.max(parent.minZ(), room.minZ());
spanX = 5; spanZ = endZ - minZ + 1;
}
if (spanX < 5 || spanZ < 5 || spanX > 31 || spanZ > 31) return null;
return new Module(Kind.PASSAGE, minX, parent.floorY() - 1, minZ, spanX, 6, spanZ,
(1 << direction) | (1 << ((direction + 2) & 3)), seed, List.of());
}
private static boolean link(List<Module> modules, int from, int to, LostCityPlan.District district,
LostCityPlan surface, Probe probe, long seed) {
if (modules.size() >= 25) return false;
var first = modules.get(from); var second = modules.get(to);
if (first.floorY() != second.floorY() || first.centerX() != second.centerX() && first.centerZ() != second.centerZ()) return false;
int direction = first.centerX() == second.centerX() ? second.centerZ() > first.centerZ() ? 2 : 0
: second.centerX() > first.centerX() ? 1 : 3;
var passage = corridor(first, second, direction, seed);
if (passage == null) return false;
for (int i = 0; i < modules.size(); i++) if (i != from && i != to && overlap(passage, modules.get(i))) return false;
passage = certify(passage, district, surface, probe, false);
if (passage == null) return false;
for (var existing : modules) if (supportConflict(passage, existing)) return false;
modules.set(from, first.opening(direction)); modules.set(to, second.opening((direction + 2) & 3)); modules.add(passage);
return true;
}
private static boolean overlap(Module a, Module b) {
return a.minX() < b.maxX() && a.maxX() > b.minX() && a.minZ() < b.maxZ() && a.maxZ() > b.minZ()
&& a.minY() <= b.maxY() && a.maxY() >= b.minY();
}
private static boolean boundsOverlap(Module a, Module b) {
return a.boundsMinX() <= b.boundsMaxX() && a.boundsMaxX() >= b.boundsMinX()
&& a.boundsMinZ() <= b.boundsMaxZ() && a.boundsMaxZ() >= b.boundsMinZ()
&& a.boundsMinY() <= b.boundsMaxY() && a.boundsMaxY() >= b.boundsMinY();
}
/** Read dependencies include the untouched rock beyond the written anchor. This is independent
* of body overlap: joined rooms deliberately share a doorway wall, but never another room's footing. */
static boolean supportConflict(Module a, Module b) {
for (var support : a.supports()) {
if (supportIntersectsBody(support, b)) return true;
for (var other : b.supports()) if (support.x() == other.x() && support.z() == other.z()
&& supportMinY(support) <= supportMaxY(other) && supportMaxY(support) >= supportMinY(other)) return true;
}
for (var support : b.supports()) if (supportIntersectsBody(support, a)) return true;
return false;
}
private static boolean supportIntersectsBody(Support support, Module module) {
return support.x() >= module.minX() && support.x() <= module.maxX()
&& support.z() >= module.minZ() && support.z() <= module.maxZ()
&& supportMinY(support) <= module.maxY() && supportMaxY(support) >= module.minY();
}
private static int supportMinY(Support support) { return support.minY() - (support.type() == Type.PILLAR ? 1 : 0); }
private static int supportMaxY(Support support) { return support.maxY() + (support.type() == Type.CHAIN ? 1 : 0); }
private static int dependencyMinY(Module module) {
int minY = module.minY();
for (var support : module.supports()) minY = Math.min(minY, supportMinY(support));
return minY;
}
private static int dependencyMaxY(Module module) {
int maxY = module.maxY();
for (var support : module.supports()) maxY = Math.max(maxY, supportMaxY(support));
return maxY;
}
private static Module certify(Module m, LostCityPlan.District district, LostCityPlan surface, Probe probe, boolean hanging) {
if (Math.abs(Math.floorDiv(m.minX(), 16) - district.startChunkX()) > 7
|| Math.abs(Math.floorDiv(m.maxX(), 16) - district.startChunkX()) > 7
|| Math.abs(Math.floorDiv(m.minZ(), 16) - district.startChunkZ()) > 7
|| Math.abs(Math.floorDiv(m.maxZ(), 16) - district.startChunkZ()) > 7 || surfaceCollision(m, surface)) return probe.reject(m, "geometry");
int solid = 0, volume = 0;
for (int z = m.minZ(); z <= m.maxZ(); z++) for (int x = m.minX(); x <= m.maxX(); x++) {
if (!probe.owns(x, z)) return probe.reject(m, "ownership");
for (int y = m.minY(); y <= m.maxY(); y++) {
if (probe.protectedAt(x, y, z)) return probe.reject(m, "protected");
// Only the visual density preference is sampled in the tall parking volume. Protection
// above and every load-bearing anchor below remain exhaustive, independent checks.
if (m.kind() == Kind.SEWER_OUTLET || m.kind() == Kind.GARAGE && (y - m.minY()) % 4 != 0) continue;
if (probe.solid(x, y, z)) solid++;
volume++;
}
}
int requiredRock = switch (m.kind()) {
case ACCESS -> 60;
case POOLROOM, EXPANSION_HALL -> 25;
case GARAGE -> 12;
case SEWER_OUTLET -> 0;
default -> hanging ? 0 : 48;
};
if (solid * 100L < volume * requiredRock) return probe.reject(m, "rock-ratio");
if (m.kind() == Kind.SEWER_OUTLET) {
var bearings = outletSupports(m, probe);
if (bearings == null) return probe.reject(m, "unanchored");
var result = m.supported(bearings);
return surfaceCollision(result, surface) ? probe.reject(m, "support-geometry") : result;
}
var supports = new ArrayList<Support>();
var xs = m.kind() == Kind.GARAGE ? List.of(m.minX() + 4, m.centerX(), m.maxX() - 4)
: lattice(m.minX(), m.maxX(), (m.kind() == Kind.POOLROOM || m.kind() == Kind.EXPANSION_HALL) ? 8 : 4);
var zs = m.kind() == Kind.GARAGE ? List.of(m.minZ() + 3, m.centerZ(), m.maxZ() - 3)
: lattice(m.minZ(), m.maxZ(), (m.kind() == Kind.POOLROOM || m.kind() == Kind.EXPANSION_HALL) ? 8 : 4);
for (int z : zs) for (int x : xs) {
if (probe.solid(x, m.minY() - 1, z) && probe.solid(x, m.minY() - 2, z)) {
if (probe.protectedAt(x, m.minY() - 1, z) || probe.protectedAt(x, m.minY() - 2, z)) return null;
if (m.kind() == Kind.GARAGE || m.kind() == Kind.POOLROOM || m.kind() == Kind.EXPANSION_HALL) supports.add(new Support(Type.PILLAR, x, m.minY() - 1, z, m.minY() - 1));
continue;
}
Support found = support(m, x, z, probe);
if (found == null || supports.size() >= 64) return probe.reject(m, "unanchored");
supports.add(found);
}
var result = m.supported(supports);
return surfaceCollision(result, surface) ? null : result;
}
/** A narrow concrete sewer can bridge up to twelve blocks between two anchored cross-beams.
* Every beam has two independent short rock anchors; the last lip cantilevers at most eight blocks.
* The first beam belongs to the already certified room where the sewer starts. */
private static List<Support> outletSupports(Module m, Probe probe) {
int direction = m.outletDirection(), length = Math.max(m.width(), m.depth());
var result = new ArrayList<Support>();
int lastBeam = 0;
while (lastBeam < length - 9) {
boolean found = false;
for (int along = Math.min(length - 6, lastBeam + 12); along >= lastBeam + 3; along--) {
int x = DX[direction] == 0 ? m.centerX() : direction == 1 ? m.minX() + along : m.maxX() - along;
int z = DZ[direction] == 0 ? m.centerZ() : direction == 2 ? m.minZ() + along : m.maxZ() - along;
var row = new ArrayList<Support>();
for (int side : new int[] {-2, 2}) {
int sx = x + DZ[direction] * side, sz = z + DX[direction] * side;
var anchor = anchorAt(m, sx, sz, probe);
if (anchor == null) { row.clear(); break; }
row.add(anchor);
}
if (row.size() != 2) continue;
result.addAll(row); lastBeam = along; found = true; break;
}
if (!found) return null;
}
return result;
}
private static Support anchorAt(Module m, int x, int z, Probe probe) {
if (probe.solid(x, m.minY() - 1, z) && probe.solid(x, m.minY() - 2, z)
&& !probe.protectedAt(x, m.minY() - 1, z) && !probe.protectedAt(x, m.minY() - 2, z))
return new Support(Type.PILLAR, x, m.minY() - 1, z, m.minY() - 1);
return support(m, x, z, probe);
}
private static List<Integer> lattice(int min, int max, int stride) {
var values = new ArrayList<Integer>(); for (int n = min; n <= max; n += stride) values.add(n);
if (values.getLast() != max) values.add(max); return values;
}
private static Support support(Module m, int x, int z, Probe probe) {
for (int distance = 1; distance <= 8; distance++) {
int y = m.minY() - distance;
if (y <= 1 || probe.protectedAt(x, y, z)) break;
if (probe.solid(x, y, z) && probe.solid(x, y - 1, z) && !probe.protectedAt(x, y - 1, z)) return new Support(Type.PILLAR, x, y, z, m.minY() - 1);
}
for (int distance = 1; distance <= 8; distance++) {
int y = m.maxY() + distance;
if (y >= 382 || probe.protectedAt(x, y, z)) break;
if (probe.solid(x, y, z) && probe.solid(x, y + 1, z) && !probe.protectedAt(x, y + 1, z)) return new Support(Type.CHAIN, x, m.maxY() + 1, z, y);
}
return null;
}
private static boolean surfaceCollision(Module m, LostCityPlan surface) {
for (var district : surface.districts()) for (var p : district.parcels()) {
if (m.maxX() < p.minX() || m.minX() > p.maxX() || m.maxZ() < p.minZ() || m.minZ() > p.maxZ()) continue;
if (m.kind() == Kind.ACCESS && accessParcel(p) && m.minX() >= p.minX() && m.maxX() <= p.maxX()
&& m.minZ() >= p.minZ() && m.maxZ() <= p.maxZ()) continue;
int footing = p.minY() - (p.kind() == LostCityPlan.Kind.BRIDGE ? 2 : 1);
if (dependencyMaxY(m) >= footing && dependencyMinY(m) <= p.maxY()) return true;
}
return false;
}
/** Render the entire saved module; the native piece clips writes to its chunk and bounds. */
public static void render(Module m, LostCityRenderer.BlockWriter writer) {
BlockState air = Blocks.AIR.defaultBlockState(), floor = Blocks.SMOOTH_STONE.defaultBlockState();
int deck = floorOffset(m.kind(), m.height());
for (int y = 0; y < m.height(); y++) for (int z = 0; z < m.depth(); z++) for (int x = 0; x < m.width(); x++) {
boolean wall = x == 0 || z == 0 || x == m.width() - 1 || z == m.depth() - 1;
BlockState block = m.kind() == Kind.GARAGE ? air : y == 0 ? Blocks.STONE.defaultBlockState()
: y <= deck ? floor : wall || y == m.height() - 1 ? LostCityPalette.masonry(m.seed(), x, y, z) : air;
writer.set(x, y, z, block);
}
switch (m.kind()) {
case ACCESS -> access(m, writer);
case POOLROOM -> pools(m, writer);
case GARAGE -> garage(m, writer);
case SEWER -> sewer(m, writer);
case SEWER_OUTLET -> sewerOutlet(m, writer);
case EXPANSION_HALL -> expansionHall(m, writer);
case PASSAGE -> { /* Narrow, unlit service passages. */ }
}
int openings = m.openings();
if (m.kind() == Kind.SEWER_OUTLET) openings |= 1 << ((Integer.numberOfTrailingZeros(openings) + 2) & 3);
for (int direction = 0; direction < 4; direction++) if ((openings & 1 << direction) != 0)
for (int side = -1; side <= 1; side++) for (int y = deck + 1; y <= deck + 3; y++)
writer.set(DX[direction] == 0 ? m.width() / 2 + side : direction == 1 ? m.width() - 1 : 0, y,
DZ[direction] == 0 ? m.depth() / 2 + side : direction == 2 ? m.depth() - 1 : 0, air);
for (var support : m.supports()) for (int y = support.minY(); y <= support.maxY(); y++) {
BlockState state = support.type() == Type.PILLAR ? Blocks.STONE_BRICKS.defaultBlockState()
: y == support.maxY() ? Blocks.POLISHED_ANDESITE.defaultBlockState() : Blocks.IRON_CHAIN.defaultBlockState();
writer.set(support.x() - m.minX(), y - m.minY(), support.z() - m.minZ(), state);
}
}
private static void access(Module m, LostCityRenderer.BlockWriter writer) {
int exit = Integer.numberOfTrailingZeros(m.openings() == 0 ? 1 : m.openings());
int wall = (exit + 2) & 3;
int x = wall == 1 ? m.width() - 2 : wall == 3 ? 1 : m.width() / 2;
int z = wall == 2 ? m.depth() - 2 : wall == 0 ? 1 : m.depth() / 2;
Direction facing = switch (wall) { case 0 -> Direction.SOUTH; case 1 -> Direction.WEST; case 2 -> Direction.NORTH; default -> Direction.EAST; };
for (int y = 2; y <= m.height() - 3; y++) writer.set(x, y, z,
Blocks.LADDER.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, facing));
// Restore the street plane around one accessible maintenance hatch, never leave a five-block-wide shaft open.
for (int zz = 0; zz < m.depth(); zz++) for (int xx = 0; xx < m.width(); xx++) {
writer.set(xx, m.height() - 3, zz, xx == x && zz == z
? Blocks.LADDER.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, facing) : Blocks.SMOOTH_STONE.defaultBlockState());
writer.set(xx, m.height() - 2, zz, Blocks.AIR.defaultBlockState());
writer.set(xx, m.height() - 1, zz, Blocks.AIR.defaultBlockState());
}
// A wooden hatch remains usable without redstone, including from below.
writer.set(x, m.height() - 2, z, Blocks.DARK_OAK_TRAPDOOR.defaultBlockState()
.setValue(BlockStateProperties.HORIZONTAL_FACING, facing));
}
private static void sewer(Module m, LostCityRenderer.BlockWriter writer) {
int z = m.depth() / 2;
for (int x = 2; x < m.width() - 2; x++) writer.set(x, 1, z, Blocks.WATER.defaultBlockState());
writer.set(1, 3, 1, Blocks.REDSTONE_WALL_TORCH.defaultBlockState()
.setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.EAST));
}
private static void sewerOutlet(Module m, LostCityRenderer.BlockWriter writer) {
int direction = Integer.numberOfTrailingZeros(m.openings()), length = DX[direction] == 0 ? m.depth() : m.width();
for (int along = 3; along < length; along++) {
int x = DX[direction] == 0 ? m.width() / 2 : direction == 1 ? along : m.width() - 1 - along;
int z = DZ[direction] == 0 ? m.depth() / 2 : direction == 2 ? along : m.depth() - 1 - along;
writer.set(x, 1, z, Blocks.AIR.defaultBlockState());
writer.set(x, 0, z, along % 7 == 0 ? Blocks.MOSSY_STONE_BRICKS.defaultBlockState() : Blocks.STONE_BRICKS.defaultBlockState());
if (along == length - 5) writer.set(x, 1, z, Blocks.SMOOTH_STONE.defaultBlockState());
if (along == length - 4) writer.set(x, 1, z, Blocks.WATER.defaultBlockState());
}
// Only the last three channel cells discharge. The upstream weir keeps the public walkways dry.
// The exit has no end wall at water level; its bed stops over certified exterior air.
int x = DX[direction] == 0 ? m.width() / 2 : direction == 1 ? m.width() - 1 : 0;
int z = DZ[direction] == 0 ? m.depth() / 2 : direction == 2 ? m.depth() - 1 : 0;
writer.set(x, 1, z, Blocks.AIR.defaultBlockState());
for (int along = 7; along < length - 5; along += 12) {
int px = DX[direction] == 0 ? 1 : direction == 1 ? along : m.width() - 1 - along;
int pz = DZ[direction] == 0 ? 1 : direction == 2 ? along : m.depth() - 1 - along;
writer.set(px, 2, pz, Blocks.IRON_BARS.defaultBlockState());
writer.set(px, 3, pz, Blocks.IRON_BARS.defaultBlockState());
}
}
private static void pools(Module m, LostCityRenderer.BlockWriter writer) {
// Fifty by twenty-five blocks of open water. The promenade is independent of the four-deep tank.
int x0 = 3, x1 = 52, z0 = 3, z1 = 27;
for (int x = x0; x <= x1; x++) for (int z = z0; z <= z1; z++) {
writer.set(x, 0, z, (z - z0) % 3 == 1 ? Blocks.POLISHED_ANDESITE.defaultBlockState() : Blocks.CALCITE.defaultBlockState());
for (int y = 1; y <= 4; y++) writer.set(x, y, z, Blocks.WATER.defaultBlockState());
writer.set(x, 5, z, Blocks.AIR.defaultBlockState());
}
// A dry half-step joins the promenade to the submerged flight without requiring a full-block jump.
for (int x = 5; x <= 8; x++) writer.set(x, 5, 2, Blocks.STONE_BRICK_STAIRS.defaultBlockState()
.setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.NORTH));
// Broad steps descend into one end of the pool, leaving its full length open to swimming.
for (int x = 5; x <= 8; x++) for (int step = 0; step < 4; step++) {
int z = z0 + step, y = 4 - step;
for (int fill = 1; fill < y; fill++) writer.set(x, fill, z, Blocks.SMOOTH_STONE.defaultBlockState());
writer.set(x, y, z, Blocks.STONE_BRICK_STAIRS.defaultBlockState()
.setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.NORTH)
.setValue(BlockStateProperties.WATERLOGGED, true));
}
for (int x : new int[] {8, 20, 34, 46}) for (int z : new int[] {1, m.depth() - 2}) {
for (int y = 6; y < m.height() - 1; y++) writer.set(x, y, z, Blocks.POLISHED_ANDESITE.defaultBlockState());
}
for (int x : new int[] {13, 27, 41}) {
for (int y = m.height() - 3; y < m.height() - 1; y++) writer.set(x, y, 15, Blocks.IRON_CHAIN.defaultBlockState());
writer.set(x, m.height() - 4, 15, Blocks.LANTERN.defaultBlockState().setValue(BlockStateProperties.HANGING, true));
}
// A few surviving lockers and benches imply previous use without filling the promenade.
for (int z : new int[] {6, 10, 20, 24}) {
writer.set(m.width() - 2, 6, z, Blocks.IRON_TRAPDOOR.defaultBlockState()
.setValue(BlockStateProperties.OPEN, true).setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.EAST));
}
writer.set(1, 6, 5, Blocks.BARREL.defaultBlockState());
}
private static void garage(Module m, LostCityRenderer.BlockWriter writer) {
int top = floorOffset(m.kind(), m.height());
for (int deck = 1; deck <= top; deck += 16) {
for (int z = 0; z < m.depth(); z++) for (int x = 0; x < m.width(); x++) {
writer.set(x, deck - 1, z, Blocks.STONE.defaultBlockState());
writer.set(x, deck, z, (z == 5 || z == 10 || z == 16 || z == 21) && (x >= 5 && x <= 11 || x >= 20 && x <= 26)
? Blocks.CONCRETE.white().defaultBlockState() : Blocks.SMOOTH_STONE.defaultBlockState());
if (x == 0 || z == 0 || x == m.width() - 1 || z == m.depth() - 1)
writer.set(x, deck + 1, z, Blocks.IRON_BARS.defaultBlockState());
}
writer.set(m.width() - 3, deck + 1, 2, Blocks.BARREL.defaultBlockState());
writer.set(m.width() - 3, deck + 1, m.depth() - 3, Blocks.CAULDRON.defaultBlockState());
}
for (int x : new int[] {4, m.width() / 2, m.width() - 5}) for (int z : new int[] {3, m.depth() / 2, m.depth() - 4})
for (int y = 0; y <= m.height() - 1; y++) writer.set(x, y, z, Blocks.POLISHED_ANDESITE.defaultBlockState());
// Three two-wide sixteen-rise stair flights connect all four decks. Their upper slab openings
// are carved after the platforms, including headroom for a standing player on every stair.
for (int lower = 1; lower < top; lower += 16) for (int step = 0; step < 16; step++) {
int z = 3 + step, y = lower + step + 1;
for (int x = 1; x <= 2; x++) {
for (int clear = y + 1; clear <= y + 3; clear++) writer.set(x, clear, z, Blocks.AIR.defaultBlockState());
writer.set(x, y, z, Blocks.STONE_BRICK_STAIRS.defaultBlockState()
.setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.SOUTH));
writer.set(x, y - 1, z, Blocks.STONE_BRICKS.defaultBlockState());
}
}
for (int deck = 1; deck < top; deck += 16) {
writer.set(15, deck + 12, 12, Blocks.POLISHED_ANDESITE.defaultBlockState());
writer.set(16, deck + 12, 12, Blocks.POLISHED_ANDESITE.defaultBlockState());
writer.set(16, deck + 11, 12, Blocks.LANTERN.defaultBlockState().setValue(BlockStateProperties.HANGING, true));
}
}
private static void expansionHall(Module m, LostCityRenderer.BlockWriter writer) {
int middle = m.width() / 2;
// An empty architectural plinth reserves a future diegetic machine location. No machine,
// map entity, interaction or progression state is created by world generation.
for (int x = middle - 2; x <= middle + 2; x++) for (int z = middle - 2; z <= middle + 2; z++)
writer.set(x, 2, z, Blocks.SMOOTH_STONE.defaultBlockState());
for (int n = middle - 1; n <= middle + 1; n++) {
writer.set(n, 2, middle - 3, Blocks.STONE_BRICK_STAIRS.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.SOUTH));
writer.set(n, 2, middle + 3, Blocks.STONE_BRICK_STAIRS.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.NORTH));
writer.set(middle - 3, 2, n, Blocks.STONE_BRICK_STAIRS.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.EAST));
writer.set(middle + 3, 2, n, Blocks.STONE_BRICK_STAIRS.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.WEST));
}
for (int x : new int[] {4, m.width() - 5}) for (int z : new int[] {4, m.depth() - 5}) {
for (int y = 2; y < m.height() - 1; y++) writer.set(x, y, z, Blocks.POLISHED_ANDESITE.defaultBlockState());
}
for (int x : new int[] {7, m.width() - 8}) for (int z : new int[] {7, m.depth() - 8}) {
// Every suspended lantern is linked to the complete masonry ceiling, not a slab or pane.
for (int y = m.height() - 3; y < m.height() - 1; y++) writer.set(x, y, z, Blocks.IRON_CHAIN.defaultBlockState());
writer.set(x, m.height() - 4, z, Blocks.LANTERN.defaultBlockState().setValue(BlockStateProperties.HANGING, true));
}
for (int z : new int[] {5, 8, m.depth() - 9, m.depth() - 6}) {
for (int y = 2; y <= 3; y++) {
writer.set(1, y, z, Blocks.BOOKSHELF.defaultBlockState());
writer.set(m.width() - 2, y, z, Blocks.BOOKSHELF.defaultBlockState());
}
writer.set(3, 2, z, Blocks.DARK_OAK_STAIRS.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.WEST));
writer.set(m.width() - 4, 2, z, Blocks.DARK_OAK_STAIRS.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.EAST));
}
for (int n = 2; n < m.width() - 2; n++) {
writer.set(n, 1, middle, Blocks.POLISHED_ANDESITE.defaultBlockState());
writer.set(middle, 1, n, Blocks.POLISHED_ANDESITE.defaultBlockState());
}
}
private record Point(int x, int y, int z) {}
private static final class Probe {
final Terrain terrain;
final Map<Point, Byte> blocks = new HashMap<>(); final Map<Long, Boolean> columns = new HashMap<>();
final Map<String, Integer> rejections = new java.util.TreeMap<>();
int samples;
Module reject(Module module, String reason) {
rejections.merge(module.kind() + ":" + reason, 1, Integer::sum); return null;
}
Probe(Terrain terrain) { this.terrain = terrain; }
void count() { if (samples >= MAX_SAMPLES) throw new BudgetExceeded(); samples++; }
boolean owns(int x, int z) {
long key = (long) x << 32 ^ (z & 0xffffffffL);
return columns.computeIfAbsent(key, ignored -> { count(); return terrain.owns(x, z); });
}
boolean exteriorAir(int x, int y, int z, int dx, int dz) {
count(); return terrain.exteriorAir(x, y, z, dx, dz);
}
boolean solid(int x, int y, int z) {
if (y < 0 || y >= 384) return false;
var key = new Point(x, y, z); byte value = blocks.getOrDefault(key, (byte) 0);
if ((value & 8) == 0) {
count(); value = (byte) (value | 8 | (terrain.solid(x, y, z) ? 1 : 0)); blocks.put(key, value);
}
return (value & 1) != 0;
}
boolean protectedAt(int x, int y, int z) {
if (y < 0 || y >= 384) return true;
var key = new Point(x, y, z); byte value = blocks.getOrDefault(key, (byte) 0);
if ((value & 4) == 0) {
count(); value = (byte) (value | 4 | (terrain.protectedAt(x, y, z) ? 2 : 0)); blocks.put(key, value);
}
return (value & 2) != 0;
}
}
private static final class BudgetExceeded extends RuntimeException {}
private static int roll(long value, int bound) { return (int) Long.remainderUnsigned(mix(value), bound); }
private static long mix(long value) { value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L; value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL; return value ^ (value >>> 31); }
}
@@ -0,0 +1,88 @@
package fr.koka.sanctuary.worldgen.city;
import java.util.ArrayList;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.resources.ResourceKey;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.StructureManager;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.StructurePiece;
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
import net.minecraft.world.level.storage.loot.LootTable;
/** A saved underground module includes its openings and certified supports, including their anchors. */
public final class LostCityUndergroundPiece15 extends StructurePiece {
private final LostCityUnderground15.Module module;
public LostCityUndergroundPiece15(LostCityUnderground15.Module module) {
super(LostCityStructures.UNDERGROUND15, 0, bounds(module));
this.module = module;
setOrientation(null);
}
public LostCityUndergroundPiece15(StructurePieceSerializationContext context, CompoundTag tag) {
super(LostCityStructures.UNDERGROUND15, tag);
if (tag.getInt("SanctuarySchema").orElseThrow() != 1)
throw new IllegalArgumentException("Unsupported underground piece schema");
var supports = new ArrayList<LostCityUnderground15.Support>();
var saved = tag.getList("Supports").orElseThrow();
if (saved.size() > 128) throw new IllegalArgumentException("Too many underground supports");
for (var entry : saved) {
if (!(entry instanceof CompoundTag support)) throw new IllegalArgumentException("Invalid support");
supports.add(new LostCityUnderground15.Support(
LostCityUnderground15.Type.valueOf(support.getString("Type").orElseThrow()),
support.getInt("X").orElseThrow(), support.getInt("MinY").orElseThrow(),
support.getInt("Z").orElseThrow(), support.getInt("MaxY").orElseThrow()));
}
module = new LostCityUnderground15.Module(LostCityUnderground15.Kind.valueOf(tag.getString("Kind").orElseThrow()),
tag.getInt("MinX").orElseThrow(), tag.getInt("MinY").orElseThrow(), tag.getInt("MinZ").orElseThrow(),
tag.getInt("Width").orElseThrow(), tag.getInt("Height").orElseThrow(), tag.getInt("Depth").orElseThrow(),
tag.getInt("Openings").orElseThrow(), tag.getLong("ModuleSeed").orElseThrow(), supports);
if (!getBoundingBox().equals(bounds(module))) throw new IllegalArgumentException("Underground bounds disagree with saved geometry");
setOrientation(null);
}
public LostCityUnderground15.Module module() { return module; }
@Override protected void addAdditionalSaveData(StructurePieceSerializationContext context, CompoundTag tag) {
tag.putInt("SanctuarySchema", 1);
tag.putString("Kind", module.kind().name());
tag.putInt("MinX", module.minX()); tag.putInt("MinY", module.minY()); tag.putInt("MinZ", module.minZ());
tag.putInt("Width", module.width()); tag.putInt("Height", module.height()); tag.putInt("Depth", module.depth());
tag.putInt("Openings", module.openings()); tag.putLong("ModuleSeed", module.seed());
var supports = new ListTag();
for (var support : module.supports()) {
var value = new CompoundTag();
value.putString("Type", support.type().name()); value.putInt("X", support.x());
value.putInt("MinY", support.minY()); value.putInt("Z", support.z()); value.putInt("MaxY", support.maxY());
supports.add(value);
}
tag.put("Supports", supports);
}
@Override public void postProcess(WorldGenLevel level, StructureManager manager, ChunkGenerator generator,
RandomSource random, BoundingBox clip, ChunkPos chunk, BlockPos pivot) {
LostCityStructures.placing(() -> LostCityUnderground15.render(module, new LostCityRenderer.BlockWriter() {
private BlockPos world(int x, int y, int z) { return new BlockPos(module.minX() + x, module.minY() + y, module.minZ() + z); }
@Override public void set(int x, int y, int z, BlockState state) {
var pos = world(x, y, z);
if (getBoundingBox().isInside(pos) && clip.isInside(pos)) placeBlock(level, state, pos.getX(), pos.getY(), pos.getZ(), clip);
}
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long seed) {
var pos = world(x, y, z);
if (getBoundingBox().isInside(pos) && clip.isInside(pos)) createChest(level, clip, RandomSource.create(seed), pos, table, null);
}
}));
}
public static BoundingBox bounds(LostCityUnderground15.Module module) {
return new BoundingBox(module.boundsMinX(), module.boundsMinY(), module.boundsMinZ(),
module.boundsMaxX(), module.boundsMaxY(), module.boundsMaxZ());
}
}
@@ -0,0 +1,88 @@
package fr.koka.sanctuary.worldgen.city;
import java.util.ArrayList;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.resources.ResourceKey;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.StructureManager;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.StructurePiece;
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
import net.minecraft.world.level.storage.loot.LootTable;
/** A saved underground module includes its openings and certified supports, including their anchors. */
public final class LostCityUndergroundPiece16 extends StructurePiece {
private final LostCityUnderground16.Module module;
public LostCityUndergroundPiece16(LostCityUnderground16.Module module) {
super(LostCityStructures.UNDERGROUND16, 0, bounds(module));
this.module = module;
setOrientation(null);
}
public LostCityUndergroundPiece16(StructurePieceSerializationContext context, CompoundTag tag) {
super(LostCityStructures.UNDERGROUND16, tag);
if (tag.getInt("SanctuarySchema").orElseThrow() != 1)
throw new IllegalArgumentException("Unsupported underground piece schema");
var supports = new ArrayList<LostCityUnderground16.Support>();
var saved = tag.getList("Supports").orElseThrow();
if (saved.size() > 128) throw new IllegalArgumentException("Too many underground supports");
for (var entry : saved) {
if (!(entry instanceof CompoundTag support)) throw new IllegalArgumentException("Invalid support");
supports.add(new LostCityUnderground16.Support(
LostCityUnderground16.Type.valueOf(support.getString("Type").orElseThrow()),
support.getInt("X").orElseThrow(), support.getInt("MinY").orElseThrow(),
support.getInt("Z").orElseThrow(), support.getInt("MaxY").orElseThrow()));
}
module = new LostCityUnderground16.Module(LostCityUnderground16.Kind.valueOf(tag.getString("Kind").orElseThrow()),
tag.getInt("MinX").orElseThrow(), tag.getInt("MinY").orElseThrow(), tag.getInt("MinZ").orElseThrow(),
tag.getInt("Width").orElseThrow(), tag.getInt("Height").orElseThrow(), tag.getInt("Depth").orElseThrow(),
tag.getInt("Openings").orElseThrow(), tag.getLong("ModuleSeed").orElseThrow(), supports);
if (!getBoundingBox().equals(bounds(module))) throw new IllegalArgumentException("Underground bounds disagree with saved geometry");
setOrientation(null);
}
public LostCityUnderground16.Module module() { return module; }
@Override protected void addAdditionalSaveData(StructurePieceSerializationContext context, CompoundTag tag) {
tag.putInt("SanctuarySchema", 1);
tag.putString("Kind", module.kind().name());
tag.putInt("MinX", module.minX()); tag.putInt("MinY", module.minY()); tag.putInt("MinZ", module.minZ());
tag.putInt("Width", module.width()); tag.putInt("Height", module.height()); tag.putInt("Depth", module.depth());
tag.putInt("Openings", module.openings()); tag.putLong("ModuleSeed", module.seed());
var supports = new ListTag();
for (var support : module.supports()) {
var value = new CompoundTag();
value.putString("Type", support.type().name()); value.putInt("X", support.x());
value.putInt("MinY", support.minY()); value.putInt("Z", support.z()); value.putInt("MaxY", support.maxY());
supports.add(value);
}
tag.put("Supports", supports);
}
@Override public void postProcess(WorldGenLevel level, StructureManager manager, ChunkGenerator generator,
RandomSource random, BoundingBox clip, ChunkPos chunk, BlockPos pivot) {
LostCityStructures.placing(() -> LostCityUnderground16.render(module, new LostCityRenderer.BlockWriter() {
private BlockPos world(int x, int y, int z) { return new BlockPos(module.minX() + x, module.minY() + y, module.minZ() + z); }
@Override public void set(int x, int y, int z, BlockState state) {
var pos = world(x, y, z);
if (getBoundingBox().isInside(pos) && clip.isInside(pos)) placeBlock(level, state, pos.getX(), pos.getY(), pos.getZ(), clip);
}
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long seed) {
var pos = world(x, y, z);
if (getBoundingBox().isInside(pos) && clip.isInside(pos)) createChest(level, clip, RandomSource.create(seed), pos, table, null);
}
}));
}
public static BoundingBox bounds(LostCityUnderground16.Module module) {
return new BoundingBox(module.boundsMinX(), module.boundsMinY(), module.boundsMinZ(),
module.boundsMaxX(), module.boundsMaxY(), module.boundsMaxZ());
}
}
@@ -0,0 +1,114 @@
package fr.koka.sanctuary.worldgen.ruins;
import java.util.ArrayList;
import java.util.Set;
import fr.koka.sanctuary.worldgen.city.LostCityRenderer;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.nbt.Tag;
import net.minecraft.resources.ResourceKey;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.StructureManager;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.StructurePiece;
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
import net.minecraft.world.level.storage.loot.LootTable;
/** Saved generation17 room geometry is rendered without replanning or reading neighboring chunks. */
public final class RuinsPiece17 extends StructurePiece {
private static final Set<String> ROOM_KEYS = Set.of("Schema", "Kind", "MinX", "MinY", "MinZ", "Width",
"Height", "Depth", "Openings", "RoomSeed", "Supports");
private static final Set<String> SUPPORT_KEYS = Set.of("X", "MinY", "Z", "MaxY");
private final RuinsPlan17.Room room;
public RuinsPiece17(RuinsPlan17.Room room) {
super(RuinsStructures17.PIECE, 0, bounds(room));
this.room = room;
setOrientation(null);
}
public RuinsPiece17(StructurePieceSerializationContext context, CompoundTag tag) {
super(RuinsStructures17.PIECE, tag);
room = decodeRoom(tag.getCompound("SanctuaryRoom")
.orElseThrow(() -> new IllegalArgumentException("Missing saved forgotten room")));
if (!getBoundingBox().equals(bounds(room)))
throw new IllegalArgumentException("Forgotten room disagrees with its saved native bounding box");
setOrientation(null);
}
public RuinsPlan17.Room room() { return room; }
@Override public BlockPos getLocatorPosition() { return locator(room); }
public static BlockPos locator(RuinsPlan17.Room room) {
return new BlockPos(room.minX() + room.width() / 2,
room.kind() == RuinsPlan17.Kind.ACCESS ? room.minY() + room.height() - 2 : room.floorY() + 1,
room.minZ() + room.depth() / 2);
}
@Override protected void addAdditionalSaveData(StructurePieceSerializationContext context, CompoundTag tag) {
tag.put("SanctuaryRoom", encodeRoom(room));
}
public static CompoundTag encodeRoom(RuinsPlan17.Room room) {
var tag = new CompoundTag(); tag.putInt("Schema", 1); tag.putString("Kind", room.kind().name());
tag.putInt("MinX", room.minX()); tag.putInt("MinY", room.minY()); tag.putInt("MinZ", room.minZ());
tag.putInt("Width", room.width()); tag.putInt("Height", room.height()); tag.putInt("Depth", room.depth());
tag.putInt("Openings", room.openings()); tag.putLong("RoomSeed", room.seed());
var supports = new ListTag();
for (var support : room.supports()) {
var value = new CompoundTag(); value.putInt("X", support.x()); value.putInt("MinY", support.minY());
value.putInt("Z", support.z()); value.putInt("MaxY", support.maxY()); supports.add(value);
}
tag.put("Supports", supports);
return tag;
}
public static RuinsPlan17.Room decodeRoom(CompoundTag tag) {
if (!tag.keySet().equals(ROOM_KEYS)) throw new IllegalArgumentException("Incomplete or unknown forgotten room fields");
if (integer(tag, "Schema") != 1) throw new IllegalArgumentException("Unsupported forgotten room schema");
var kind = RuinsPlan17.Kind.valueOf(tag.getString("Kind").orElseThrow(() -> new IllegalArgumentException("Missing room kind")));
type(tag, "RoomSeed", Tag.TAG_LONG); type(tag, "Supports", Tag.TAG_LIST);
var saved = tag.getList("Supports").orElseThrow();
if (saved.size() > 64) throw new IllegalArgumentException("Too many saved forgotten room supports");
var supports = new ArrayList<RuinsPlan17.Support>();
for (var entry : saved) {
if (!(entry instanceof CompoundTag value) || !value.keySet().equals(SUPPORT_KEYS))
throw new IllegalArgumentException("Invalid forgotten room support fields");
supports.add(new RuinsPlan17.Support(integer(value, "X"), integer(value, "MinY"), integer(value, "Z"), integer(value, "MaxY")));
}
return new RuinsPlan17.Room(kind, integer(tag, "MinX"), integer(tag, "MinY"), integer(tag, "MinZ"),
integer(tag, "Width"), integer(tag, "Height"), integer(tag, "Depth"), integer(tag, "Openings"),
tag.getLong("RoomSeed").orElseThrow(), supports);
}
private static int integer(CompoundTag tag, String key) {
type(tag, key, Tag.TAG_INT);
return tag.getInt(key).orElseThrow();
}
private static void type(CompoundTag tag, String key, byte id) {
if (tag.get(key) == null || tag.get(key).getId() != id)
throw new IllegalArgumentException("Invalid saved forgotten room field: " + key);
}
@Override public void postProcess(WorldGenLevel level, StructureManager manager, ChunkGenerator generator,
RandomSource random, BoundingBox clip, ChunkPos chunk, BlockPos pivot) {
RuinsStructures17.placing(() -> RuinsRenderer17.render(room, new LostCityRenderer.BlockWriter() {
private BlockPos world(int x, int y, int z) { return new BlockPos(room.minX() + x, room.minY() + y, room.minZ() + z); }
@Override public void set(int x, int y, int z, BlockState block) {
var position = world(x, y, z);
if (getBoundingBox().isInside(position) && clip.isInside(position))
placeBlock(level, block, position.getX(), position.getY(), position.getZ(), clip);
}
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> loot, long seed) {
var position = world(x, y, z);
if (getBoundingBox().isInside(position) && clip.isInside(position))
createChest(level, clip, RandomSource.create(seed), position, loot, null);
}
}));
}
public static BoundingBox bounds(RuinsPlan17.Room room) {
int minY = room.minY(), maxY = Math.addExact(room.minY(), room.height() - 1);
for (var support : room.supports()) {
minY = Math.min(minY, support.minY()); maxY = Math.max(maxY, support.maxY());
}
return new BoundingBox(room.minX(), minY, room.minZ(), Math.addExact(room.minX(), room.width() - 1),
maxY, Math.addExact(room.minZ(), room.depth() - 1));
}
}
@@ -0,0 +1,123 @@
package fr.koka.sanctuary.worldgen.ruins;
import java.util.HashSet;
import java.util.List;
/** Immutable generation17 experiment. Native pieces persist every room and its bounded footing. */
public record RuinsPlan17(List<Network> networks, int samples) {
public static final int MAX_SAMPLES = 250_000;
public static final RuinsPlan17 EMPTY = new RuinsPlan17(List.of(), 0);
public enum Family { MINE, STRANGE, INFRASTRUCTURE }
public enum Kind { ACCESS, CORRIDOR, MINE_GALLERY, WORKSHOP, STORE,
TILED_HALL, ARCHIVE, SERVICE_HALL, PUMP_HALL, CONTROL_ROOM }
/** One-block architectural pier. Its immediately lower block remains native solid rock. */
public record Support(int x, int minY, int z, int maxY) {
public Support {
if (minY < 9 || maxY >= 380 || minY > maxY || maxY - minY >= 12)
throw new IllegalArgumentException("Invalid generation17 ruins support");
}
}
/** Openings N=1,E=2,S=4,W=8: centered three wide, local y2..4. Floor is local y1.
* Adjacent bodies never overlap, including their opened boundary walls. */
public record Room(Kind kind, int minX, int minY, int minZ, int width, int height, int depth,
int openings, long seed, List<Support> supports) {
public Room {
supports = List.copyOf(supports);
if (kind == null || width < 5 || width > 17 || depth < 5 || depth > 17
|| (width & 1) == 0 || (depth & 1) == 0 || height < 6 || height > 40
|| minY < 10 || minY + height > 383 || openings < 0 || openings > 15 || supports.size() > 9)
throw new IllegalArgumentException("Invalid generation17 ruins room");
if (kind == Kind.ACCESS && (width != 5 || depth != 5))
throw new IllegalArgumentException("Ruins access must be five by five");
if (kind != Kind.ACCESS && kind != Kind.CORRIDOR && (width < 9 || depth < 9 || height < 7))
throw new IllegalArgumentException("Thematic ruins rooms need space for their central passage and furnishings");
var positions = new HashSet<Long>();
for (var support : supports) if (support.x() < minX || support.x() >= minX + width
|| support.z() < minZ || support.z() >= minZ + depth || support.maxY() != minY - 1
|| !positions.add(key(support.x(), support.z())))
throw new IllegalArgumentException("Ruins support does not meet its own room floor");
}
public int maxX() { return minX + width - 1; }
public int maxY() { return minY + height - 1; }
public int maxZ() { return minZ + depth - 1; }
public int centerX() { return minX + width / 2; }
public int centerZ() { return minZ + depth / 2; }
public int floorY() { return minY + 1; }
public int boundsMinY() { return supports.stream().mapToInt(Support::minY).min().orElse(minY); }
public int boundsMaxY() { return maxY(); }
public boolean contains(int x, int y, int z) {
if (x < minX || x > maxX() || z < minZ || z > maxZ()) return false;
if (y >= minY && y <= maxY()) return true;
for (var support : supports) if (support.x() == x && support.z() == z && y >= support.minY() && y <= support.maxY()) return true;
return false;
}
public boolean intersects(int x0, int y0, int z0, int x1, int y1, int z1) {
if (x0 > maxX() || x1 < minX || z0 > maxZ() || z1 < minZ) return false;
if (y0 <= maxY() && y1 >= minY) return true;
for (var support : supports) if (support.x() >= x0 && support.x() <= x1 && support.z() >= z0 && support.z() <= z1
&& support.minY() <= y1 && support.maxY() >= y0) return true;
return false;
}
public Room withOpening(int direction) {
if (direction < 0 || direction > 3) throw new IllegalArgumentException("Invalid opening direction");
return new Room(kind, minX, minY, minZ, width, height, depth, openings | 1 << direction, seed, supports);
}
public Room withSupports(List<Support> values) {
return new Room(kind, minX, minY, minZ, width, height, depth, openings, seed, values);
}
}
public record Network(String id, Family family, int startChunkX, int startChunkZ, List<Room> rooms) {
public Network {
rooms = List.copyOf(rooms);
if (id == null || !id.matches("[a-z][a-z0-9_]{0,31}") || family == null || rooms.size() < 3 || rooms.size() > 6
|| rooms.stream().filter(room -> room.kind() == Kind.ACCESS).count() != 1)
throw new IllegalArgumentException("Invalid generation17 ruins network");
for (var room : rooms) if (Math.abs((room.minX() >> 4) - startChunkX) > 7
|| Math.abs((room.maxX() >> 4) - startChunkX) > 7
|| Math.abs((room.minZ() >> 4) - startChunkZ) > 7 || Math.abs((room.maxZ() >> 4) - startChunkZ) > 7)
throw new IllegalArgumentException("Ruins network exceeds native reference range");
}
}
public RuinsPlan17 {
networks = List.copyOf(networks);
if (networks.size() > 3 || samples < 0 || samples > MAX_SAMPLES)
throw new IllegalArgumentException("Invalid generation17 ruins planning budget");
var ids = new HashSet<String>();
for (var network : networks) if (!ids.add(network.id())) throw new IllegalArgumentException("Duplicate ruins network id");
}
public boolean isEmpty() { return networks.isEmpty(); }
public boolean contains(int x, int y, int z) {
for (var network : networks) for (var room : network.rooms()) if (room.contains(x, y, z)) return true;
return false;
}
public boolean intersects(int x0, int y0, int z0, int x1, int y1, int z1) {
if (x0 > x1 || y0 > y1 || z0 > z1) throw new IllegalArgumentException("Reversed ruins bounds");
for (var network : networks) for (var room : network.rooms()) if (room.intersects(x0, y0, z0, x1, y1, z1)) return true;
return false;
}
/** Construction plus the unpainted native rock which carries its floors and short piers. */
public boolean protects(int x, int y, int z) {
if (contains(x, y, z)) return true;
for (var network : networks) for (var room : network.rooms()) {
if (x >= room.minX() && x <= room.maxX() && z >= room.minZ() && z <= room.maxZ()
&& y >= room.minY() - 2 && y < room.minY()) return true;
for (var support : room.supports()) if (x == support.x() && z == support.z() && y == support.minY() - 1) return true;
}
return false;
}
public boolean intersectsProtection(int x0, int y0, int z0, int x1, int y1, int z1) {
if (intersects(x0, y0, z0, x1, y1, z1)) return true;
for (var network : networks) for (var room : network.rooms()) {
if (x0 <= room.maxX() && x1 >= room.minX() && z0 <= room.maxZ() && z1 >= room.minZ()
&& y0 < room.minY() && y1 >= room.minY() - 2) return true;
for (var support : room.supports()) if (support.x() >= x0 && support.x() <= x1 && support.z() >= z0 && support.z() <= z1
&& support.minY() - 1 >= y0 && support.minY() - 1 <= y1) return true;
}
return false;
}
private static long key(int x, int z) { return (long) x << 32 ^ z & 0xffffffffL; }
}
@@ -0,0 +1,217 @@
package fr.koka.sanctuary.worldgen.ruins;
import fr.koka.sanctuary.worldgen.ruins.RuinsPlan17.Family;
import fr.koka.sanctuary.worldgen.ruins.RuinsPlan17.Kind;
import fr.koka.sanctuary.worldgen.ruins.RuinsPlan17.Network;
import fr.koka.sanctuary.worldgen.ruins.RuinsPlan17.Room;
import fr.koka.sanctuary.worldgen.ruins.RuinsPlan17.Support;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/** Opt-in generation17 prototype. Three independently budgeted families have discoverable
* surface shafts and compact, connected interiors. No existing chunk or world state is read.
* Families remain separate in this prototype; no speculative cross-network door is published. */
public final class RuinsPlanner17 {
public static final int MAX_COLUMNS = 5_000;
public static final int MAX_SAMPLES = RuinsPlan17.MAX_SAMPLES;
private static final int INVALID = Integer.MIN_VALUE;
private static final int[] DX = {0, 1, 0, -1}, DZ = {-1, 0, 1, 0};
private RuinsPlanner17() {}
public interface Terrain {
boolean owns(int x, int z);
boolean solid(int x, int y, int z);
boolean protectedAt(int x, int y, int z);
/** Highest dry solid block; -1 when the column has no accessible surface. */
int surfaceY(int x, int z);
}
public static RuinsPlan17 plan(long seed, int centerX, int centerZ, int diameter, Terrain terrain) {
if (diameter < 64 || diameter > 1024) throw new IllegalArgumentException("Unsupported ruins island size");
var probe = new Probe(terrain, centerX, centerZ, diameter);
var networks = new ArrayList<Network>();
for (var family : Family.values()) {
int remaining = Family.values().length - family.ordinal() - 1;
probe.columnLimit = MAX_COLUMNS - remaining * (MAX_COLUMNS / 3);
probe.sampleLimit = MAX_SAMPLES - remaining * (MAX_SAMPLES / 3);
Network best = null;
try {
search: for (int attempt = 0; attempt < 6; attempt++) {
Site site = site(seed, family, attempt, centerX, centerZ, diameter, probe);
if (site == null) continue;
for (int drop : new int[] {14, 22, 30}) {
Network candidate = network(seed, family, site, drop, networks, probe);
if (candidate != null && (best == null || candidate.rooms().size() > best.rooms().size())) best = candidate;
if (best != null && best.rooms().size() == 5) break search;
}
}
} catch (BudgetExceeded exhausted) { probe.reject(family.name().toLowerCase(Locale.ROOT) + "-budget"); }
if (best != null) networks.add(best);
}
if (Boolean.getBoolean("sanctuary.test.ruins17") || Boolean.getBoolean("sanctuary.ruins.plannerDiagnostics"))
System.getLogger(RuinsPlanner17.class.getName()).log(System.Logger.Level.INFO,
"Ruins17 seed=" + seed + " diameter=" + diameter + " networks="
+ networks.stream().map(n -> n.family() + ":" + n.rooms().size()).toList()
+ " columns=" + probe.columns.size() + " samples=" + probe.samples + " rejected=" + probe.rejections);
return new RuinsPlan17(networks, probe.samples);
}
private static Site site(long seed, Family family, int attempt, int cx, int cz, int diameter, Probe probe) {
long salt = mix(seed ^ family.ordinal() * 0x9E3779B97F4A7C15L ^ attempt * 0xD1342543DE82EF95L ^ 0x1700L);
double base = unit(mix(seed ^ 0x17BA5EL)) * Math.PI * 2 + family.ordinal() * Math.PI * 2 / 3;
double angle = base + new double[] {0, .30, -.30, .48, -.48, .72}[attempt];
double radius = Math.min(diameter * .27, 216) * (attempt == 3 || attempt == 4 ? .72 : 1);
int x = cx + (int) Math.round(Math.cos(angle) * radius), z = cz + (int) Math.round(Math.sin(angle) * radius);
int low = 384, high = -1;
// Five coarse witnesses reject cliffs, water and unowned corners before a volume scan.
for (int[] offset : new int[][] {{-2, -2}, {-2, 2}, {2, -2}, {2, 2}, {0, 0}}) {
int px = x + offset[0], pz = z + offset[1], top = probe.surface(px, pz);
if (top < 54 || top > 376 || !probe.solid(px, top, pz) || probe.protectedAt(px, top, pz)) {
probe.reject("coarse-entrance"); return null;
}
low = Math.min(low, top); high = Math.max(high, top);
}
if (high - low > 4) { probe.reject("entrance-cliff"); return null; }
int direction = Math.abs(x - cx) >= Math.abs(z - cz) ? x < cx ? 1 : 3 : z < cz ? 2 : 0;
if ((salt & 1) != 0) direction = (direction + (attempt % 2 == 0 ? 1 : 3)) & 3;
return new Site(x, z, high, direction, salt);
}
private static Network network(long seed, Family family, Site site, int drop, List<Network> previous, Probe probe) {
int top = site.top();
for (int z = site.z() - 2; z <= site.z() + 2; z++) for (int x = site.x() - 2; x <= site.x() + 2; x++) {
int height = probe.surface(x, z);
if (height < site.top() - 4 || height > site.top() + 4) { probe.reject("entrance-surface"); return null; }
top = Math.max(top, height);
}
int floor = top - drop;
Room access = new Room(Kind.ACCESS, site.x() - 2, floor - 1, site.z() - 2,
5, drop + 4, 5, 0, mix(seed ^ site.seed() ^ drop), List.of());
var rooms = new ArrayList<Room>();
access = certify(access, rooms, previous, probe);
if (access == null) return null;
rooms.add(access);
if (!attach(rooms, 0, Kind.CORRIDOR, site.direction(), mix(site.seed() ^ drop ^ 0xC0L), previous, probe)) return null;
Kind[] kinds = switch (family) {
case MINE -> new Kind[] {Kind.MINE_GALLERY, Kind.WORKSHOP, Kind.STORE};
case STRANGE -> new Kind[] {Kind.TILED_HALL, Kind.ARCHIVE, Kind.STORE};
case INFRASTRUCTURE -> new Kind[] {Kind.SERVICE_HALL, Kind.PUMP_HALL, Kind.CONTROL_ROOM};
};
if (!attach(rooms, 1, kinds[0], site.direction(), mix(site.seed() ^ drop ^ 0xA1L), previous, probe)) return null;
// Keep already connected rooms when an optional branch encounters protected terrain.
int side = (site.direction() + ((site.seed() & 2) == 0 ? 1 : 3)) & 3;
try {
attach(rooms, 2, kinds[1], side, mix(site.seed() ^ drop ^ 0xB2L), previous, probe);
attach(rooms, 2, kinds[2], site.direction(), mix(site.seed() ^ drop ^ 0xC3L), previous, probe);
} catch (BudgetExceeded exhausted) { probe.reject("optional-branch-budget"); }
return new Network(family.name().toLowerCase(Locale.ROOT), family, site.x() >> 4, site.z() >> 4, rooms);
}
private static boolean attach(List<Room> rooms, int parentIndex, Kind kind, int direction, long seed,
List<Network> previous, Probe probe) {
Room parent = rooms.get(parentIndex);
int width = kind == Kind.CORRIDOR ? 5 : kind == Kind.MINE_GALLERY ? 13 : 9;
int depth = kind == Kind.CORRIDOR ? 5 : 9, height = kind == Kind.CORRIDOR ? 6 : 7;
int x = parent.centerX() - width / 2, z = parent.centerZ() - depth / 2;
if (direction == 0) z = parent.minZ() - depth;
if (direction == 1) x = parent.maxX() + 1;
if (direction == 2) z = parent.maxZ() + 1;
if (direction == 3) x = parent.minX() - width;
Room room = new Room(kind, x, parent.minY(), z, width, height, depth, 1 << ((direction + 2) & 3), seed, List.of());
room = certify(room, rooms, previous, probe);
if (room == null) return false;
rooms.set(parentIndex, parent.withOpening(direction)); rooms.add(room);
return true;
}
private static Room certify(Room draft, List<Room> siblings, List<Network> previous, Probe probe) {
for (Room room : siblings) if (conflicts(room, draft)) { probe.reject("sibling-overlap"); return null; }
for (Network network : previous) for (Room room : network.rooms()) if (conflicts(room, draft)) {
probe.reject("network-overlap"); return null;
}
for (int z = draft.minZ(); z <= draft.maxZ(); z++) for (int x = draft.minX(); x <= draft.maxX(); x++) {
int top = probe.surface(x, z);
if (top == INVALID || top < draft.minY() + 5 || draft.kind() != Kind.ACCESS && top < draft.maxY() + 1) {
probe.reject("room-exposed"); return null;
}
for (int y = draft.minY(); y <= draft.maxY(); y++) if (probe.protectedAt(x, y, z)) {
probe.reject("room-protected"); return null;
}
}
var supports = new ArrayList<Support>();
for (int z : new int[] {draft.minZ() + 1, draft.centerZ(), draft.maxZ() - 1})
for (int x : new int[] {draft.minX() + 1, draft.centerX(), draft.maxX() - 1}) {
int bottom = draft.minY() - 1;
if (probe.solid(x, bottom, z) && probe.solid(x, bottom - 1, z)) {
if (probe.protectedAt(x, bottom, z) || probe.protectedAt(x, bottom - 1, z)) { probe.reject("footing-protected"); return null; }
continue;
}
int rock = -1;
for (int y = bottom - 1; y >= Math.max(9, draft.minY() - 13); y--)
if (probe.solid(x, y, z) && probe.solid(x, y - 1, z)) { rock = y; break; }
if (rock < 0) { probe.reject("footing-void"); return null; }
for (int y = rock; y <= bottom; y++) if (probe.protectedAt(x, y, z)) { probe.reject("pier-protected"); return null; }
supports.add(new Support(x, rock + 1, z, bottom));
}
Room result = draft.withSupports(supports);
for (Room sibling : siblings) if (conflicts(sibling, result)) { probe.reject("pier-sibling"); return null; }
for (Network network : previous) for (Room room : network.rooms()) if (conflicts(room, result)) {
probe.reject("pier-network"); return null;
}
return result;
}
private static boolean conflicts(Room a, Room b) {
if (protectsBounds(a, b.minX(), b.minY(), b.minZ(), b.maxX(), b.maxY(), b.maxZ())
|| protectsBounds(b, a.minX(), a.minY(), a.minZ(), a.maxX(), a.maxY(), a.maxZ())) return true;
for (var support : b.supports()) if (protectsBounds(a, support.x(), support.minY(), support.z(), support.x(), support.maxY(), support.z())) return true;
for (var support : a.supports()) if (protectsBounds(b, support.x(), support.minY(), support.z(), support.x(), support.maxY(), support.z())) return true;
return false;
}
private static boolean protectsBounds(Room room, int x0, int y0, int z0, int x1, int y1, int z1) {
if (room.intersects(x0, y0, z0, x1, y1, z1)) return true;
if (x0 <= room.maxX() && x1 >= room.minX() && z0 <= room.maxZ() && z1 >= room.minZ()
&& y0 < room.minY() && y1 >= room.minY() - 2) return true;
for (var support : room.supports()) if (support.x() >= x0 && support.x() <= x1 && support.z() >= z0 && support.z() <= z1
&& support.minY() - 1 >= y0 && support.minY() - 1 <= y1) return true;
return false;
}
private record Site(int x, int z, int top, int direction, long seed) {}
private static final class BudgetExceeded extends RuntimeException {}
private static final class Probe {
final Terrain terrain; final int centerX, centerZ; final double radius;
final Map<Long, Integer> columns = new HashMap<>();
final Map<String, Integer> rejections = new java.util.TreeMap<>();
int samples, columnLimit, sampleLimit;
Probe(Terrain terrain, int centerX, int centerZ, int diameter) {
this.terrain = java.util.Objects.requireNonNull(terrain); this.centerX = centerX; this.centerZ = centerZ;
radius = diameter / 2.0 - 3;
}
int surface(int x, int z) {
if (Math.hypot((double) x - centerX, (double) z - centerZ) > radius || !terrain.owns(x, z)) return INVALID;
long key = (long) x << 32 ^ z & 0xffffffffL;
var cached = columns.get(key); if (cached != null) return cached;
if (columns.size() >= columnLimit) throw new BudgetExceeded();
count(); int top = terrain.surfaceY(x, z); columns.put(key, top); return top;
}
boolean solid(int x, int y, int z) {
if (y < 0 || y >= 384 || surface(x, z) == INVALID) return false;
count(); return terrain.solid(x, y, z);
}
boolean protectedAt(int x, int y, int z) {
if (y < 0 || y >= 384 || surface(x, z) == INVALID) return true;
count(); return terrain.protectedAt(x, y, z);
}
void count() { if (samples >= sampleLimit) throw new BudgetExceeded(); samples++; }
void reject(String reason) { rejections.merge(reason, 1, Integer::sum); }
}
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,277 @@
package fr.koka.sanctuary.worldgen.ruins;
import fr.koka.sanctuary.worldgen.city.LostCityPalette;
import fr.koka.sanctuary.worldgen.city.LostCityRenderer;
import java.util.ArrayList;
import java.util.List;
import net.minecraft.core.Direction;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.Rotation;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.AttachFace;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.storage.loot.BuiltInLootTables;
/** Generation17 experiments: original, physical rooms reconstructed solely from their saved inputs.
* Coordinates given to the writer are local to the saved room. There are no terrain reads, entities,
* active machines or free fluids. Openings are restored last so connected pieces share dry thresholds. */
public final class RuinsRenderer17 {
private static final Direction[] DIRECTIONS = {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST};
private RuinsRenderer17() {}
/** Local standing positions for native path checks, including approaches to useful fixtures. */
public record Target(String name, int x, int y, int z) {}
public static List<Target> targets(RuinsPlan17.Room room) {
var targets = new ArrayList<Target>();
if (room.kind() == RuinsPlan17.Kind.ACCESS) {
targets.add(new Target("surface_hatch_approach", room.width() / 2, room.height() - 2, room.depth() / 2));
targets.add(new Target("shaft_bottom", room.width() / 2, 2, room.depth() / 2));
} else {
targets.add(new Target("central_crossing", room.width() / 2, 2, room.depth() / 2));
switch (room.kind()) {
case WORKSHOP -> {
targets.add(decorTarget(room, "workbench", 2, 2, 2));
targets.add(decorTarget(room, "unfinished_work_platform", room.width() - 3, 4, room.depth() - 3));
}
case STORE -> targets.add(decorTarget(room, "supply_chest", room.width() - 3, 2, room.depth() - 2));
case ARCHIVE -> targets.add(decorTarget(room, "reading_desk", 2, 2, 2));
case PUMP_HALL, CONTROL_ROOM -> targets.add(decorTarget(room, "controls", 2, 2, 2));
default -> { }
}
}
for (int direction = 0; direction < 4; direction++) if ((room.openings() & 1 << direction) != 0) {
int x = direction == 1 ? room.width() - 1 : direction == 3 ? 0 : room.width() / 2;
int z = direction == 2 ? room.depth() - 1 : direction == 0 ? 0 : room.depth() / 2;
targets.add(new Target("opening_" + DIRECTIONS[direction].getName(), x, 2, z));
}
return List.copyOf(targets);
}
public static void render(RuinsPlan17.Room room, LostCityRenderer.BlockWriter output) {
var b = new Builder(room, output);
b.shell();
switch (room.kind()) {
case ACCESS -> b.access();
case CORRIDOR -> b.corridor();
case MINE_GALLERY -> b.mine();
case WORKSHOP -> b.workshop();
case STORE -> b.store();
case TILED_HALL -> b.tiledHall();
case ARCHIVE -> b.archive();
case SERVICE_HALL -> b.service();
case PUMP_HALL -> b.pumps();
case CONTROL_ROOM -> b.controls();
}
if (room.kind() != RuinsPlan17.Kind.ACCESS) b.lights();
b.openings();
for (var support : room.supports()) for (int y = support.minY(); y <= support.maxY(); y++)
output.set(support.x() - room.minX(), y - room.minY(), support.z() - room.minZ(),
y == support.minY() ? Blocks.SMOOTH_STONE.defaultBlockState() : Blocks.STONE_BRICKS.defaultBlockState());
}
private static boolean turn(RuinsPlan17.Room room) { return (room.seed() & 1L) != 0; }
private static Target decorTarget(RuinsPlan17.Room room, String name, int x, int y, int z) {
return new Target(name, turn(room) ? room.width() - 1 - x : x, y, turn(room) ? room.depth() - 1 - z : z);
}
private static final class Builder {
final RuinsPlan17.Room room;
final LostCityRenderer.BlockWriter out;
final int w, h, d, cx, cz;
final boolean reverse;
Builder(RuinsPlan17.Room room, LostCityRenderer.BlockWriter out) {
this.room = room; this.out = out; w = room.width(); h = room.height(); d = room.depth();
cx = w / 2; cz = d / 2; reverse = turn(room);
}
void set(int x, int y, int z, BlockState state) { out.set(x, y, z, state); }
void set(int x, int y, int z, net.minecraft.world.level.block.Block block) { set(x, y, z, block.defaultBlockState()); }
void decor(int x, int y, int z, BlockState state) {
out.set(reverse ? w - 1 - x : x, y, reverse ? d - 1 - z : z,
reverse ? state.rotate(Rotation.CLOCKWISE_180) : state);
}
void decor(int x, int y, int z, net.minecraft.world.level.block.Block block) { decor(x, y, z, block.defaultBlockState()); }
boolean crossing(int x, int z) { return Math.abs(x - cx) <= 1 || Math.abs(z - cz) <= 1; }
void shell() {
for (int y = 0; y < h; y++) for (int z = 0; z < d; z++) for (int x = 0; x < w; x++) {
BlockState state;
boolean wall = x == 0 || x == w - 1 || z == 0 || z == d - 1;
if (y == 0) state = Blocks.STONE.defaultBlockState();
else if (y == 1) state = floor(x, z);
else if (wall || y == h - 1) state = LostCityPalette.masonry(room.seed(), x, y, z);
else state = Blocks.AIR.defaultBlockState();
set(x, y, z, state);
}
// A continuous ceiling cornice and stone thresholds remain intact despite the weathering.
for (int x = 0; x < w; x++) for (int z = 0; z < d; z++) if (x == 0 || x == w - 1 || z == 0 || z == d - 1)
set(x, h - 2, z, Blocks.SMOOTH_STONE);
}
BlockState floor(int x, int z) {
return switch (room.kind()) {
case MINE_GALLERY -> (x == cx ? Blocks.DARK_OAK_PLANKS : (x + z) % 5 == 0 ? Blocks.ANDESITE : Blocks.STONE).defaultBlockState();
case WORKSHOP, STORE -> (x == 1 || x == w - 2 || z == 1 || z == d - 2
? Blocks.DARK_OAK_PLANKS : Blocks.SMOOTH_STONE).defaultBlockState();
case TILED_HALL, ARCHIVE -> ((x / 2 + z / 2) % 2 == 0 ? Blocks.CALCITE : Blocks.POLISHED_ANDESITE).defaultBlockState();
case SERVICE_HALL, PUMP_HALL, CONTROL_ROOM -> (x == cx || z == cz ? Blocks.POLISHED_ANDESITE : Blocks.SMOOTH_STONE).defaultBlockState();
default -> LostCityPalette.pavement(room.seed(), x, z);
};
}
void access() {
int exit = Integer.numberOfTrailingZeros(room.openings() == 0 ? 1 : room.openings());
int wall = (exit + 2) & 3;
int x = wall == 1 ? w - 2 : wall == 3 ? 1 : cx;
int z = wall == 2 ? d - 2 : wall == 0 ? 1 : cz;
Direction facing = DIRECTIONS[wall].getOpposite();
if ((room.openings() & 1 << wall) != 0) {
// A branching shaft cannot attach its lowest ladder to another open doorway.
// This corner post leaves all three doorway cells and the central crossing usable.
x = 1; z = 2; facing = Direction.SOUTH;
for (int y = 2; y <= h - 3; y++) set(1, y, 1, Blocks.STONE_BRICKS);
}
BlockState ladder = Blocks.LADDER.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, facing);
for (int y = 2; y <= h - 3; y++) set(x, y, z, ladder);
for (int zz = 0; zz < d; zz++) for (int xx = 0; xx < w; xx++) {
set(xx, h - 3, zz, xx == x && zz == z ? ladder : Blocks.SMOOTH_STONE.defaultBlockState());
set(xx, h - 2, zz, Blocks.AIR); set(xx, h - 1, zz, Blocks.AIR);
}
set(x, h - 2, z, Blocks.DARK_OAK_TRAPDOOR.defaultBlockState()
.setValue(BlockStateProperties.HORIZONTAL_FACING, facing));
}
void corridor() {
boolean alongX = w > d;
int length = alongX ? w : d;
for (int n = 2; n < length - 1; n += 4) {
if (alongX) {
for (int y = 2; y <= h - 1; y++) { set(n, y, 0, Blocks.POLISHED_ANDESITE); set(n, y, d - 1, Blocks.POLISHED_ANDESITE); }
} else for (int y = 2; y <= h - 1; y++) { set(0, y, n, Blocks.POLISHED_ANDESITE); set(w - 1, y, n, Blocks.POLISHED_ANDESITE); }
}
}
void mine() {
for (int z : new int[] {2, d - 3}) {
for (int x : new int[] {2, w - 3}) for (int y = 2; y <= h - 2; y++) decor(x, y, z, Blocks.STRIPPED_OAK_LOG);
for (int x = 2; x <= w - 3; x++) decor(x, h - 2, z, Blocks.STRIPPED_OAK_LOG.defaultBlockState()
.setValue(BlockStateProperties.AXIS, Direction.Axis.X));
}
for (int z = 1; z < d - 1; z++) set(cx, 2, z, Blocks.RAIL);
// A short second siding ends at a buffer; the main track and cross passage remain open.
if (w >= 13) {
for (int z = 3; z < d - 2; z++) decor(w - 5, 2, z, Blocks.RAIL);
decor(w - 5, 2, d - 2, Blocks.DARK_OAK_FENCE);
}
decor(1, 2, 1, Blocks.BARREL);
decor(w - 2, 2, d - 2, Blocks.ANDESITE);
if (LostCityPalette.roll(room.seed(), 1, 0, 0, 2) == 0) decor(w - 2, 3, d - 2, Blocks.COBBLESTONE);
}
void workshop() {
decor(1, 2, 1, Blocks.CRAFTING_TABLE);
decor(2, 2, 1, Blocks.FURNACE.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.SOUTH));
decor(1, 2, d - 3, Blocks.GRINDSTONE.defaultBlockState()
.setValue(BlockStateProperties.ATTACH_FACE, AttachFace.FLOOR)
.setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.EAST));
decor(w - 2, 2, 1, Blocks.STONECUTTER.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.WEST));
decor(1, 2, d - 2, Blocks.CAULDRON);
decor(2, 2, d - 2, Blocks.ANVIL);
// Two grounded posts and a small planked deck preserve the trace of unfinished repairs.
for (int x = w - 3; x <= w - 2; x++) {
decor(x, 2, d - 3, Blocks.STRIPPED_OAK_LOG);
for (int z = d - 3; z <= d - 2; z++) decor(x, 3, z, Blocks.DARK_OAK_PLANKS);
}
for (int y = 2; y <= 3; y++) decor(w - 3, y, d - 4, Blocks.LADDER.defaultBlockState()
.setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.NORTH));
decor(w - 2, 2, d - 2, Blocks.BARREL);
}
void store() {
for (int z = 1; z < d - 1; z++) if (!crossing(1, z)) {
decor(1, 2, z, Blocks.BARREL);
if ((z & 1) == 0) decor(1, 3, z, Blocks.BARREL);
decor(1, 4, z, Blocks.DARK_OAK_SLAB);
}
for (int x = cx + 2; x <= w - 2; x++) decor(x, 2, 1, Blocks.DARK_OAK_PLANKS);
decor(w - 2, 3, 1, Blocks.BARREL);
loot(w - 2, 2, d - 2, BuiltInLootTables.VILLAGE_PLAINS_HOUSE);
}
void tiledHall() {
// Repeated columns are interrupted by complete, centered cross passages.
for (int x : new int[] {2, w - 3}) for (int z : new int[] {2, d - 3})
for (int y = 2; y < h - 1; y++) decor(x, y, z, y == 2 || y == h - 2 ? Blocks.SMOOTH_STONE : Blocks.CALCITE);
if (w >= 13) for (int x : new int[] {3, w - 4}) for (int z = 1; z <= d - 2; z++)
if (Math.abs(z - cz) > 1) for (int y = 2; y < h - 1; y++)
decor(x, y, z, y == h - 2 ? Blocks.POLISHED_ANDESITE : Blocks.SMOOTH_STONE);
for (int x = cx - 1; x <= cx + 1; x++) {
decor(x, h - 2, 1, Blocks.POLISHED_ANDESITE);
decor(x, h - 2, d - 2, Blocks.POLISHED_ANDESITE);
}
// Mismatched surviving benches give these physically reachable side niches a former use.
decor(1, 2, 1, seat(Direction.WEST));
decor(w - 2, 2, 1, seat(Direction.EAST));
}
void archive() {
for (int z = 1; z <= d - 2; z++) if (Math.abs(z - cz) > 1)
for (int y = 2; y <= 4; y++) decor(w - 2, y, z,
y == 4 && (z & 1) == 0 ? Blocks.DARK_OAK_PLANKS : Blocks.BOOKSHELF);
decor(1, 2, 1, Blocks.BOOKSHELF);
decor(1, 2, 2, Blocks.LECTERN.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.EAST));
decor(1, 2, d - 2, Blocks.CARTOGRAPHY_TABLE);
decor(2, 2, d - 3, Blocks.BARREL);
loot(1, 2, d - 3, BuiltInLootTables.VILLAGE_CARTOGRAPHER);
}
void service() {
for (int z = 1; z < d - 1; z++) if (Math.abs(z - cz) > 1) {
decor(1, 2, z, Blocks.POLISHED_ANDESITE);
decor(1, 3, z, Blocks.IRON_BARS);
}
for (int z : new int[] {1, d - 2}) for (int x = 1; x < w - 1; x++)
decor(x, h - 2, z, Blocks.STRIPPED_OAK_LOG.defaultBlockState().setValue(BlockStateProperties.AXIS, Direction.Axis.X));
decor(w - 2, 2, 1, Blocks.BARREL);
decor(w - 2, 2, d - 2, Blocks.CRAFTING_TABLE);
decor(2, 2, d - 2, seat(Direction.SOUTH));
}
void pumps() {
for (int z : new int[] {1, d - 2}) {
decor(1, 2, z, Blocks.DISPENSER.defaultBlockState().setValue(BlockStateProperties.FACING, Direction.EAST));
decor(2, 2, z, Blocks.WATER_CAULDRON.defaultBlockState().setValue(BlockStateProperties.LEVEL_CAULDRON, 3));
decor(1, 3, z, Blocks.POLISHED_ANDESITE);
decor(1, 4, z, Blocks.IRON_BARS);
decor(w - 2, 2, z, Blocks.CAULDRON);
}
for (int z = 1; z <= d - 2; z++) decor(1, h - 2, z, Blocks.POLISHED_ANDESITE);
decor(w - 2, 2, 2, Blocks.SMOOTH_STONE);
decor(w - 2, 3, 2, Blocks.LEVER.defaultBlockState().setValue(BlockStateProperties.ATTACH_FACE, AttachFace.FLOOR)
.setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.WEST));
}
void controls() {
for (int x = 1; x <= cx - 2; x++) {
decor(x, 2, 1, Blocks.SMOOTH_STONE);
decor(x, 3, 1, Blocks.LEVER.defaultBlockState().setValue(BlockStateProperties.ATTACH_FACE, AttachFace.FLOOR)
.setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.SOUTH));
decor(x, 3, 0, Blocks.REDSTONE_LAMP);
}
decor(1, 2, 2, seat(Direction.WEST));
decor(w - 2, 2, 1, Blocks.CRAFTING_TABLE);
decor(w - 2, 2, d - 2, Blocks.BARREL);
// An inspection window remains backed by a complete wall: there is no opening into unverified rock or water.
for (int z = cz + 2; z < d - 1; z++) decor(0, 3, z, Blocks.GLASS);
decor(1, 2, d - 2, Blocks.CARTOGRAPHY_TABLE);
}
BlockState seat(Direction back) { return Blocks.DARK_OAK_STAIRS.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, back); }
void lights() {
// Every lantern hangs directly from the full masonry ceiling; never from a bottom slab.
for (int[] p : new int[][] {{1, 1}, {w - 2, d - 2}})
set(p[0], h - 2, p[1], Blocks.LANTERN.defaultBlockState().setValue(BlockStateProperties.HANGING, true));
}
void openings() {
for (int direction = 0; direction < 4; direction++) if ((room.openings() & 1 << direction) != 0)
for (int side = -1; side <= 1; side++) {
int x = direction == 1 ? w - 1 : direction == 3 ? 0 : cx + side;
int z = direction == 2 ? d - 1 : direction == 0 ? 0 : cz + side;
set(x, 1, z, Blocks.SMOOTH_STONE);
for (int y = 2; y <= 4; y++) set(x, y, z, Blocks.AIR);
}
}
void loot(int x, int y, int z, net.minecraft.resources.ResourceKey<net.minecraft.world.level.storage.loot.LootTable> table) {
out.chest(reverse ? w - 1 - x : x, y, reverse ? d - 1 - z : z, table, room.seed() ^ 0x17A6C41L);
}
}
}
@@ -0,0 +1,39 @@
package fr.koka.sanctuary.worldgen.ruins;
import com.mojang.serialization.MapCodec;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import java.util.Optional;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructureType;
/** One start contains every planned experimental network sharing its origin chunk. */
public final class RuinsStructure17 extends Structure {
public static final MapCodec<RuinsStructure17> CODEC = simpleCodec(RuinsStructure17::new);
public RuinsStructure17(StructureSettings settings) { super(settings); }
@Override protected Optional<GenerationStub> findGenerationPoint(GenerationContext context) {
if (!(context.chunkGenerator() instanceof SanctuaryChunkGenerator generator)
|| generator.rootGeneration() != 17 || !generator.experimentalStructures()) return Optional.empty();
var island = generator.islandForChunk(context.chunkPos().x(), context.chunkPos().z());
if (island == null || !island.origin() || island.generation() != 17) return Optional.empty();
var networks = generator.ruinsPlan17().networks().stream()
.filter(n -> n.startChunkX() == context.chunkPos().x() && n.startChunkZ() == context.chunkPos().z()).toList();
if (networks.isEmpty()) return Optional.empty();
for (var network : networks) for (var room : network.rooms()) {
var bounds = RuinsPiece17.bounds(room);
if (Math.abs((long) Math.floorDiv(bounds.minX(), 16) - network.startChunkX()) > 8
|| Math.abs((long) Math.floorDiv(bounds.maxX(), 16) - network.startChunkX()) > 8
|| Math.abs((long) Math.floorDiv(bounds.minZ(), 16) - network.startChunkZ()) > 8
|| Math.abs((long) Math.floorDiv(bounds.maxZ(), 16) - network.startChunkZ()) > 8)
throw new IllegalStateException("Forgotten network exceeds native reference range: " + network.id());
}
return Optional.of(new GenerationStub(RuinsStructures17.locator(networks.getFirst()), pieces -> {
// Shafts come last so their surface hatches cannot be filled by a connected room.
networks.forEach(n -> n.rooms().stream().filter(r -> r.kind() != RuinsPlan17.Kind.ACCESS)
.forEach(r -> pieces.addPiece(new RuinsPiece17(r))));
networks.forEach(n -> n.rooms().stream().filter(r -> r.kind() == RuinsPlan17.Kind.ACCESS)
.forEach(r -> pieces.addPiece(new RuinsPiece17(r))));
}));
}
@Override public StructureType<?> type() { return RuinsStructures17.TYPE; }
}
@@ -0,0 +1,111 @@
package fr.koka.sanctuary.worldgen.ruins;
import com.mojang.datafixers.util.Pair;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.expansion.ExpansionChunkGenerator;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderSet;
import net.minecraft.core.Registry;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.StructureManager;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.StructureType;
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceType;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
/** Experimental generation17 uses independent native starts, references and saved room pieces. */
public final class RuinsStructures17 {
public static final ResourceKey<Structure> KEY = ResourceKey.create(Registries.STRUCTURE, SanctuaryMod.id("forgotten_networks"));
public static final StructureType<RuinsStructure17> TYPE = Registry.register(BuiltInRegistries.STRUCTURE_TYPE,
SanctuaryMod.id("forgotten_networks_v17"), () -> RuinsStructure17.CODEC);
public static final StructurePieceType PIECE = Registry.register(BuiltInRegistries.STRUCTURE_PIECE,
SanctuaryMod.id("forgotten_room_v17"), RuinsPiece17::new);
private static final ThreadLocal<Integer> PLACEMENT_DEPTH = ThreadLocal.withInitial(() -> 0);
private RuinsStructures17() {}
public static void register() { /* Initialize serializers before the dynamic structure registry is read. */ }
public static boolean isPlacing() { return PLACEMENT_DEPTH.get() > 0; }
static void placing(Runnable renderer) {
int previous = PLACEMENT_DEPTH.get();
PLACEMENT_DEPTH.set(previous + 1);
try { renderer.run(); }
finally {
if (previous == 0) PLACEMENT_DEPTH.remove();
else PLACEMENT_DEPTH.set(previous);
}
}
public static void generate(ExpansionChunkGenerator generator, RegistryAccess registries,
ChunkGeneratorStructureState state, StructureManager manager, ChunkAccess chunk,
StructureTemplateManager templates, ResourceKey<Level> level) {
if (!Level.OVERWORLD.equals(level) || !manager.shouldGenerateStructures()
|| !(generator instanceof SanctuaryChunkGenerator sanctuary) || generator.rootGeneration() != 17
|| !sanctuary.experimentalStructures()) return;
var island = generator.islandForChunk(chunk.getPos().x(), chunk.getPos().z());
if (island == null || !island.origin() || island.generation() != 17) return;
var plan = sanctuary.ruinsPlan17();
if (plan.networks().stream().noneMatch(n -> n.startChunkX() == chunk.getPos().x()
&& n.startChunkZ() == chunk.getPos().z())) return;
Holder<Structure> holder = registries.lookupOrThrow(Registries.STRUCTURE).getOrThrow(KEY);
Structure structure = holder.value();
StructureStart previous = manager.getStartForStructure(structure, chunk);
if (previous != null && previous.isValid()) return;
var random = generator.randomState(island);
var sampler = random.createClimateSampler(SamplerContext.builder().enableCaches().build());
var start = structure.generate(holder, level, registries, generator, generator.getBiomeSource(), sampler,
random, templates, island.seed(), chunk.getPos(), previous == null ? 0 : previous.getReferences(),
chunk, structure.biomes()::contains);
if (start.isValid()) manager.setStartForStructure(structure, start, chunk);
}
/** Starts are loaded at STRUCTURE_STARTS; locating never creates an expansion or rebuilds an existing start. */
public static Pair<BlockPos, Holder<Structure>> findNearest(ServerLevel level, HolderSet<Structure> structures,
BlockPos origin, int radius, boolean skipReferenced) {
if (!Level.OVERWORLD.equals(level.dimension())
|| !(level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator generator)
|| generator.rootGeneration() != 17 || !generator.experimentalStructures()
|| !level.structureManager().shouldGenerateStructures()) return null;
Holder<Structure> holder = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(KEY);
if (!structures.contains(holder)) return null;
var candidates = new ArrayList<RuinsPlan17.Network>();
int originChunkX = origin.getX() >> 4, originChunkZ = origin.getZ() >> 4;
for (var network : generator.ruinsPlan17().networks()) {
var island = generator.islandForChunk(network.startChunkX(), network.startChunkZ());
if (island == null || !island.origin() || island.generation() != 17 || !island.status().equals("ready")) continue;
if (Math.abs((long) network.startChunkX() - originChunkX) <= radius
&& Math.abs((long) network.startChunkZ() - originChunkZ) <= radius) candidates.add(network);
}
candidates.sort(Comparator.comparingDouble(n -> origin.distSqr(locator(n))));
var visited = new HashSet<Long>();
for (var network : candidates) {
long key = ((long) network.startChunkX() << 32) ^ (network.startChunkZ() & 0xffffffffL);
if (!visited.add(key)) continue;
var chunk = level.getChunk(network.startChunkX(), network.startChunkZ(), ChunkStatus.STRUCTURE_STARTS, true);
StructureStart start = level.structureManager().getStartForStructure(holder.value(), chunk);
if (start == null || !start.isValid() || skipReferenced && !start.canBeReferenced()) continue;
if (skipReferenced) level.structureManager().addReference(start);
var entrance = start.getPieces().stream().filter(p -> p instanceof RuinsPiece17 room
&& room.room().kind() == RuinsPlan17.Kind.ACCESS).findFirst().orElse(start.getPieces().getFirst());
return Pair.of(entrance.getLocatorPosition(), holder);
}
return null;
}
static BlockPos locator(RuinsPlan17.Network network) {
var room = network.rooms().stream().filter(r -> r.kind() == RuinsPlan17.Kind.ACCESS).findFirst()
.orElseGet(() -> network.rooms().getFirst());
return RuinsPiece17.locator(room);
}
}
@@ -0,0 +1,60 @@
package fr.koka.sanctuary.worldgen.ruins;
import fr.koka.sanctuary.expansion.ExpansionIsland;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Predicate;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.LevelHeightAccessor;
import net.minecraft.world.level.NoiseColumn;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import net.minecraft.world.level.levelgen.RandomState;
/** Read-only, bounded native-noise cache. Unknown columns always reject construction. */
public final class RuinsTerrain17 implements RuinsPlanner17.Terrain {
public static final int MAX_COLUMNS = 5_000;
private final ExpansionIsland island;
private final NoiseBasedChunkGenerator generator;
private final RandomState random;
private final LevelHeightAccessor height;
private final Predicate<BlockPos> protection;
private final Map<Long, NoiseColumn> columns = new HashMap<>();
public RuinsTerrain17(ExpansionIsland island, NoiseBasedChunkGenerator generator, RandomState random,
LevelHeightAccessor height, Predicate<BlockPos> protection) {
this.island = island; this.generator = generator; this.random = random;
this.height = height; this.protection = protection;
}
private NoiseColumn column(int x, int z) {
if (!owns(x, z)) return null;
long key = ((long) x << 32) ^ (z & 0xffffffffL);
var found = columns.get(key);
if (found != null) return found;
if (columns.size() >= MAX_COLUMNS) return null;
var result = generator.getBaseColumn(x, z, height, random);
columns.put(key, result);
return result;
}
@Override public boolean owns(int x, int z) { return island.contains(x, z); }
@Override public boolean solid(int x, int y, int z) {
if (y < 0 || y >= 384) return false;
var column = column(x, z);
return column != null && column.getBlock(y).isSolid() && column.getBlock(y).getFluidState().isEmpty();
}
@Override public boolean protectedAt(int x, int y, int z) {
if (y < 0 || y >= 384) return true;
var column = column(x, z);
return column == null || !column.getBlock(y).getFluidState().isEmpty()
|| protection.test(new BlockPos(x, y, z));
}
@Override public int surfaceY(int x, int z) {
var column = column(x, z);
if (column == null) return -1;
for (int y = 380; y >= 1; y--) {
var state = column.getBlock(y);
if (!state.isAir()) return state.isSolid() && state.getFluidState().isEmpty() ? y : -1;
}
return -1;
}
public int columnsUsed() { return columns.size(); }
}
@@ -106,7 +106,7 @@
"sanctuary.expansion.error.journal_unavailable": "The expansion journal is unavailable. Preparation has stopped; fix storage and reload the world before resuming.",
"sanctuary.expansion.error.invalid_id": "An identifier must start with a lowercase letter and contain at most 32 lowercase letters, digits or underscores.",
"sanctuary.expansion.error.invalid_climate": "Unknown climate. Use autocomplete to choose a profile.",
"sanctuary.expansion.error.invalid_diameter": "Allowed diameters are 64, 128, 256 and 512 blocks.",
"sanctuary.expansion.error.invalid_diameter": "Supported diameters are 64, 128, 256, 512 and 1024 blocks (1024 requires generation 14 or 15).",
"sanctuary.expansion.error.invalid_relief": "Unknown relief: natural, peaks, canyon or volcano.",
"sanctuary.expansion.error.invalid_distance": "Distance must be between 0 and 100000 blocks. 0 uses automatic placement.",
"sanctuary.expansion.error.no_free_space": "No free location found within the search limits. Try a greater distance or a different parent. No existing terrain was changed.",
@@ -117,5 +117,15 @@
"sanctuary.world_options.size.small": "Small",
"sanctuary.world_options.size.medium": "Medium",
"sanctuary.world_options.size.large": "Large",
"sanctuary.world_options.diameter": "Nominal diameter: %s blocks. The terrain and seed shape the outline."
"sanctuary.world_options.diameter": "Nominal diameter: %s blocks. The terrain and seed shape the outline.",
"biome.sanctuary.lost_city": "Lost City",
"structure.sanctuary.lost_city": "Ruined district",
"sanctuary.expansion.climate.lost_city": "Lost City",
"sanctuary.expansion.error.city_generation_required": "This profile or size requires a generation 14 or 15 Sanctuary world. This save keeps its original generation.",
"sanctuary.expansion.reuse_reload": "Expansion %s reserved in previously generated void. Close and reopen the world to begin preparation.",
"sanctuary.expansion.error.reload_required": "This expansion is waiting for the world to reopen before reusing verified void chunks.",
"sanctuary.expansion.error.void_reuse_incomplete": "Void reuse is incomplete. The reservation is retained; check the server log.",
"sanctuary.world_options.experimental": "Experimental structures",
"sanctuary.world_options.experimental_hint": "Mines, strange rooms and abandoned facilities. This choice is saved with the new world.",
"structure.sanctuary.forgotten_networks": "Forgotten networks"
}
@@ -106,7 +106,7 @@
"sanctuary.expansion.error.journal_unavailable": "Le journal dexpansion est indisponible. La préparation est arrêtée ; corrigez le stockage puis rechargez le monde avant de reprendre.",
"sanctuary.expansion.error.invalid_id": "Lidentifiant doit commencer par une lettre minuscule et contenir au plus 32 lettres minuscules, chiffres ou tirets bas.",
"sanctuary.expansion.error.invalid_climate": "Climat inconnu. Utiliser lautocomplétion pour choisir un profil.",
"sanctuary.expansion.error.invalid_diameter": "Les diamètres autorisés sont 64, 128, 256 et 512 blocs.",
"sanctuary.expansion.error.invalid_diameter": "Les diamètres autorisés sont 64, 128, 256, 512 et 1024 blocs (1024 à partir de la génération 14).",
"sanctuary.expansion.error.invalid_relief": "Relief inconnu : natural, peaks, canyon ou volcano.",
"sanctuary.expansion.error.invalid_distance": "La distance doit être comprise entre 0 et 100000 blocs. 0 utilise le placement automatique.",
"sanctuary.expansion.error.no_free_space": "Aucun emplacement libre trouvé dans les limites de recherche. Réessayer avec une distance plus grande ou un autre parent. Aucun terrain existant na été modifié.",
@@ -117,5 +117,15 @@
"sanctuary.world_options.size.small": "Petit",
"sanctuary.world_options.size.medium": "Moyen",
"sanctuary.world_options.size.large": "Grand",
"sanctuary.world_options.diameter": "Diamètre nominal : %s blocs. Le relief et la graine dessinent le contour."
"sanctuary.world_options.diameter": "Diamètre nominal : %s blocs. Le relief et la graine dessinent le contour.",
"biome.sanctuary.lost_city": "Lost City",
"structure.sanctuary.lost_city": "Quartier en ruine",
"sanctuary.expansion.climate.lost_city": "Lost City",
"sanctuary.expansion.error.city_generation_required": "Ce profil ou cette taille demande un monde Sanctuary de génération 14 ou 15. Cette sauvegarde conserve sa génération.",
"sanctuary.expansion.reuse_reload": "Expansion %s réservée dans du vide déjà généré. Fermez puis rouvrez le monde pour démarrer sa préparation.",
"sanctuary.expansion.error.reload_required": "Cette expansion attend la réouverture du monde pour réutiliser les chunks de vide vérifiés.",
"sanctuary.expansion.error.void_reuse_incomplete": "La réutilisation du vide est incomplète. La réservation est conservée ; consultez le journal du serveur.",
"sanctuary.world_options.experimental": "Structures expérimentales",
"sanctuary.world_options.experimental_hint": "Mines, salles étranges et installations abandonnées. Ce choix reste attaché au nouveau monde.",
"structure.sanctuary.forgotten_networks": "Réseaux oubliés"
}
@@ -0,0 +1,55 @@
{
"replace": false,
"values": [
"#minecraft:is_overworld",
"sanctuary:cavern_bamboo_grove",
"sanctuary:cavern_birch_forest",
"sanctuary:cavern_clearing",
"sanctuary:cavern_dark_grove",
"sanctuary:cavern_dripstone_caves",
"sanctuary:cavern_dry_woodland",
"sanctuary:cavern_lush_caves",
"sanctuary:cavern_oak_forest",
"sanctuary:cavern_rocky_heath",
"sanctuary:cavern_sulfur_depths",
"sanctuary:layered_flower_meadow",
"sanctuary:layered_lower_grove",
"sanctuary:layered_meadow",
"sanctuary:layered_mossy_terraces",
"sanctuary:layered_rocky_terraces",
"sanctuary:lost_city",
"sanctuary:population_bamboo_grove",
"sanctuary:population_birch_forest",
"sanctuary:population_clearing",
"sanctuary:population_dark_grove",
"sanctuary:population_dripstone_caves",
"sanctuary:population_dry_woodland",
"sanctuary:population_lush_caves",
"sanctuary:population_oak_forest",
"sanctuary:population_rocky_heath",
"sanctuary:population_sulfur_depths",
"sanctuary:rift_bamboo_grove",
"sanctuary:rift_birch_forest",
"sanctuary:rift_clearing",
"sanctuary:rift_dark_grove",
"sanctuary:rift_dry_woodland",
"sanctuary:rift_oak_forest",
"sanctuary:rift_rocky_heath",
"sanctuary:rift_sulfur_depths",
"sanctuary:starter_forest",
"sanctuary:starter_forest_hydrology",
"sanctuary:temperate_birch_forest",
"sanctuary:temperate_dappled_forest",
"sanctuary:temperate_flower_forest",
"sanctuary:temperate_forest",
"sanctuary:temperate_plains",
"sanctuary:woodland_bamboo_grove",
"sanctuary:woodland_birch_forest",
"sanctuary:woodland_clearing",
"sanctuary:woodland_dark_grove",
"sanctuary:woodland_dry_woodland",
"sanctuary:woodland_oak_forest",
"sanctuary:woodland_rocky_heath",
"sanctuary:woodland_sulfur_depths"
]
}
@@ -0,0 +1,84 @@
{
"attributes": {
"minecraft:audio/background_music": {
"default": {
"max_delay": 24000,
"min_delay": 12000,
"sound": "minecraft:music.overworld.meadow"
}
},
"minecraft:gameplay/natural_mob_spawns": {
"argument": {
"spawn_costs": {},
"spawns_by_category": {
"monster": [
{
"type": "minecraft:zombie",
"weight": 100,
"count": {
"type": "minecraft:uniform",
"min_inclusive": 2,
"max_inclusive": 4
}
}
],
"ambient": [
{
"type": "minecraft:bat",
"weight": 10,
"count": 4
}
],
"creature": [],
"axolotls": [],
"misc": [],
"water_ambient": [],
"water_creature": [],
"underground_water_creature": []
}
},
"modifier": "overlay"
},
"minecraft:visual/sky_color": "#79a6ff"
},
"carvers": [],
"downfall": 0.75,
"effects": {
"water_color": "#3f76e4",
"grass_color": "#81936b",
"foliage_color": "#768663",
"dry_foliage_color": "#b9a766"
},
"features": [
[],
[],
[],
[],
[],
[],
[
"sanctuary:rift_ore_coal",
"sanctuary:rift_ore_iron",
"sanctuary:rift_ore_copper",
"sanctuary:rift_ore_gold",
"sanctuary:rift_ore_redstone",
"sanctuary:rift_ore_lapis",
"sanctuary:rift_ore_diamond",
"sanctuary:rift_ore_emerald",
"sanctuary:rift_ore_coal_surface",
"sanctuary:rift_ore_iron_surface",
"sanctuary:rift_ore_copper_surface",
"sanctuary:rift_ore_coal_ledges",
"sanctuary:rift_ore_iron_ledges",
"sanctuary:rift_ore_copper_ledges"
],
[],
[],
[],
[
"minecraft:freeze_top_layer"
]
],
"has_precipitation": true,
"temperature": 0.7
}
@@ -0,0 +1,7 @@
{
"type": "sanctuary:forgotten_networks_v17",
"biomes": "#sanctuary:has_structure/forgotten_networks",
"step": "underground_structures",
"terrain_adaptation": "none",
"spawn_overrides": {}
}
@@ -0,0 +1,22 @@
{
"type": "sanctuary:lost_city",
"biomes": "sanctuary:lost_city",
"step": "surface_structures",
"terrain_adaptation": "none",
"spawn_overrides": {
"monster": {
"bounding_box": "piece",
"spawns": [
{
"type": "minecraft:zombie",
"weight": 100,
"count": {
"type": "minecraft:uniform",
"min_inclusive": 2,
"max_inclusive": 4
}
}
]
}
}
}
@@ -3,7 +3,7 @@
"minecraft:overworld": {
"type": "sanctuary:sanctuary_384",
"generator": {
"type": "sanctuary:island_v13",
"type": "sanctuary:island_v17",
"biome_source": {
"biomes": [
"minecraft:the_void",
@@ -49,7 +49,8 @@
"sanctuary:population_bamboo_grove",
"sanctuary:population_sulfur_depths",
"sanctuary:population_lush_caves",
"sanctuary:population_dripstone_caves"
"sanctuary:population_dripstone_caves",
"sanctuary:lost_city"
]
},
"initial_diameter": 724
@@ -0,0 +1,80 @@
{
"dimensions": {
"minecraft:overworld": {
"type": "sanctuary:sanctuary_384",
"generator": {
"type": "sanctuary:island_v13",
"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",
"sanctuary:population_oak_forest",
"sanctuary:population_birch_forest",
"sanctuary:population_clearing",
"sanctuary:population_dry_woodland",
"sanctuary:population_rocky_heath",
"sanctuary:population_dark_grove",
"sanctuary:population_bamboo_grove",
"sanctuary:population_sulfur_depths",
"sanctuary:population_lush_caves",
"sanctuary:population_dripstone_caves"
]
},
"initial_diameter": 724
}
},
"minecraft:the_end": {
"type": "minecraft:the_end",
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "minecraft:the_end"
},
"settings": "minecraft:end"
}
},
"minecraft:the_nether": {
"type": "minecraft:the_nether",
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "minecraft:multi_noise",
"preset": "minecraft:nether"
},
"settings": "minecraft:nether"
}
}
}
}
@@ -0,0 +1,81 @@
{
"dimensions": {
"minecraft:overworld": {
"type": "sanctuary:sanctuary_384",
"generator": {
"type": "sanctuary:island_v14",
"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",
"sanctuary:population_oak_forest",
"sanctuary:population_birch_forest",
"sanctuary:population_clearing",
"sanctuary:population_dry_woodland",
"sanctuary:population_rocky_heath",
"sanctuary:population_dark_grove",
"sanctuary:population_bamboo_grove",
"sanctuary:population_sulfur_depths",
"sanctuary:population_lush_caves",
"sanctuary:population_dripstone_caves",
"sanctuary:lost_city"
]
},
"initial_diameter": 724
}
},
"minecraft:the_end": {
"type": "minecraft:the_end",
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "minecraft:the_end"
},
"settings": "minecraft:end"
}
},
"minecraft:the_nether": {
"type": "minecraft:the_nether",
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "minecraft:multi_noise",
"preset": "minecraft:nether"
},
"settings": "minecraft:nether"
}
}
}
}
@@ -0,0 +1,81 @@
{
"dimensions": {
"minecraft:overworld": {
"type": "sanctuary:sanctuary_384",
"generator": {
"type": "sanctuary:island_v15",
"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",
"sanctuary:population_oak_forest",
"sanctuary:population_birch_forest",
"sanctuary:population_clearing",
"sanctuary:population_dry_woodland",
"sanctuary:population_rocky_heath",
"sanctuary:population_dark_grove",
"sanctuary:population_bamboo_grove",
"sanctuary:population_sulfur_depths",
"sanctuary:population_lush_caves",
"sanctuary:population_dripstone_caves",
"sanctuary:lost_city"
]
},
"initial_diameter": 724
}
},
"minecraft:the_end": {
"type": "minecraft:the_end",
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "minecraft:the_end"
},
"settings": "minecraft:end"
}
},
"minecraft:the_nether": {
"type": "minecraft:the_nether",
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "minecraft:multi_noise",
"preset": "minecraft:nether"
},
"settings": "minecraft:nether"
}
}
}
}
@@ -0,0 +1,81 @@
{
"dimensions": {
"minecraft:overworld": {
"type": "sanctuary:sanctuary_384",
"generator": {
"type": "sanctuary:island_v16",
"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",
"sanctuary:population_oak_forest",
"sanctuary:population_birch_forest",
"sanctuary:population_clearing",
"sanctuary:population_dry_woodland",
"sanctuary:population_rocky_heath",
"sanctuary:population_dark_grove",
"sanctuary:population_bamboo_grove",
"sanctuary:population_sulfur_depths",
"sanctuary:population_lush_caves",
"sanctuary:population_dripstone_caves",
"sanctuary:lost_city"
]
},
"initial_diameter": 724
}
},
"minecraft:the_end": {
"type": "minecraft:the_end",
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "minecraft:the_end"
},
"settings": "minecraft:end"
}
},
"minecraft:the_nether": {
"type": "minecraft:the_nether",
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "minecraft:multi_noise",
"preset": "minecraft:nether"
},
"settings": "minecraft:nether"
}
}
}
}
@@ -11,7 +11,15 @@
"WoodlandSpringOutletMixin",
"RiftSpringOutletMixin",
"CavernSpringOutletMixin",
"UnifiedDecorationProtectionMixin"
"UnifiedDecorationProtectionMixin",
"ExpansionVoidServerLevelAccess",
"ExpansionVoidEntityManagerAccess",
"ExpansionVoidEntityStorageAccess",
"ExpansionVoidPoiStorageAccess",
"ExpansionVoidTicksAccess",
"ExpansionReuseChunkMarkerMixin",
"ExpansionReusePromotionMixin",
"ExpansionReuseSerializationMixin"
],
"client": [
"client.SanctuarySkyMixin",
@@ -2,12 +2,14 @@ package fr.koka.sanctuary.expansion;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AccessDeniedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
/** Persistence failures must never turn saved continents into a new empty history. No game world is opened. */
public final class ExpansionJournalSmoke {
@@ -70,6 +72,99 @@ public final class ExpansionJournalSmoke {
require(Arrays.equals(unifiedMediumBytes, Files.readAllBytes(unifiedMediumDirectory.resolve(ExpansionJournal.FILE_NAME))),
"Rejected cross-generation read changed its journal");
Path cityDirectory = scratch.resolve("city-generation");
var cityRoot = ExpansionIsland.origin(724, SEED, 14);
var city = new ExpansionJournal(cityDirectory, SEED, 724, 14,
ExpansionJournal.directorySyncFor("\\", ignored -> { throw new AssertionError("Windows must not open the directory"); }));
var district = ExpansionPlacement.candidates(SEED, List.of(cityRoot), "city", "sanctuary", "east", "lost_city", 256, "natural", 0).getFirst();
require(district.generation() == 14, "New city expansion retains generation 14");
city.commit(List.of(cityRoot, district));
require(new ExpansionJournal(cityDirectory, SEED, 724, 14).islands().equals(List.of(cityRoot, district)),
"Generation 14 root and reserved city survive journal reload");
byte[] cityBytes = Files.readAllBytes(cityDirectory.resolve(ExpansionJournal.FILE_NAME));
reject(() -> new ExpansionJournal(cityDirectory, SEED, 724, 13), "Reading a city journal as alpha.13");
reject(() -> new ExpansionJournal(unifiedMediumDirectory, SEED, 724, 14), "Reading alpha.13 as a city journal");
require(Arrays.equals(cityBytes, Files.readAllBytes(cityDirectory.resolve(ExpansionJournal.FILE_NAME)))
&& Arrays.equals(unifiedMediumBytes, Files.readAllBytes(unifiedMediumDirectory.resolve(ExpansionJournal.FILE_NAME))),
"Cross-generation journal reads preserve both histories");
reject(() -> city.commit(List.of(cityRoot, district, north)), "Legacy expansion in generation 14 history");
var reuseDirectory = scratch.resolve("city-reuse");
var reuseJournal = new ExpansionJournal(reuseDirectory, SEED, 724, 14);
var certified = district.withReuseToken("a".repeat(64));
reuseJournal.commit(List.of(cityRoot, certified));
require(new ExpansionJournal(reuseDirectory, SEED, 724, 14).islands().get(1).equals(certified),
"Void certificate binding survives reservation reload");
byte[] reuseBytes = Files.readAllBytes(reuseDirectory.resolve(ExpansionJournal.FILE_NAME));
reject(() -> reuseJournal.commit(List.of(cityRoot, district)), "Removing a persisted certificate token");
reject(() -> reuseJournal.commit(List.of(cityRoot, district.withReuseToken("b".repeat(64)))), "Replacing a persisted certificate token");
require(Arrays.equals(reuseBytes, Files.readAllBytes(reuseDirectory.resolve(ExpansionJournal.FILE_NAME))),
"Rejected certificate changes preserve journal bytes");
reuseJournal.commit(List.of(cityRoot, certified.ready()));
require(new ExpansionJournal(reuseDirectory, SEED, 724, 14).islands().get(1).equals(certified.ready()),
"Completed island retains its immutable certificate binding");
for (var unsupported : List.of("../certificate", "A".repeat(64), "a".repeat(63))) {
try { district.withReuseToken(unsupported); throw new AssertionError("Invalid token was accepted"); }
catch (IllegalArgumentException expected) { }
}
for (var unsupported : List.of(cityRoot, north)) {
try { unsupported.withReuseToken("a".repeat(64)); throw new AssertionError("Root or legacy reuse was accepted"); }
catch (IllegalArgumentException expected) { }
}
require(!Files.readString(unifiedState).contains("reuse_token"), "Legacy journal encoding stays unchanged");
var detailRoot = ExpansionIsland.origin(724, SEED, 15);
var detailDirectory = scratch.resolve("city-detail-15");
var detailJournal = new ExpansionJournal(detailDirectory, SEED, 724, 15);
var detailChild = ExpansionPlacement.candidates(SEED, List.of(detailRoot), "detail", "sanctuary", "west", "lost_city", 1024, "natural", 0).getFirst()
.withReuseToken("c".repeat(64));
require(detailChild.generation() == 15, "Generation15 expansions retain their own generator identity");
detailJournal.commit(List.of(detailRoot, detailChild));
require(new ExpansionJournal(detailDirectory, SEED, 724, 15).islands().equals(List.of(detailRoot, detailChild)),
"Generation15 reservation and certificate survive reload");
byte[] detailBytes = Files.readAllBytes(detailDirectory.resolve(ExpansionJournal.FILE_NAME));
reject(() -> new ExpansionJournal(detailDirectory, SEED, 724, 14), "Reading generation15 as generation14");
reject(() -> new ExpansionJournal(cityDirectory, SEED, 724, 15), "Reading generation14 as generation15");
reject(() -> detailJournal.commit(List.of(detailRoot, detailChild, district)), "Generation14 child in generation15 journal");
require(Arrays.equals(detailBytes, Files.readAllBytes(detailDirectory.resolve(ExpansionJournal.FILE_NAME)))
&& Arrays.equals(cityBytes, Files.readAllBytes(cityDirectory.resolve(ExpansionJournal.FILE_NAME))),
"Cross-generation rejection preserves both city histories");
var ruinsRoot = ExpansionIsland.origin(724, SEED, 16);
var ruinsDirectory = scratch.resolve("city-ruins-16");
var ruinsJournal = new ExpansionJournal(ruinsDirectory, SEED, 724, 16);
var ruinsChild = ExpansionPlacement.candidates(SEED, List.of(ruinsRoot), "ruins", "sanctuary", "west", "lost_city", 512, "natural", 0).getFirst()
.withReuseToken("d".repeat(64));
require(ruinsChild.generation() == 16, "Generation16 child inherits its root identity");
ruinsJournal.commit(List.of(ruinsRoot, ruinsChild));
require(new ExpansionJournal(ruinsDirectory, SEED, 724, 16).islands().equals(List.of(ruinsRoot, ruinsChild)),
"Generation16 reservation and certificate survive reload");
byte[] ruinsBytes = Files.readAllBytes(ruinsDirectory.resolve(ExpansionJournal.FILE_NAME));
reject(() -> new ExpansionJournal(ruinsDirectory, SEED, 724, 15), "Reading generation16 as15");
reject(() -> new ExpansionJournal(detailDirectory, SEED, 724, 16), "Reading generation15 as16");
reject(() -> ruinsJournal.commit(List.of(ruinsRoot, detailChild)), "Generation15 child in generation16 journal");
require(Arrays.equals(ruinsBytes, Files.readAllBytes(ruinsDirectory.resolve(ExpansionJournal.FILE_NAME)))
&& Arrays.equals(detailBytes, Files.readAllBytes(detailDirectory.resolve(ExpansionJournal.FILE_NAME))),
"Rejected migrations preserve both published15 and16 bytes");
var experimentRoot = ExpansionIsland.origin(724, SEED, 17);
var experimentDirectory = scratch.resolve("experiment-17");
var experimentJournal = new ExpansionJournal(experimentDirectory, SEED, 724, 17);
var experimentChild = ExpansionPlacement.candidates(SEED, List.of(experimentRoot), "experiment", "sanctuary", "east", "lost_city", 128, "natural", 0).getFirst()
.withReuseToken("e".repeat(64));
experimentJournal.commit(List.of(experimentRoot, experimentChild));
require(new ExpansionJournal(experimentDirectory, SEED, 724, 17).islands().equals(List.of(experimentRoot, experimentChild)),
"Generation17 journal and child preserve their independent identity");
byte[] experimentBytes = Files.readAllBytes(experimentDirectory.resolve(ExpansionJournal.FILE_NAME));
reject(() -> new ExpansionJournal(experimentDirectory, SEED, 724, 16), "No implicit downgrade from17 to16");
reject(() -> new ExpansionJournal(ruinsDirectory, SEED, 724, 17), "No implicit upgrade from16 to17");
reject(() -> experimentJournal.commit(List.of(experimentRoot, ruinsChild)), "No generation16 child in17");
require(Arrays.equals(experimentBytes, Files.readAllBytes(experimentDirectory.resolve(ExpansionJournal.FILE_NAME)))
&& Arrays.equals(ruinsBytes, Files.readAllBytes(ruinsDirectory.resolve(ExpansionJournal.FILE_NAME))),
"Rejected cross-generation changes preserve experiment and published16 bytes");
try {
ExpansionPlacement.candidates(SEED, List.of(ExpansionIsland.origin(724, SEED, 13)), "city", "sanctuary", "east", "lost_city", 256, "natural", 0);
throw new AssertionError("Lost City must not silently activate in a saved alpha.13 world");
} catch (IllegalArgumentException expected) {
require(expected.getMessage().equals("city_generation_required"), "Explain the legacy world contract");
}
Path missing = scratch.resolve("missing"); Files.createDirectory(missing);
reject(() -> new ExpansionJournal(missing, SEED, 512), "Existing directory with no state");
Files.writeString(missing.resolve(".expansions-interrupted.tmp"), "partial");
@@ -120,10 +215,14 @@ public final class ExpansionJournalSmoke {
afterCrash.commit(List.of(root, north.ready(), onward));
require(new ExpansionJournal(resumeDirectory, SEED, 512).islands().size() == 3, "Resumed region lost its descendant");
windowsDirectoryPolicy(scratch.resolve("windows-policy"));
directoryFailureAfterReplacement(scratch.resolve("directory-failure"));
readbackFailureAfterReplacement(scratch.resolve("readback-failure"));
var tooMany = new ArrayList<ExpansionIsland>(); tooMany.add(root);
for (int i = 0; i < 64; i++) tooMany.add(island("overflow_" + i, "sanctuary", "north", 0, -(i + 1) * 1024));
reject(() -> afterCrash.commit(tooMany), "Region count limit");
System.out.println("Expansion journal smoke passed: atomic persistence, restart, immutable history, corruption, interrupted reservation and unified root identity");
System.out.println("Expansion journal smoke passed: atomic persistence, restart, immutable history, corruption, interrupted reservation, unified root identity, Windows directory policy and post-replacement failures");
} finally {
try (var paths = Files.walk(scratch)) {
for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(path);
@@ -131,6 +230,77 @@ public final class ExpansionJournalSmoke {
}
}
private static void windowsDirectoryPolicy(Path directory) throws Exception {
var directoryAttempts = new AtomicInteger();
var windowsSync = ExpansionJournal.directorySyncFor("\\", path -> {
directoryAttempts.incrementAndGet();
throw new AccessDeniedException(path.toString());
});
var root = ExpansionIsland.origin(724, SEED, 13);
var north = island("windows_north", "sanctuary", "north", 0, -1024);
var journal = new ExpansionJournal(directory, SEED, 724, 13, windowsSync);
require(journal.healthy() && journal.islands().equals(List.of(root)),
"Windows policy must bootstrap a verified journal without opening the directory as a file");
journal.commit(List.of(root, north));
var reserved = new ExpansionJournal(directory, SEED, 724, 13, windowsSync);
require(reserved.healthy() && reserved.islands().equals(List.of(root, north)),
"Windows policy must persist and reload the exact reservation");
reserved.commit(List.of(root, north.ready()));
var ready = new ExpansionJournal(directory, SEED, 724, 13, windowsSync);
require(ready.healthy() && ready.islands().equals(List.of(root, north.ready())),
"Windows policy must complete and reload the same reservation");
require(directoryAttempts.get() == 0,
"Windows policy must never attempt the unsupported directory channel, including bootstrap and completion");
}
private static void directoryFailureAfterReplacement(Path directory) throws Exception {
var root = ExpansionIsland.origin(724, SEED, 13);
var north = island("retained_north", "sanctuary", "north", 0, -1024);
new ExpansionJournal(directory, SEED, 724, 13);
Path state = directory.resolve(ExpansionJournal.FILE_NAME);
var attempts = new AtomicInteger();
var denied = new AccessDeniedException(directory.toString());
var requiredSync = ExpansionJournal.directorySyncFor("/", path -> {
attempts.incrementAndGet();
throw denied;
});
var failed = new ExpansionJournal(directory, SEED, 724, 13, requiredSync);
try {
failed.commit(List.of(root, north));
throw new AssertionError("A directory sync failure on a supported filesystem was ignored");
} catch (AccessDeniedException actual) {
require(actual == denied, "The original directory sync error must reach the caller unchanged");
}
require(attempts.get() == 1, "A supported filesystem must attempt directory synchronization");
require(!failed.healthy() && failed.islands().equals(List.of(root)),
"Uncertain persistence must fail closed without publishing the new reservation to world generation");
byte[] reservedBytes = Files.readAllBytes(state);
var recovered = new ExpansionJournal(directory, SEED, 724, 13);
require(recovered.healthy() && recovered.islands().equals(List.of(root, north)),
"A verified reload must recover the reservation already installed before directory sync failed");
reject(() -> failed.commit(List.of(root, north)), "Retrying a failed journal without reload");
require(attempts.get() == 1 && Arrays.equals(reservedBytes, Files.readAllBytes(state)),
"A failed session must neither retry storage operations nor overwrite its retained reservation");
recovered.commit(List.of(root, north.ready()));
require(new ExpansionJournal(directory, SEED, 724, 13).islands().equals(List.of(root, north.ready())),
"A recovered reservation must complete normally without changing its identity");
}
private static void readbackFailureAfterReplacement(Path directory) throws Exception {
var root = ExpansionIsland.origin(724, SEED, 13);
var north = island("readback_north", "sanctuary", "north", 0, -1024);
new ExpansionJournal(directory, SEED, 724, 13);
Path state = directory.resolve(ExpansionJournal.FILE_NAME);
var journal = new ExpansionJournal(directory, SEED, 724, 13,
path -> Files.writeString(state, "damaged after atomic replacement", StandardCharsets.UTF_8));
reject(() -> journal.commit(List.of(root, north)), "Corruption detected by post-replacement verification");
require(!journal.healthy() && journal.islands().equals(List.of(root)),
"Successful replacement alone must not publish an unverified reservation");
require(Files.readString(state).equals("damaged after atomic replacement"),
"A failed readback must preserve evidence instead of silently recreating the journal");
reject(() -> new ExpansionJournal(directory, SEED, 724, 13), "Reloading a journal damaged after replacement");
}
private static ExpansionIsland island(String id, String parent, String direction, int x, int z) {
return new ExpansionIsland(id, parent, direction, ExpansionIsland.directionalClimate(direction), 64, x, z,
ExpansionIsland.deriveSeed(SEED, id), "natural", "reserved", ExpansionIsland.GENERATION);

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