Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71d5cf7906 | ||
|
|
dd9c795695 | ||
|
|
c576d655ae |
+25
-2
@@ -1,6 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0-alpha.14 — en préparation
|
||||
## 0.1.0-alpha.15 — 2026-09-09
|
||||
|
||||
- Six familles de bâtiments reprennent leurs intérieurs : vraies pièces et
|
||||
portes, seuils, vestibules, couloirs, escaliers, sols, plafonds et mobilier
|
||||
adapté. Les parcours et les approches des lits sont vérifiés.
|
||||
- Réseaux souterrains d’égouts, passages, garages et pool rooms ; descentes
|
||||
de surface, bassins contenus et appuis courts ancrés lorsque la roche manque.
|
||||
- Petite ville compacte, rues et carrefours jointifs, terrasses dégagées et
|
||||
ponts intégrés au réseau. Correction des charnières, sièges et appuis des
|
||||
lanternes ; grande salle sèche dans les tunnels pour une future machine.
|
||||
- Codec `sanctuary:island_v15`, journal15 et pièces natives versionnées.
|
||||
Les anciennes générations gardent leurs plans, rendus et sauvegardes.
|
||||
Le contrat de reprise certifiée du vide reste strictement lié à sa génération.
|
||||
|
||||
`check build assemblePack`, les huit tests moteur et les scénarios de création
|
||||
et réouverture passent : 76 pièces natives et cinq coffres conservés, rues et
|
||||
intérieurs accessibles, bassins contenus. Release et canal packwiz publiés ;
|
||||
même instance Prism synchronisée deux fois, 767 fichiers personnels et réglages
|
||||
suivis conservés. Le JAR d’essai anticipé est sauvegardé séparément. Voir
|
||||
[les preuves et limites alpha.15](docs/testing-alpha15.md).
|
||||
|
||||
## 0.1.0-alpha.14 — 2026-09-09
|
||||
|
||||
- Biome Lost City et quartiers procéduraux contemporains : poches sur l’île
|
||||
initiale et profil `lost_city` pour les nouvelles expansions. Dix-sept types
|
||||
@@ -24,7 +45,9 @@
|
||||
`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
|
||||
vérifiés ; publication et synchronisations encore à attester. Voir les
|
||||
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
|
||||
|
||||
@@ -13,14 +13,36 @@ 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. **L’alpha.14 est validée,
|
||||
sa publication est en préparation** : Lost City ajoute des quartiers en ruine au monde flottant.
|
||||
Le dernier correctif publié reste l’alpha.13.1 tant que la nouvelle livraison
|
||||
n’est pas consignée dans [Distribution](docs/packwiz.md).
|
||||
décrits dans la vision ne sont pas encore implémentés. **L’alpha.15 est publiée** :
|
||||
Lost City devient une petite ville compacte, avec intérieurs détaillés et
|
||||
réseaux souterrains. Le canal packwiz et la même instance Prism Mac sont à jour ;
|
||||
voir [Distribution](docs/packwiz.md).
|
||||
|
||||
## Alpha.14 — Lost City, en préparation
|
||||
## Alpha.15 — ville compacte et réseaux
|
||||
|
||||
Les bâtiments de Lost City reçoivent des plans intérieurs détaillés : pièces,
|
||||
portes, vestibules, couloirs, escaliers, chambres, cuisines et salles d’eau.
|
||||
Les égouts relient des salles et équipements souterrains, avec garages, pool
|
||||
rooms et accès depuis la surface. Les bâtiments se regroupent en une
|
||||
petite ville compacte avec rues, carrefours et entrées reliés. Les ponts et
|
||||
viaducs prolongent le réseau ; le terrain est dégagé dans l’emprise urbaine.
|
||||
Une grande salle souterraine prépare la future machine d’expansion.
|
||||
|
||||
Le biome Lost City est conservé car il pilote réellement les biomes urbains
|
||||
et leurs apparitions de zombies vanilla. La nouvelle génération **15** demande
|
||||
un **nouveau monde Sanctuary** ; les mondes 12/13/14 gardent leur génération.
|
||||
Les tailles et le bouton natif **Personnaliser** restent identiques. Le
|
||||
[protocole alpha.15](docs/testing-alpha15.md) consigne les preuves et les limites. Les tests de création et de réouverture
|
||||
passent, ainsi que `check build assemblePack`. La
|
||||
[release alpha.15](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.15)
|
||||
est publiée ; la même instance Prism est synchronisée deux fois, avec 767
|
||||
fichiers personnels et réglages suivis conservés. Créer un **nouveau monde**,
|
||||
y compris après le JAR d’essai anticipé15. La machine d’expansion et ses cartes
|
||||
préremplies restent prévues pour un prochain ticket.
|
||||
|
||||
## Alpha.14 — Lost City
|
||||
|
||||
Lost City est un biome de quartiers contemporains abandonnés, composé par du
|
||||
code Sanctuary original. Des poches urbaines peuvent apparaître sur l’île de
|
||||
@@ -59,7 +81,11 @@ 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 publication et la synchronisation Prism ne sont pas encore attestées.
|
||||
La [release alpha.14](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.14),
|
||||
le MRpack et le canal packwiz sont publiés. Deux installations isolées puis deux
|
||||
synchronisations de la même instance Prism Mac passent ; les 713 fichiers
|
||||
personnels et réglages suivis conservent leurs hashes. Aucun monde personnel
|
||||
n’a été ouvert ou régénéré. La validation native Windows reste à effectuer.
|
||||
|
||||
## Alpha.13.1 — correctif des expansions sous Windows
|
||||
|
||||
@@ -329,7 +355,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.14, validation et livraison en cours |
|
||||
| Sanctuary / pack | 0.1.0-alpha.15 |
|
||||
|
||||
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
|
||||
@@ -358,7 +384,7 @@ décrits dans [Validation](docs/testing.md).
|
||||
|
||||
Résultats :
|
||||
|
||||
- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.14.jar` : mod à installer avec
|
||||
- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.15.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).
|
||||
|
||||
+47
-2
@@ -577,12 +577,57 @@ de spawn ; aucun spawner ni nouveau zombie spécial dans cette étape.
|
||||
fondations, circulation, absence de spawners/villageois, héritage Windows,
|
||||
build et pack. Livrer dans le canal stable et la même instance Prism.
|
||||
|
||||
**État :** implémentation et vérifications terminées ; la publication alpha.14
|
||||
et les synchronisations Prism restent à attester. Les résultats sont suivis dans
|
||||
**État : terminé et livré en alpha.14.** Build et tests moteur réussis,
|
||||
release et canal packwiz publiés, deux synchronisations isolées puis deux dans
|
||||
la même instance Prism Mac réussies, 713 fichiers personnels et réglages suivis
|
||||
conservés. Aucun essai Windows natif effectué. Les résultats sont suivis dans
|
||||
[Validation alpha.14](testing-alpha14.md). Les alpha.15/.16 poursuivront la validation de génération
|
||||
avant la bêta consacrée aux systèmes et à la progression. Les infectés, spitters,
|
||||
boomers, hunters, chargeurs et autres variantes restent réservés à la bêta.
|
||||
|
||||
### WG-18 — Lost City, ville compacte et réseaux souterrains — alpha.15
|
||||
|
||||
**Branche :** `codex/lost-city-interiors`.
|
||||
|
||||
**Contrat :** reprendre les bâtiments avec des pièces aménagées et une
|
||||
circulation lisible, puis rendre les équipements souterrains et franchissements
|
||||
réellement découvrables dans une petite ville dense et reliée.
|
||||
|
||||
- Premier chantier : plans intérieurs propres aux appartements, hôtels,
|
||||
bibliothèques, restaurants, casernes et halls ; seuil, portes, vestibule,
|
||||
couloirs, escaliers, sols, plafonds, chambres et détails d'usage.
|
||||
- Deuxième chantier en parallèle : égouts procéduraux, pool rooms et garages
|
||||
reliés par des passages, avec accès depuis la surface. Les portions exposées
|
||||
au vide exigent des chaînes ou des pylônes ancrés dans la roche, de longueur
|
||||
bornée. Préserver les eaux naturelles et contenir les bassins construits.
|
||||
- Troisième chantier : une grille urbaine compacte, avec lots, rues et carrefours
|
||||
jointifs. Les lots sans accès au réseau sont refusés ; les terrasses et
|
||||
dégagements restent dans l’emprise urbaine certifiée. Les ponts et viaducs
|
||||
prolongent ce réseau. Aucun équipement ne doit flotter sans appui.
|
||||
- Retour de l’essai anticipé : corriger les charnières, les dossiers des sièges
|
||||
et les lanternes ; contrôler les directions après rotation et leur appui sur
|
||||
des blocs complets. Une grande salle sèche accessible dans les tunnels prépare
|
||||
la future machine d’expansion, sans l’activer dans cet incrément.
|
||||
- Le biome `sanctuary:lost_city` est conservé : il est effectivement utilisé
|
||||
pour les parcelles urbaines, la décoration et les apparitions naturelles.
|
||||
- Nouvelle génération **15**, codec `sanctuary:island_v15`, journal propre
|
||||
`data/sanctuary-world-v15/expansions.json`. Les mondes 12/13/14 conservent leurs
|
||||
codecs, plans et rendus historiques, y compris les chunks encore non générés.
|
||||
Les nouvelles pièces ont un identifiant et des paramètres persistants propres.
|
||||
Aucun monde personnel n'est ouvert, converti ou régénéré pour cette livraison.
|
||||
- La reprise certifiée du vide conserve le contrat WG-17 dans le journal15,
|
||||
avec une identité de génération stricte ; aucun certificat14 n'autorise15.
|
||||
- Vérifier parcours, portes et lits, géométrie/supports, eau après ticks, témoins
|
||||
natifs des équipements, biomes et persistance après réouverture. Exécuter
|
||||
`check build assemblePack`, publier un artefact immuable, avancer packwiz et
|
||||
synchroniser deux fois la même instance Prism avec conservation des réglages.
|
||||
|
||||
**État : livré et vérifié.** Les tests détaillés et les scénarios natifs de
|
||||
création/réouverture passent, avec 76 pièces conservées. `check build assemblePack`
|
||||
final passe. Release, canal packwiz et même instance Prism à jour ; deux
|
||||
synchronisations réussies et 767 fichiers personnels/réglages suivis conservés.
|
||||
Voir [les preuves](testing-alpha15.md) et [le reçu](packwiz.md).
|
||||
|
||||
## 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.
|
||||
|
||||
+59
-8
@@ -13,14 +13,65 @@ 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.14 en préparation
|
||||
## Alpha.15 publiée — 9 septembre 2026
|
||||
|
||||
Les sources ciblent `0.1.0-alpha.14`. `check build assemblePack`, les scénarios
|
||||
moteur et la vérification du JAR, du MRpack et du ZIP Prism passent. La publication
|
||||
immuable, l’avancement du canal et les synchronisations restent à attester
|
||||
avant d’annoncer cette version publiée. Le dernier reçu de livraison
|
||||
ci-dessous reste celui de l’alpha.13.1. Voir le
|
||||
[contrat de génération et les preuves attendues](testing-alpha14.md).
|
||||
[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 utilise cette
|
||||
version, au commit `6cf54ee`. JAR, [MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.15/Sanctuary-0.1.0-alpha.15.mrpack)
|
||||
et ZIP d’amorçage Prism ont été vérifiés par téléchargement public.
|
||||
SHA-256 du JAR :
|
||||
|
||||
```text
|
||||
52253f386d30530cc718d4b90ac6953cd4790b2125c78b74599eebff944824f2
|
||||
```
|
||||
|
||||
`check build assemblePack`, les huit GameTests et les scénarios natifs de
|
||||
création/réouverture passent. Deux synchronisations isolées puis deux dans la
|
||||
même instance Prism Mac réussissent. Un seul JAR Sanctuary alpha.15 est actif ;
|
||||
les **767 fichiers personnels et réglages suivis** conservent leurs hashes.
|
||||
Aucune sauvegarde personnelle n’a été ouverte, convertie ou régénérée.
|
||||
Le dossier reste `Sanctuary-0.1.0-alpha.1`, affiché « Sanctuary Beta ».
|
||||
|
||||
Le JAR d’essai15 ajouté manuellement avant publication est conservé dans
|
||||
`sanctuary-backups/alpha15-manual-preview/`, hors de `mods/`. Le JAR14 et le
|
||||
suivi packwiz sont sauvegardés dans `sanctuary-backups/before-0.1.0-alpha.15/`.
|
||||
Les reçus locaux ignorés sont `build/alpha15-isolated-validation.json` et
|
||||
`build/alpha15-prism-validation.json` ; les empreintes avant/après figurent
|
||||
aussi dans la sauvegarde ciblée de l’instance.
|
||||
|
||||
Créer un **nouveau monde Sanctuary** pour la ville compacte et ses sous-sols,
|
||||
y compris après l’essai anticipé15. Les mondes publiés12/13/14 gardent leurs
|
||||
générateurs. La salle d’expansion est présente lorsque le terrain le permet ;
|
||||
sa future machine et les cartes préremplies ne sont pas implémentées.
|
||||
Voir [les preuves, coordonnées et limites](testing-alpha15.md).
|
||||
Aucun accès à l’instance Windows n’a eu lieu pour cette livraison.
|
||||
|
||||
## Alpha.14 publiée — 9 septembre 2026
|
||||
|
||||
[Sanctuary 0.1.0-alpha.14](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.14)
|
||||
est publiée depuis le commit source `1c5c2c2`. Le canal stable utilisait alors cette
|
||||
version, au commit `dabf57e`. Le JAR, le
|
||||
[MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.14/Sanctuary-0.1.0-alpha.14.mrpack)
|
||||
et le ZIP d’amorçage Prism ont été vérifiés par téléchargement public.
|
||||
SHA-256 du JAR :
|
||||
|
||||
```text
|
||||
1c517a13e1151501c6f4974d1bc00faedf67aaecd9bf58f34ac4a655d09dc6ef
|
||||
```
|
||||
|
||||
`check build assemblePack` et les huit GameTests passent, ainsi que les
|
||||
scénarios natifs Lost City et reprise du vide après réouverture. Deux
|
||||
synchronisations isolées puis deux dans la même instance Prism Mac ont réussi.
|
||||
Le second passage conserve les fichiers gérés à l’identique ; un seul JAR
|
||||
Sanctuary alpha.14 est actif. Les **713 fichiers personnels et réglages suivis**
|
||||
conservent leurs hashes. Aucune sauvegarde personnelle n’a été ouverte,
|
||||
convertie ou régénérée. Le dossier reste `Sanctuary-0.1.0-alpha.1`.
|
||||
|
||||
Sauvegarde ciblée dans cette instance :
|
||||
`sanctuary-backups/before-0.1.0-alpha.14/`. Reçus locaux ignorés :
|
||||
`build/alpha14-isolated-validation.json` et `build/alpha14-prism-validation.json`.
|
||||
Voir le [contrat de génération et les preuves](testing-alpha14.md).
|
||||
La machine Windows n’a pas été accessible pour un essai natif ou une installation.
|
||||
|
||||
La livraison conserve la même instance et ses sauvegardes. Pour les nouveautés
|
||||
Lost City, créer ensuite un **nouveau monde Sanctuary** de génération 14 ;
|
||||
@@ -32,7 +83,7 @@ l’installateur packwiz et n’autorise pas à supprimer un monde ou son journa
|
||||
## 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 utilise cette
|
||||
est publié depuis le commit source `b19e93d`. Le canal stable utilisait alors cette
|
||||
version. Le JAR, le [MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.13.1/Sanctuary-0.1.0-alpha.13.1.mrpack)
|
||||
et le ZIP d’amorçage ont été vérifiés par téléchargement public. SHA-256 du JAR :
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
# Alpha.14 — Lost City et expansions
|
||||
|
||||
**État : code et artefacts vérifiés ; publication et installation en attente.**
|
||||
**É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 ; le canal public et l’instance Prism ne sont pas encore attestés ici.
|
||||
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 :
|
||||
@@ -347,11 +351,10 @@ restent à consigner.
|
||||
| Sérialisation et stockage | Trois tailles, codec historique et erreurs de stockage bloquantes | Suite native réussie pour les trois tailles et le codec 13 ; politique Windows simulée réussie |
|
||||
| Hydrologie conservée | Eau retenue après écoulement moteur | Réussi : 125 chunks FULL, 5 923 blocs d’eau, 1 800 ticks de fluides |
|
||||
| Build et pack | `check build assemblePack` réussi avec versions alignées | Réussi à 13 h 57 min 48 s, huit GameTests requis, JAR/MRpack/ZIP Prism vérifiés |
|
||||
| Distribution | Artefacts immuables vérifiés, canal avancé, deux installations isolées et deux synchronisations de la même instance | À exécuter et consigner |
|
||||
| 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 devront préciser commit source, graine, diamètre, commandes, journaux,
|
||||
résultats et SHA-256 des artefacts ; ne pas remplacer une absence de témoin par
|
||||
une affirmation de présence. Aucun essai Windows natif, mesure de FPS ou test
|
||||
Les reçus locaux sont `build/alpha14-isolated-validation.json` et
|
||||
`build/alpha14-prism-validation.json`. Aucun essai Windows natif, mesure de FPS ou test
|
||||
de charge multijoueur n’est attesté par cette liste. Une validation de géométrie
|
||||
en mémoire ne remplace pas la génération, les ticks et la persistance du moteur.
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# Alpha.15 — intérieurs, sous-sols et ville compacte
|
||||
|
||||
**État : code, génération et distribution validés.**
|
||||
Ticket [WG-18](backlog.md). `check build assemblePack` et les huit tests moteur
|
||||
passent sur les sources finales, ainsi que la création et la réouverture
|
||||
natives de Lost City. Le reçu de publication figure dans [Distribution](packwiz.md).
|
||||
|
||||
## Contrat de génération
|
||||
|
||||
Minecraft **26.3-pre-2**, Java **25**, dépendances épinglées inchangées.
|
||||
Le mod et le pack passent à `0.1.0-alpha.15`. Un **nouveau monde Sanctuary**
|
||||
utilise `sanctuary:island_v15` et son journal propre
|
||||
`data/sanctuary-world-v15/expansions.json`.
|
||||
Le bouton natif **Personnaliser** conserve Petit 512, Moyen 724 par défaut et
|
||||
Grand 1 024. La densité du terrain, les forêts et l’hydrologie naturelle ne sont
|
||||
pas remplacées par cette passe urbaine.
|
||||
|
||||
Le JAR d’essai anticipé du 9 septembre, avant publication de l’alpha.15,
|
||||
contenait encore les fragments dispersés. Les essais de cette nouvelle
|
||||
composition se font dans un **monde neuf** ; aucune réparation ou régénération
|
||||
des mondes créés avec ce prototype n’est exécutée. Le binaire d’essai est
|
||||
conservé séparément avant synchronisation de l’instance.
|
||||
|
||||
Les mondes 12/13/14 conservent leurs codecs, paramètres, journaux, plans et
|
||||
rendus, même pour leurs chunks encore non générés. Le preset 14 reste enregistré
|
||||
mais caché dans la création. Aucun monde personnel n’est ouvert, converti ou
|
||||
régénéré. Les nouvelles pièces persistantes ont leurs propres identifiants :
|
||||
`sanctuary:lost_city_parcel_v15`, `sanctuary:lost_city_underground_v15` et
|
||||
`sanctuary:lost_city_support_v15`.
|
||||
|
||||
Le biome `sanctuary:lost_city` reste utilisé : les parcelles acceptées pilotent
|
||||
la carte des biomes urbains et les apparitions naturelles de zombies vanilla.
|
||||
Les cavités gardent leurs biomes ; un égout souterrain ne transforme pas toute
|
||||
la colonne en biome urbain. Il n’y a ni villageois générés, ni spawner, ni
|
||||
classes de zombies de la future bêta.
|
||||
|
||||
## Aménagements
|
||||
|
||||
Les appartements, hôtels, bibliothèques, restaurants, casernes et halls ont
|
||||
des usages distincts. Seuil et vestibule rejoignent un couloir, une cage
|
||||
d’escalier et des pièces séparées par de vraies portes. Sols, plafonds,
|
||||
chambres, salles d’eau, cuisines, réserves, réception, rayonnages, dortoirs,
|
||||
auditorium et galerie suivent le type du bâtiment. Les détails d’usure doivent
|
||||
préserver les passages. Le butin reste différé, avec un coffre par bâtiment.
|
||||
|
||||
Les réseaux souterrains cherchent une descente depuis la surface, des égouts,
|
||||
des passages, des garages et des pool rooms. La roche et les volumes d’eau
|
||||
naturelle décident de leur admission. Les parties exposées demandent des
|
||||
appuis courts certifiés : pylônes ou chaînes réellement ancrés. Les bassins
|
||||
construits ont un fond, des parois et un accès ; ils ne doivent pas se vider
|
||||
dans les corridors. Les modules voisins ne se superposent pas. Une grande salle sèche accessible
|
||||
prépare une future machine d’expansion ; la machine et ses cartes ne font pas
|
||||
partie de cette livraison.
|
||||
|
||||
Le retour de l’essai anticipé remplace les fragments dispersés par une grille
|
||||
compacte : lots, rues et carrefours sont jointifs, avec un réseau accessible
|
||||
depuis chaque entrée. Les routes sont dégagées sur huit blocs de hauteur ;
|
||||
les terrasses sont certifiées dans leur emprise et les ponts prolongent le
|
||||
réseau. Les sièges, charnières et appuis des lanternes sont revus. Les emplacements admissibles
|
||||
restent conditionnels ; aucun équipement n’est promis sur toute graine.
|
||||
|
||||
## Vérification reproductible
|
||||
|
||||
Utiliser seulement les mondes de développement ignorés. Exécuter les processus
|
||||
Minecraft séquentiellement et faire suivre chaque création de sa réouverture,
|
||||
avant un autre test qui nettoie ce dossier de développement.
|
||||
|
||||
```sh
|
||||
./gradlew :sanctuary:lostCityInteriors15Smoke :sanctuary:lostCityUnderground15Smoke --console=plain
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryLostCity15Tests=true -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryLostCity15Tests=true -PsanctuaryExpansionReload=true -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
|
||||
./gradlew check build assemblePack -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
|
||||
```
|
||||
|
||||
La suite historique `-PsanctuaryLostCityTests=true` choisit explicitement la
|
||||
génération 14. Les suites de reprise certifiée du vide utilisent la génération
|
||||
15 par défaut ; la réouverture doit être exécutée deux fois après leur création.
|
||||
Les certificats sont liés à leur génération : un certificat ou journal 14 ne
|
||||
peut pas autoriser une expansion 15. Le [contrat WG-17](testing-alpha14.md)
|
||||
reste applicable dans chaque journal, avec inspection stricte avant écriture.
|
||||
|
||||
Les résultats finaux préciseront graine, version, coordonnées, pièces natives,
|
||||
eau après ticks, conservation après réouverture et durée des commandes.
|
||||
|
||||
### Compatibilité historique déjà vérifiée
|
||||
|
||||
La création de génération 14 avec le binaire alpha.15 passe le 9 septembre
|
||||
2026 (`build/alpha15-legacy14-create.log`, processus 22663, 2 min 57 s).
|
||||
Sur graine 0, racine 724 et extension `city_gardens` 256, les deux starts NBT,
|
||||
les inventaires de blocs, les coffres différés et le journal ont exactement les
|
||||
mêmes empreintes ou contenus que la livraison alpha.14. La réouverture du même
|
||||
binaire dans le processus 23083 passe (`build/alpha15-legacy14-reload.log`, 57 s),
|
||||
avec **38 pièces**, **5 coffres non ouverts**, le bloc témoin et le journal
|
||||
conservés. Cette réouverture exclut la recompilation : elle vérifie les mêmes
|
||||
binaires que la création, pendant le développement séparé du nouveau planner15.
|
||||
Les classes historiques de planification et de rendu ne sont pas modifiées.
|
||||
Les rapports sont conservés dans `build/alpha15-evidence/legacy14-*.json`.
|
||||
|
||||
Le premier test moteur15 a vérifié les accès des bâtiments et réseaux, puis a
|
||||
échoué sur une hypothèse insuffisante de son ordonnanceur de fluides. Le test
|
||||
attend maintenant que les chunks et leurs entités soient prêts, observe un
|
||||
écoulement natif dans des témoins temporaires fermés, puis contrôle les bassins
|
||||
naturels après des ticks mesurés. Ces témoins sont séparés des équipements
|
||||
naturellement générés et supprimés avant sauvegarde.
|
||||
Les essais sur modèles contrôlés seront distingués des équipements effectivement
|
||||
générés sur terrain naturel. La validation Windows native reste à effectuer.
|
||||
|
||||
### Contrôles ciblés du retour d’essai anticipé
|
||||
|
||||
Les smokes exécutés le 9 septembre passent avec les classes Minecraft exactes :
|
||||
les six familles d’intérieurs dans quatre orientations contrôlent la collision
|
||||
des portes ouvertes, le côté des dossiers des sièges et l’appui plein des
|
||||
lanternes (`build/alpha15-targeted-furnishings.log`). Le rendu de surface15
|
||||
corrige aussi les bancs et ajoute les marches sur le sol existant, leur sommet
|
||||
rejoignant le niveau voisin. Dégagement des rues, volumes de protection,
|
||||
grande salle, boucle alternative et fondations souterraines passent dans
|
||||
`build/alpha15-targeted-utilities.log`. Ces essais contrôlés ne remplacent pas
|
||||
le scénario natif final.
|
||||
|
||||
Le scénario de reprise certifiée du vide de génération15 passe sa création et
|
||||
ses deux réouvertures dans des JVM séparées : **154 chunks FULL**, tous éclairés
|
||||
correctement, et bloc témoin conservé. Les refus de bloc, coffre, entité ou tick
|
||||
présents et la conservation des régions face à une mutation étrangère passent.
|
||||
Rapports : `build/alpha15-evidence/void-reuse-*.json`. Ces preuves précèdent la
|
||||
révision compacte du seul placement urbain ; le test complet final reste exigé.
|
||||
|
||||
### Ville compacte : création finale vérifiée
|
||||
|
||||
La création finale passe sur la graine **0**, île **724**, le 9 septembre
|
||||
2026 : `build/alpha15-final-city-create.log`, processus **26229**, **3 min 19 s**
|
||||
pour les quatre smokes puis le scénario moteur. Cette durée inclut les contrôles,
|
||||
les prospects et la préparation d’un continent ; ce n’est pas un benchmark de
|
||||
chargement joueur ou de rendu.
|
||||
|
||||
- Racine : un quartier natif au chunk **(-2, 3)**, cinq lots (immeuble, hôtel,
|
||||
bibliothèque, jardin et place), **33 pièces de rues/carrefours**, **52 pièces
|
||||
natives** avec le sous-sol. Les cinq entrées rejoignent le même réseau ;
|
||||
**393 sections** de rues sont traversées sur les seules rues et seuils.
|
||||
L’immeuble est accessible en **(-30, 252, 21)** sur cette graine.
|
||||
- Sous la racine : un accès, quatre collecteurs, sept passages, une pool room
|
||||
et une grande salle sèche. Le socle et les quatre secteurs de la salle sont
|
||||
accessibles depuis la trappe de surface.
|
||||
- Continent témoin **256**, graine **8737523517608206795**, centre **(2112, 320)** :
|
||||
restaurant et immeuble, réseau de rues, pont de 17 blocs, garage et pool room
|
||||
souterrains. **24 pièces natives** et **5 appuis souterrains** inspectés.
|
||||
Le pont est parcouru de **(2104, 225, 309)** à **(2120, 224, 309)**.
|
||||
- Les biomes urbains et le spawn naturel de zombies sont vérifiés ; aucun
|
||||
spawner ni villageois ajouté. Cinq coffres restent à butin différé.
|
||||
- Les bassins souterrains sont contrôlés après **120 ticks de monde mesurés** ;
|
||||
19 témoins temporaires indépendants prouvent l’exécution native des fluides,
|
||||
puis sont retirés avant sauvegarde. La piscine de surface est vérifiée dans
|
||||
le premier monde compact, avant la dernière affectation des lots : ses
|
||||
**207 sources** sont conservées, et neuf cellules au-dessus des marches se
|
||||
remplissent normalement sans fuite (216 cellules d’eau au total).
|
||||
|
||||
Le témoin final contient un pont court et des appuis souterrains. Les grands
|
||||
viaducs sur piles construites restent validés sur terrains contrôlés ; ils ne
|
||||
sont pas présentés comme observés dans ce monde naturel. Aucune famille de
|
||||
bâtiments, piscine, garage, salle ou pont n’est garantie sur chaque graine.
|
||||
Rapport : `build/alpha15-evidence/final-city-create.json` ; le monde compact
|
||||
précédent est conservé dans `compact-first-create.json`.
|
||||
|
||||
La réouverture finale du même monde passe dans un autre processus (**26452**,
|
||||
`build/alpha15-final-city-reload.log`, **51 s**) : **76 pièces natives**, **5 coffres
|
||||
à butin différé**, **108 points de parcours intérieurs** et **5 appuis** conservés.
|
||||
Les sept entrées restent reliées aux rues, le bloc témoin **(-38, 252, 21)** est
|
||||
présent et le journal garde exactement ses octets. Rapport :
|
||||
`build/alpha15-evidence/final-city-reload.json`.
|
||||
|
||||
`check build assemblePack` final passe en **2 min 6 s** le 9 septembre 2026,
|
||||
avec les **huit tests moteur requis** et tous les smokes, dont les quatre
|
||||
contrôles15 (`build/alpha15-final-check-build-pack.log`). Le JAR et le pack
|
||||
sont construits depuis les mêmes sources que le scénario final. Le changement
|
||||
reste réservé à15 ; la revue finale ne trouve aucune modification des quatre
|
||||
classes de planification/rendu/pièce historiques14.
|
||||
|
||||
La release immuable `v0.1.0-alpha.15`, source `dd9c795`, et le canal `6cf54ee`
|
||||
sont publiés. JAR, MRpack et amorçage sont vérifiés ; deux installations isolées
|
||||
et deux synchronisations de la même instance Prism réussissent. Les **767**
|
||||
fichiers personnels et réglages suivis restent identiques. L’ancien JAR d’essai
|
||||
est conservé séparément ; un seul JAR Sanctuary est actif. Voir le
|
||||
[reçu de livraison](packwiz.md#alpha15-publiée--9-septembre-2026).
|
||||
+18
-4
@@ -178,8 +178,8 @@ L'ambition est de reprendre puis d'étendre la centaine de biomes du catalogue T
|
||||
- **Lost City** : des quartiers traités comme un biome, où les bâtiments
|
||||
contemporains en ruine prennent la place principale. L’alpha.14 développe une
|
||||
réalisation originale Sanctuary, inspirée du principe de ville abandonnée,
|
||||
sans ajouter le mod The Lost Cities ni reprendre ses assets. Des poches sont
|
||||
souhaitées sur l’île de départ et dans les continents d’expansion, en laissant
|
||||
sans ajouter le mod The Lost Cities ni reprendre ses assets. Une petite ville compacte est
|
||||
souhaitée sur l’île de départ, puis des quartiers dans les continents d’expansion, en laissant
|
||||
une majorité naturelle à l’île initiale. La pierre, la pierre lisse et les
|
||||
briques de pierre, notamment fissurées et moussues, donnent une palette sobre.
|
||||
- **Vie urbaine passée** : routes, ponts, places, marchés, appartements,
|
||||
@@ -187,12 +187,26 @@ L'ambition est de reprendre puis d'étendre la centaine de biomes du catalogue T
|
||||
casernes, halls, excavations et piscines. Les parcs comprennent boisements,
|
||||
jardins à haies et fleurs, aires de jeux et mémoriaux. Chaque usage doit se
|
||||
reconnaître dans les volumes et les aménagements, avec entrées et circulations.
|
||||
La passe alpha.15 vise des pièces réellement séparées et aménagées : seuil,
|
||||
vestibule, hall, corridors, escaliers, chambres, sols, plafonds et ornements.
|
||||
Les rues, parcs et bâtiments doivent être incrustés dans le sol. Les bâtiments
|
||||
se regroupent en îlots denses avec rues et carrefours continus, sans mottes
|
||||
de terre entre les façades et leurs accès. Les ponts et viaducs prolongent
|
||||
le réseau urbain sur les franchissements naturels admissibles.
|
||||
- **Parkings et sous-sols** : creuser seulement là où l’épaisseur de roche
|
||||
permet un volume soutenu ; un garage en surface reste possible ailleurs.
|
||||
Les excavations ouvertes et les piscines doivent aussi garder des fondations,
|
||||
des accès et, pour l’eau, un bassin contenu. Les parkings ne font pas partie
|
||||
de la future dimension des cavernes. À terme, ils pourront former des réseaux
|
||||
souterrains étendus ; cette connexion entre parkings reste à construire.
|
||||
de la future dimension des cavernes. L’alpha.15 travaille des égouts et pool
|
||||
rooms en réseau, procéduralement creusés sous la ville,
|
||||
avec des passages et des accès de surface. Une section suspendue exige des
|
||||
chaînes ou des pylônes ancrés. Leur présence reste liée au terrain ; le
|
||||
[suivi de validation](testing-alpha15.md) distingue les intentions des preuves.
|
||||
- **Future salle d’expansion** : une grande salle sèche reliée aux tunnels
|
||||
pourrait accueillir une machine diégétique. Un socle et des cadres porteraient
|
||||
des cartes déjà découvertes, présentant l’ensemble de l’île en vue du dessus.
|
||||
L’alpha prépare la salle ; la machine, les cartes préremplies et les
|
||||
interactions d’expansion seront définies et implémentées dans un ticket suivant.
|
||||
- **Occupants de Lost City** : des zombies plutôt que des villageois, sans
|
||||
spawner. L’alpha utilise le spawn naturel des zombies vanilla ; les infectés,
|
||||
spitters, boomers, hunters, chargeurs et autres classes arrivent avec les
|
||||
|
||||
+2
-2
@@ -8,6 +8,6 @@ loader_version=0.19.5
|
||||
loom_version=1.17.20
|
||||
fabric_api_version=0.160.0+26.3
|
||||
|
||||
mod_version=0.1.0-alpha.14
|
||||
pack_version=0.1.0-alpha.14
|
||||
mod_version=0.1.0-alpha.15
|
||||
pack_version=0.1.0-alpha.15
|
||||
maven_group=fr.koka.sanctuary
|
||||
|
||||
@@ -39,9 +39,12 @@ 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' : '15'))
|
||||
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.voidReuse', providers.gradleProperty('sanctuaryVoidReuseTests').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.optionalStructureWitness', providers.gradleProperty('sanctuaryOptionalStructureWitness').getOrElse('false'))
|
||||
}
|
||||
@@ -55,10 +58,12 @@ tasks.named('processGametestResources') {
|
||||
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 voidReuse = providers.gradleProperty('sanctuaryVoidReuseTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('voidReuse', voidReuse)
|
||||
filesMatching('fabric.mod.json') {
|
||||
expand(gametest_entrypoint: voidReuse ? 'fr.koka.sanctuary.gametest.ExpansionVoidReuseGameTests' : lostCity ? 'fr.koka.sanctuary.gametest.LostCityWorldGameTests' : expansion ? 'fr.koka.sanctuary.gametest.ExpansionWorldGameTests' : benchmark
|
||||
expand(gametest_entrypoint: voidReuse ? 'fr.koka.sanctuary.gametest.ExpansionVoidReuseGameTests' : 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')
|
||||
@@ -313,3 +318,37 @@ tasks.register('expansionVoidCertificateSmoke', JavaExec) {
|
||||
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') }
|
||||
|
||||
+3
-3
@@ -39,7 +39,7 @@ public final class ExpansionVoidReuseGameTests {
|
||||
helper.assertTrue(helper.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator,
|
||||
"Void reuse proof needs the unified generator");
|
||||
var generator = (SanctuaryChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
|
||||
helper.assertTrue(generator.rootGeneration() == 14, "Void reuse is restricted to generation 14");
|
||||
helper.assertTrue(ExpansionIsland.spatialGeneration(generator.rootGeneration()), "Void reuse is restricted to spatial city generations");
|
||||
new Scenario(helper, generator).advance();
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ public final class ExpansionVoidReuseGameTests {
|
||||
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", 14);
|
||||
report.put("world_seed", level.getSeed()); report.put("generation", generator.rootGeneration());
|
||||
}
|
||||
void advance() {
|
||||
try {
|
||||
@@ -69,7 +69,7 @@ public final class ExpansionVoidReuseGameTests {
|
||||
}
|
||||
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", 14);
|
||||
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);
|
||||
|
||||
+911
@@ -0,0 +1,911 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.expansion.ExpansionIsland;
|
||||
import fr.koka.sanctuary.expansion.ExpansionJournal;
|
||||
import fr.koka.sanctuary.expansion.ExpansionRuntime;
|
||||
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityInteriors15;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPalette;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPiece15;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPlan;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityRenderer;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityStructures;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCitySurface15;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCitySupportPiece15;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityUnderground15;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityUndergroundPiece15;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.function.Predicate;
|
||||
import net.fabricmc.fabric.api.gametest.v1.GameTest;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.resources.RegistryOps;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.entity.EntityTypes;
|
||||
import net.minecraft.world.entity.MobCategory;
|
||||
import net.minecraft.world.entity.npc.villager.Villager;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.DoorBlock;
|
||||
import net.minecraft.world.level.block.StairBlock;
|
||||
import net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.properties.BedPart;
|
||||
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
|
||||
import net.minecraft.world.level.block.state.properties.DoubleBlockHalf;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.levelgen.structure.BoundingBox;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureStart;
|
||||
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
|
||||
import net.minecraft.world.level.material.Fluids;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
import net.minecraft.world.level.storage.loot.LootTable;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
|
||||
/** Opt-in generation-15 proof: bounded natural prospects, native placement, fluid ticks and a second JVM.
|
||||
* It never imports an artificial city into the natural-coverage totals or opens a personal world. */
|
||||
public final class LostCity15WorldGameTests {
|
||||
private static final Set<LostCityUnderground15.Kind> REQUIRED_UNDERGROUND = Set.of(
|
||||
LostCityUnderground15.Kind.SEWER, LostCityUnderground15.Kind.POOLROOM, LostCityUnderground15.Kind.GARAGE);
|
||||
private static final Set<LostCityPlan.Kind> SURFACE_INTEREST = Set.of(
|
||||
LostCityPlan.Kind.BRIDGE, LostCityPlan.Kind.POOL, LostCityPlan.Kind.PARKING);
|
||||
private static final int MAX_DISTRICTS_PER_ISLAND = 6;
|
||||
private static final Direction[] HORIZONTAL = {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST};
|
||||
|
||||
@GameTest(maxTicks = 12000)
|
||||
public void cityFragmentsAndUtilitiesPersistNatively(GameTestHelper helper) {
|
||||
helper.assertTrue(helper.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator,
|
||||
"Lost City15 requires the unified generator");
|
||||
var generator = (SanctuaryChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
|
||||
helper.assertTrue(generator.rootGeneration() == 15, "Lost City15 must use a fresh generation-15 world");
|
||||
if (Boolean.getBoolean("sanctuary.test.expansionReload")) reload(helper, generator);
|
||||
else new Scenario(helper, generator).advance();
|
||||
}
|
||||
|
||||
private static final class Scenario {
|
||||
final GameTestHelper helper;
|
||||
final ServerLevel level;
|
||||
final SanctuaryChunkGenerator generator;
|
||||
final ExpansionRuntime.Session session;
|
||||
final Map<String, Object> report;
|
||||
final List<Map<String, Object>> sites = new ArrayList<>();
|
||||
final List<Map<String, Object>> prospects = new ArrayList<>();
|
||||
final List<Map<String, Object>> naturalPlans = new ArrayList<>();
|
||||
final List<LostCityUnderground15.Module> wetModules = new ArrayList<>();
|
||||
final List<LostCityPlan.Parcel> wetSurfacePools = new ArrayList<>();
|
||||
final Set<LostCityUnderground15.Kind> observedKinds = new HashSet<>();
|
||||
final Set<LostCityPlan.Kind> observedSurfaceKinds = new HashSet<>();
|
||||
final Set<ChunkPos> forced = new HashSet<>();
|
||||
final List<BlockPos> tickWitnesses = new ArrayList<>();
|
||||
int phase, round;
|
||||
String childId;
|
||||
long readinessWaitTick, simulationStartWorldTick = -1;
|
||||
|
||||
Scenario(GameTestHelper helper, SanctuaryChunkGenerator generator) {
|
||||
this.helper = helper; this.level = helper.getLevel(); this.generator = generator;
|
||||
session = ExpansionRuntime.session(level); report = report(level);
|
||||
report.put("sites", sites); report.put("city_prospects", prospects); report.put("natural_plans", naturalPlans);
|
||||
}
|
||||
|
||||
void advance() {
|
||||
try {
|
||||
if (phase == 0) {
|
||||
verifyLegacy(helper, generator);
|
||||
report.put("saved_interior_models", verifyInteriorModels(helper));
|
||||
observeIsland(session.island("sanctuary"));
|
||||
// Always exercise one actual expansion; a second is reserved for missing natural utility types.
|
||||
childId = prospect();
|
||||
helper.assertTrue(childId != null, "The bounded natural prospects found no city with a building; see city_prospects");
|
||||
session.create(childId, "sanctuary", "east", "lost_city", 256, "natural", 2048);
|
||||
phase = 1;
|
||||
}
|
||||
if (phase == 1 && session.island(childId).status().equals("ready")) {
|
||||
observeIsland(session.island(childId));
|
||||
if (!observedKinds.containsAll(REQUIRED_UNDERGROUND) && round < 2) {
|
||||
childId = prospect();
|
||||
helper.assertTrue(childId != null, "Second bounded search cannot supply a natural city reference");
|
||||
session.create(childId, "sanctuary", "east", "lost_city", 256, "natural", 2048);
|
||||
} else {
|
||||
helper.assertTrue(observedKinds.containsAll(REQUIRED_UNDERGROUND),
|
||||
"Natural reference coverage must include sewer, poolroom and garage; found " + observedKinds);
|
||||
helper.assertTrue(sites.size() >= 2, "The reference must inspect several distinct native district starts");
|
||||
for (var module : wetModules) {
|
||||
forceBounds(LostCityUndergroundPiece15.bounds(module));
|
||||
}
|
||||
for (var pool : wetSurfacePools) {
|
||||
forceBounds(new LostCityPiece15(pool).getBoundingBox());
|
||||
}
|
||||
readinessWaitTick = helper.getTick(); phase = 2;
|
||||
}
|
||||
}
|
||||
if (phase == 2 && simulationStartWorldTick < 0) awaitFluidReadiness();
|
||||
if (phase == 2 && simulationStartWorldTick >= 0 && level.getGameTime() - simulationStartWorldTick >= 120) {
|
||||
verifyFluidWitnesses();
|
||||
report.put("natural_fluid_checks", wetModules.stream().map(m -> verifyWater(helper, m, true)).toList());
|
||||
report.put("natural_surface_pool_checks", wetSurfacePools.stream().map(p -> verifySurfacePool(helper, p, true)).toList());
|
||||
removeFluidWitnesses();
|
||||
var witnessSite = sites.stream().filter(site -> site.containsKey("edit_position")).findFirst().orElseThrow();
|
||||
@SuppressWarnings("unchecked") var at = (List<Integer>) witnessSite.get("edit_position");
|
||||
var gold = new BlockPos(at.get(0), at.get(1), at.get(2));
|
||||
level.setBlock(gold, Blocks.GOLD_BLOCK.defaultBlockState(), 3);
|
||||
report.put("gold_witness", coords(gold));
|
||||
report.put("islands", ExpansionIsland.CODEC.listOf().encodeStart(JsonOps.INSTANCE, session.islands()).getOrThrow());
|
||||
report.put("generator_codec", ChunkGenerator.CODEC.encodeStart(RegistryOps.create(JsonOps.INSTANCE, level.registryAccess()), generator).getOrThrow());
|
||||
report.put("journal_sha256", hash(Files.readString(journal(level, generator))));
|
||||
releaseForced();
|
||||
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush all native starts, utility bodies, supports, loot and gameplay edit");
|
||||
report.put("world_flushed_before_shutdown", true); report.put("passed", true);
|
||||
write(level, report, false); helper.succeed(); return;
|
||||
}
|
||||
if (helper.getTick() % 200 == 0) SanctuaryMod.LOGGER.info("Lost City15 integration phase {}: {}", phase, session.progress());
|
||||
helper.runAtTickTime(helper.getTick() + 10, this::advance);
|
||||
} catch (Exception error) {
|
||||
removeFluidWitnesses();
|
||||
releaseForced(); report.put("passed", false); report.put("failure", error.toString());
|
||||
try { write(level, report, false); } catch (IOException ignored) { }
|
||||
throw new IllegalStateException("Lost City15 native creation failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
void releaseForced() {
|
||||
for (var pos : forced) level.setChunkForced(pos.x(), pos.z(), false);
|
||||
forced.clear();
|
||||
}
|
||||
void forceBounds(BoundingBox bounds) {
|
||||
bounds.intersectingChunks().forEach(pos -> { level.setChunkForced(pos.x(), pos.z(), true); forced.add(pos); });
|
||||
}
|
||||
|
||||
void awaitFluidReadiness() throws IOException {
|
||||
var pending = forced.stream().filter(pos -> !level.isPositionTickingWithEntitiesLoaded(pos.pack())).toList();
|
||||
report.put("stage", "waiting_for_native_fluid_scheduler"); report.put("fluid_game_time", level.getGameTime());
|
||||
if (!pending.isEmpty()) {
|
||||
report.put("pending_fluid_chunks", pending.stream().map(pos -> Map.of("chunk", List.of(pos.x(), pos.z()),
|
||||
"ticket", level.shouldTickBlocksAt(pos.pack()), "entities", level.areEntitiesLoaded(pos.pack()),
|
||||
"ticking_future", level.getChunkSource().isPositionTicking(pos.pack()))).toList());
|
||||
helper.assertTrue(helper.getTick() - readinessWaitTick < 800,
|
||||
"Forced natural water chunks did not become scheduler-ready within800ticks: " + pending);
|
||||
if (helper.getTick() % 100 == 0) {
|
||||
SanctuaryMod.LOGGER.info("Lost City15 waiting for actual fluid scheduler: {} pending chunks at world time {}", pending.size(), level.getGameTime());
|
||||
write(level, report, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
report.remove("pending_fluid_chunks");
|
||||
// Establish retention before simulation, so a pre-existing leak cannot masquerade as a scheduling delay.
|
||||
report.put("natural_water_before_simulation", wetModules.stream().map(m -> verifyWater(helper, m, false)).toList());
|
||||
for (var p : wetSurfacePools) verifySurfacePool(helper, p, false);
|
||||
for (var pos : forced.stream().sorted(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z)).toList()) placeFluidWitness(pos);
|
||||
for (var module : wetModules) for (var water : expectedWater(module)) level.scheduleTick(water, Fluids.WATER, 1);
|
||||
for (var pool : wetSurfacePools) for (var water : expectedSurfaceWater(pool)) level.scheduleTick(water, Fluids.WATER, 1);
|
||||
simulationStartWorldTick = level.getGameTime();
|
||||
report.put("stage", "observed_native_fluid_simulation"); report.put("fluid_simulation_start_game_time", simulationStartWorldTick);
|
||||
report.put("temporary_scheduler_witnesses", tickWitnesses.size()); write(level, report, false);
|
||||
}
|
||||
|
||||
void placeFluidWitness(ChunkPos chunk) {
|
||||
BlockPos witness = null;
|
||||
for (int localX : new int[]{4, 8, 12}) for (int localZ : new int[]{4, 8, 12}) {
|
||||
var candidate = new BlockPos(chunk.x() * 16 + localX, 378, chunk.z() * 16 + localZ);
|
||||
boolean empty = true;
|
||||
for (int x = -1; x <= 1; x++) for (int z = -1; z <= 1; z++) for (int y = -4; y <= 0; y++)
|
||||
empty &= level.getBlockState(candidate.offset(x, y, z)).isAir();
|
||||
if (empty && witness == null) witness = candidate;
|
||||
}
|
||||
helper.assertTrue(witness != null, "The independent fluid witness needs empty development sky in " + chunk);
|
||||
tickWitnesses.add(witness);
|
||||
for (int x = -1; x <= 1; x++) for (int z = -1; z <= 1; z++) for (int y = -4; y <= 0; y++)
|
||||
if (x != 0 || z != 0 || y == -4) level.setBlock(witness.offset(x, y, z), Blocks.STONE.defaultBlockState(), 3);
|
||||
helper.assertTrue(level.getFluidState(witness.below()).isEmpty(), "Independent witness starts with an empty cell below its source");
|
||||
level.setBlock(witness, Blocks.WATER.defaultBlockState(), 3); level.scheduleTick(witness, Fluids.WATER, 1);
|
||||
}
|
||||
|
||||
void verifyFluidWitnesses() {
|
||||
helper.assertTrue(forced.stream().allMatch(pos -> level.isPositionTickingWithEntitiesLoaded(pos.pack())),
|
||||
"Every observed natural water chunk must remain scheduler-ready throughout the measured interval");
|
||||
for (var witness : tickWitnesses) helper.assertTrue(level.getFluidState(witness.below()).is(net.minecraft.tags.FluidTags.WATER),
|
||||
"A real native water tick must flow downward in each forced natural-water chunk: " + witness);
|
||||
helper.assertTrue(!tickWitnesses.isEmpty(), "Fluid execution proof cannot be empty");
|
||||
report.put("fluid_execution_proof", Map.of("scope", "Independent enclosed temporary sky witnesses, excluded from natural city and water counts",
|
||||
"chunks", tickWitnesses.size(), "downward_flow_observed", true, "measured_world_ticks", level.getGameTime() - simulationStartWorldTick));
|
||||
}
|
||||
|
||||
void removeFluidWitnesses() {
|
||||
for (var witness : tickWitnesses) {
|
||||
// Empty the fluid before removing its enclosing walls; this leaves the originally empty sky unchanged.
|
||||
for (int y = 0; y >= -3; y--) level.setBlock(witness.offset(0, y, 0), Blocks.AIR.defaultBlockState(), 3);
|
||||
level.getFluidTicks().clearArea(new BoundingBox(witness.getX() - 1, witness.getY() - 4, witness.getZ() - 1,
|
||||
witness.getX() + 1, witness.getY(), witness.getZ() + 1));
|
||||
for (int x = -1; x <= 1; x++) for (int z = -1; z <= 1; z++) for (int y = -4; y <= 0; y++)
|
||||
level.setBlock(witness.offset(x, y, z), Blocks.AIR.defaultBlockState(), 3);
|
||||
}
|
||||
tickWitnesses.clear();
|
||||
}
|
||||
|
||||
String prospect() throws IOException {
|
||||
int bestScore = -1; String best = null;
|
||||
for (int candidateIndex = 0; candidateIndex < 3; candidateIndex++) {
|
||||
String id = "city15_" + round + "_" + candidateIndex;
|
||||
var candidate = session.candidate(id, "sanctuary", "east", "lost_city", 256, "natural", 2048);
|
||||
try {
|
||||
var plan = generator.lostCityPlan(candidate);
|
||||
var underground = generator.undergroundPlan(candidate);
|
||||
var kinds = new HashSet<LostCityUnderground15.Kind>();
|
||||
underground.networks().forEach(n -> n.modules().forEach(m -> kinds.add(m.kind())));
|
||||
var surfaceKinds = new HashSet<LostCityPlan.Kind>();
|
||||
plan.districts().forEach(d -> d.parcels().forEach(p -> surfaceKinds.add(p.kind())));
|
||||
long buildings = plan.districts().stream().flatMap(d -> d.parcels().stream()).filter(p -> p.kind().building()).count();
|
||||
prospects.add(Map.of("id", id, "seed", candidate.seed(), "diameter", candidate.diameter(),
|
||||
"districts", plan.districts().size(), "buildings", buildings, "underground_networks", underground.networks().size(),
|
||||
"underground_kinds", kinds.stream().map(Enum::name).sorted().toList(), "read_only", true,
|
||||
"surface_kinds", surfaceKinds.stream().map(Enum::name).sorted().toList(), "viaduct_piers", generator.citySupports(candidate).size()));
|
||||
SanctuaryMod.LOGGER.info("Lost City15 read-only prospect {} seed {}: {} districts, {} buildings, utility kinds {}",
|
||||
id, candidate.seed(), plan.districts().size(), buildings, kinds);
|
||||
report.put("passed", false); report.put("stage", "read_only_prospects"); write(level, report, false);
|
||||
int score = (int) kinds.stream().filter(REQUIRED_UNDERGROUND::contains).filter(k -> !observedKinds.contains(k)).count() * 1000
|
||||
+ (kinds.contains(LostCityUnderground15.Kind.EXPANSION_HALL) && !observedKinds.contains(LostCityUnderground15.Kind.EXPANSION_HALL) ? 150 : 0)
|
||||
+ (int) surfaceKinds.stream().filter(SURFACE_INTEREST::contains).filter(k -> !observedSurfaceKinds.contains(k)).count() * 100
|
||||
+ Math.min(20, underground.networks().size() * 4) + Math.min(10, plan.districts().size());
|
||||
if (buildings > 0 && score > bestScore) { bestScore = score; best = id; }
|
||||
} finally { generator.discardUnpublishedContext(candidate); }
|
||||
}
|
||||
round++;
|
||||
return best;
|
||||
}
|
||||
|
||||
void observeIsland(ExpansionIsland island) throws IOException {
|
||||
helper.assertTrue(island.generation() == 15, "Reference islands must retain generation 15");
|
||||
var plan = generator.lostCityPlan(island);
|
||||
var underground = generator.undergroundPlan(island);
|
||||
Map<String, Integer> surfaceTypes = new TreeMap<>(), undergroundTypes = new TreeMap<>();
|
||||
plan.districts().forEach(d -> d.parcels().forEach(p -> surfaceTypes.merge(p.kind().name(), 1, Integer::sum)));
|
||||
underground.networks().forEach(n -> n.modules().forEach(m -> undergroundTypes.merge(m.kind().name(), 1, Integer::sum)));
|
||||
naturalPlans.add(Map.of("island", island.id(), "seed", island.seed(), "diameter", island.diameter(),
|
||||
"districts", plan.districts().size(), "surface_kinds", surfaceTypes, "viaduct_supports", generator.citySupports(island).size(),
|
||||
"underground_networks", underground.networks().size(), "underground_kinds", undergroundTypes));
|
||||
SanctuaryMod.LOGGER.info("Lost City15 natural reference {} seed {} before native inspection: {} districts, surface {}, utility {}, {} piers",
|
||||
island.id(), island.seed(), plan.districts().size(), surfaceTypes, undergroundTypes, generator.citySupports(island).size());
|
||||
report.put("passed", false); report.put("stage", "native_inspection_" + island.id()); write(level, report, false);
|
||||
List<LostCityPlan.District> selected = new ArrayList<>(plan.districts());
|
||||
if (selected.isEmpty()) {
|
||||
report.put(island.origin() ? "starter_city_absent" : island.id() + "_city_absent", true);
|
||||
return;
|
||||
}
|
||||
for (int inspected = 0; inspected < MAX_DISTRICTS_PER_ISLAND && !selected.isEmpty(); inspected++) {
|
||||
selected.sort(Comparator.comparingInt((LostCityPlan.District district) -> districtScore(district, underground)).reversed()
|
||||
.thenComparingInt(LostCityPlan.District::startChunkX).thenComparingInt(LostCityPlan.District::startChunkZ));
|
||||
var district = selected.removeFirst();
|
||||
var result = observe(helper, generator, island, district, underground);
|
||||
sites.add(result);
|
||||
district.parcels().forEach(p -> {
|
||||
observedSurfaceKinds.add(p.kind());
|
||||
if (p.kind() == LostCityPlan.Kind.POOL && !expectedSurfaceWater(p).isEmpty()) wetSurfacePools.add(p);
|
||||
});
|
||||
for (var network : networks(district, underground)) for (var module : network.modules()) {
|
||||
observedKinds.add(module.kind());
|
||||
if (!expectedWater(module).isEmpty()) wetModules.add(module);
|
||||
}
|
||||
}
|
||||
}
|
||||
int districtScore(LostCityPlan.District district, LostCityUnderground15.Plan underground) {
|
||||
Set<LostCityUnderground15.Kind> kinds = new HashSet<>();
|
||||
networks(district, underground).forEach(n -> n.modules().forEach(m -> kinds.add(m.kind())));
|
||||
var surfaceKinds = district.parcels().stream().map(LostCityPlan.Parcel::kind).distinct().toList();
|
||||
return (int) kinds.stream().filter(REQUIRED_UNDERGROUND::contains).filter(k -> !observedKinds.contains(k)).count() * 1000
|
||||
+ (kinds.contains(LostCityUnderground15.Kind.EXPANSION_HALL) && !observedKinds.contains(LostCityUnderground15.Kind.EXPANSION_HALL) ? 150 : 0)
|
||||
+ (int) surfaceKinds.stream().filter(SURFACE_INTEREST::contains).filter(k -> !observedSurfaceKinds.contains(k)).count() * 300
|
||||
+ (int) surfaceKinds.stream().filter(LostCityPlan.Kind::building).filter(k -> !observedSurfaceKinds.contains(k)).count() * 20
|
||||
+ (int) district.parcels().stream().filter(p -> p.kind().building()).count();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<LostCityUnderground15.Network> networks(LostCityPlan.District d, LostCityUnderground15.Plan plan) {
|
||||
return plan.networks().stream().filter(n -> n.startChunkX() == d.startChunkX() && n.startChunkZ() == d.startChunkZ()).toList();
|
||||
}
|
||||
|
||||
private static Map<String, Object> observe(GameTestHelper helper, SanctuaryChunkGenerator generator, ExpansionIsland island,
|
||||
LostCityPlan.District district, LostCityUnderground15.Plan underground) {
|
||||
var level = helper.getLevel();
|
||||
var holder = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(LostCityStructures.KEY);
|
||||
var startChunk = level.getChunkSource().getChunk(district.startChunkX(), district.startChunkZ(), ChunkStatus.STRUCTURE_STARTS, true);
|
||||
var start = startChunk.getStartForStructure(holder.value());
|
||||
helper.assertTrue(start != null && start.isValid(), "Accepted generation-15 district needs a native start");
|
||||
int modules = networks(district, underground).stream().mapToInt(n -> n.modules().size()).sum();
|
||||
long piers = generator.citySupports(island).stream().filter(s -> s.startChunkX() == district.startChunkX()
|
||||
&& s.startChunkZ() == district.startChunkZ()).count();
|
||||
helper.assertTrue(start.getPieces().size() == district.parcels().size() + modules + piers,
|
||||
"Native start must save every surface parcel, utility module and viaduct pier");
|
||||
Set<Long> chunks = new HashSet<>();
|
||||
for (var piece : start.getPieces()) piece.getBoundingBox().intersectingChunks().forEach(pos -> {
|
||||
if (!chunks.add(pos.pack())) return;
|
||||
helper.assertTrue(chunks.size() <= 225, "District inspection must stay inside the native reference footprint");
|
||||
var chunk = level.getChunkSource().getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true);
|
||||
helper.assertTrue(chunk instanceof LevelChunk && chunk.getReferencesForStructure(holder.value()).contains(start.getChunkPos().pack()),
|
||||
"Every touched FULL chunk must retain the native structure reference");
|
||||
});
|
||||
var context = StructurePieceSerializationContext.fromLevel(level);
|
||||
var tag = start.createTag(context, start.getChunkPos());
|
||||
var loaded = StructureStart.loadStaticStart(context, tag, island.seed());
|
||||
helper.assertTrue(loaded != null && loaded.isValid() && loaded.createTag(context, loaded.getChunkPos()).equals(tag),
|
||||
"Native starts must round-trip all surface/underground fields, openings, supports and anchors");
|
||||
Map<String, Integer> surfaceKinds = new TreeMap<>(), moduleKinds = new TreeMap<>();
|
||||
List<Map<String, Object>> chests = new ArrayList<>(), rooms = new ArrayList<>(), surfaceAccess = new ArrayList<>();
|
||||
int biomes = 0, supports = 0;
|
||||
for (var piece : start.getPieces()) {
|
||||
if (piece instanceof LostCityPiece15 surface) {
|
||||
var p = surface.parcel(); surfaceKinds.merge(p.kind().name(), 1, Integer::sum);
|
||||
var centre = new BlockPos(p.minX() + p.width() / 2, p.floorY() + 1, p.minZ() + p.depth() / 2);
|
||||
if (level.getBiome(centre).is(SanctuaryMod.id("lost_city"))) biomes++;
|
||||
var monsters = generator.getMobsAt(level, level.structureManager(), MobCategory.MONSTER, centre).unwrap();
|
||||
helper.assertTrue(!monsters.isEmpty() && monsters.stream().allMatch(e -> e.value().type() == EntityTypes.ZOMBIE),
|
||||
"Native city monster overrides must contain vanilla zombies only");
|
||||
if (p.kind().building()) rooms.add(Map.of("kind", p.kind().name(), "min_x", p.minX(), "min_z", p.minZ(),
|
||||
"facing", p.facing(), "walkable_rooms", verifyInterior(helper, p, piece.getBoundingBox())));
|
||||
else if (p.kind() == LostCityPlan.Kind.BRIDGE || p.kind() == LostCityPlan.Kind.PARKING)
|
||||
surfaceAccess.add(verifySurfaceAccess(helper, p, piece.getBoundingBox()));
|
||||
scanBlocks(helper, piece.getBoundingBox(), p.kind().building() ? p.floorY() : Integer.MAX_VALUE, chests);
|
||||
} else if (piece instanceof LostCityUndergroundPiece15 utility) {
|
||||
var m = utility.module(); moduleKinds.merge(m.kind().name(), 1, Integer::sum);
|
||||
var pieceTag = utility.createTag(context);
|
||||
var decoded = new LostCityUndergroundPiece15(context, pieceTag);
|
||||
helper.assertTrue(decoded.module().equals(m) && decoded.createTag(context).equals(pieceTag), "Utility bodies and support records must persist exactly");
|
||||
supports += verifySupports(helper, m);
|
||||
scanBlocks(helper, new BoundingBox(m.minX(), m.minY(), m.minZ(), m.maxX(), m.maxY(), m.maxZ()), m.minY(), chests);
|
||||
} else if (piece instanceof LostCitySupportPiece15 pier) {
|
||||
var pieceTag = pier.createTag(context);
|
||||
var decoded = new LostCitySupportPiece15(context, pieceTag);
|
||||
helper.assertTrue(decoded.support().equals(pier.support()) && decoded.createTag(context).equals(pieceTag),
|
||||
"Viaduct pier geometry and footing must round-trip through native NBT");
|
||||
verifyPier(helper, pier); supports++;
|
||||
} else helper.assertTrue(false, "Generation 15 loaded a historical or unknown native piece type");
|
||||
}
|
||||
for (var network : networks(district, underground)) verifyNetworkAccess(helper, network);
|
||||
helper.assertTrue(chests.size() >= rooms.size(), "Every observed building needs its unopened deferred native loot chest");
|
||||
helper.assertTrue(biomes > 0, "At least one real saved city biome sample must exist per inspected district");
|
||||
var bounds = start.getBoundingBox();
|
||||
helper.assertTrue(level.getEntitiesOfClass(Villager.class, new AABB(bounds.minX(), bounds.minY(), bounds.minZ(),
|
||||
bounds.maxX() + 1, bounds.maxY() + 1, bounds.maxZ() + 1)).isEmpty(), "The abandoned native district must not spawn villagers");
|
||||
var located = generator.findNearestMapStructure(level, HolderSet.direct(holder),
|
||||
new BlockPos(district.startChunkX() * 16 + 8, 220, district.startChunkZ() * 16 + 8), 100, false);
|
||||
helper.assertTrue(located != null && located.getSecond().is(LostCityStructures.KEY), "Exact native locate must find Lost City");
|
||||
var result = new LinkedHashMap<String, Object>();
|
||||
result.put("island", island.id()); result.put("generation", island.generation()); result.put("island_seed", island.seed());
|
||||
result.put("start_chunk", List.of(district.startChunkX(), district.startChunkZ()));
|
||||
result.put("native_start_sha256", hash(tag.toString())); result.put("native_pieces", start.getPieces().size());
|
||||
result.put("full_chunks_inspected", chunks.size()); result.put("surface_kinds", surfaceKinds); result.put("underground_kinds", moduleKinds);
|
||||
result.put("supports_checked", supports); result.put("viaduct_piers", piers);
|
||||
result.put("walkable_buildings", rooms); result.put("biome_witnesses", biomes);
|
||||
result.put("native_surface_access", surfaceAccess);
|
||||
result.put("native_city_circulation", verifyCityCirculation(helper, district.parcels()));
|
||||
result.put("unopened_chests", chests); result.put("native_zombie_only_monster_spawns", true);
|
||||
result.put("native_locate", coords(located.getFirst()));
|
||||
district.parcels().stream().filter(p -> p.kind().building()).findFirst().ifPresent(first ->
|
||||
result.put("edit_position", List.of(first.minX(), first.floorY() + 1, first.minZ())));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void scanBlocks(GameTestHelper helper, BoundingBox box, int noOreFromY, List<Map<String, Object>> chests) {
|
||||
var level = helper.getLevel();
|
||||
for (int x = box.minX(); x <= box.maxX(); x++) for (int z = box.minZ(); z <= box.maxZ(); z++)
|
||||
for (int y = box.minY(); y <= box.maxY(); y++) {
|
||||
var pos = new BlockPos(x, y, z); var block = level.getBlockState(pos);
|
||||
helper.assertTrue(!block.is(Blocks.SPAWNER), "Native city must not contain a zombie spawner");
|
||||
if (y >= noOreFromY) helper.assertTrue(!BuiltInRegistries.BLOCK.getKey(block.getBlock()).getPath().endsWith("_ore"),
|
||||
"Native ore decoration replaced protected masonry/interiors at " + pos);
|
||||
if (block.is(Blocks.CHEST) && level.getBlockEntity(pos) instanceof RandomizableContainerBlockEntity chest && chest.getLootTable() != null)
|
||||
chests.add(Map.of("position", coords(pos), "table", chest.getLootTable().identifier().toString(), "seed", chest.getLootTableSeed()));
|
||||
}
|
||||
}
|
||||
|
||||
private record StreetSection(String description, List<BlockPos> candidates) { }
|
||||
|
||||
/** Only drawn roads and the two front porch columns are walkable in this proof. Natural terrain,
|
||||
* building interiors and roofs cannot disguise disconnected streets or an isolated raised lot. */
|
||||
private static Map<String, Object> verifyCityCirculation(GameTestHelper helper, List<LostCityPlan.Parcel> parcels) {
|
||||
Set<BlockPos> permitted = new HashSet<>();
|
||||
List<StreetSection> sections = new ArrayList<>();
|
||||
List<Map<String, Object>> entrances = new ArrayList<>();
|
||||
List<BlockPos> targets = new ArrayList<>();
|
||||
int streets = 0;
|
||||
for (var parcel : parcels) {
|
||||
if (parcel.kind() == LostCityPlan.Kind.STREET || parcel.kind() == LostCityPlan.Kind.BRIDGE) {
|
||||
streets++;
|
||||
for (int x = 0; x < parcel.width(); x++) for (int z = 0; z < parcel.depth(); z++)
|
||||
for (int rise = 1; rise <= 2; rise++) permitted.add(new BlockPos(parcel.minX() + x, parcel.surfaceY(x, z) + rise, parcel.minZ() + z));
|
||||
boolean eastWest = parcel.width() >= parcel.depth();
|
||||
int length = eastWest ? parcel.width() : parcel.depth();
|
||||
int breadth = eastWest ? parcel.depth() : parcel.width();
|
||||
for (int along = 0; along < length; along++) {
|
||||
List<BlockPos> options = new ArrayList<>();
|
||||
// A closed utility hatch may occupy the exact middle; the adjacent traffic lane
|
||||
// must still cross this complete street section without leaving the road.
|
||||
for (int across = Math.max(0, breadth / 2 - 1); across <= Math.min(breadth - 1, breadth / 2 + 1); across++)
|
||||
for (int rise = 1; rise <= 2; rise++) {
|
||||
int x = eastWest ? along : across, z = eastWest ? across : along;
|
||||
options.add(new BlockPos(parcel.minX() + x, parcel.surfaceY(x, z) + rise, parcel.minZ() + z));
|
||||
}
|
||||
sections.add(new StreetSection(parcel.kind() + " at " + parcel.minX() + "," + parcel.minZ() + " section " + along, options));
|
||||
}
|
||||
} else {
|
||||
BlockPos entrance = rotatedWorld(parcel, parcel.width() / 2, 1, 0);
|
||||
targets.add(entrance);
|
||||
entrances.add(Map.of("kind", parcel.kind().name(), "position", coords(entrance)));
|
||||
permitted.add(entrance);
|
||||
permitted.add(rotatedWorld(parcel, parcel.width() / 2 - 1, 1, 0));
|
||||
}
|
||||
}
|
||||
helper.assertTrue(streets > 0 && !targets.isEmpty(), "A native city must connect actual lots to drawn streets");
|
||||
var source = targets.getFirst();
|
||||
var reached = walk(helper, source, permitted::contains);
|
||||
for (var target : targets) helper.assertTrue(reached.contains(target),
|
||||
"Native city entrance is isolated from its road network: " + source + " to " + target);
|
||||
List<List<Integer>> witnessedSections = new ArrayList<>();
|
||||
for (var section : sections) {
|
||||
var witness = section.candidates().stream().filter(reached::contains).findFirst();
|
||||
helper.assertTrue(witness.isPresent(), "Native street is blocked or disconnected: " + section.description()
|
||||
+ "; candidates " + section.candidates());
|
||||
witnessedSections.add(coords(witness.orElseThrow()));
|
||||
}
|
||||
SanctuaryMod.LOGGER.info("Lost City15 native circulation: {} lots, {} street/bridge pieces, {} crossed sections, {} standing cells from {}",
|
||||
targets.size(), streets, sections.size(), reached.size(), source);
|
||||
return Map.of("scope", "Connected drawn streets and front porches only; no natural terrain, interior or roof shortcuts",
|
||||
"source", coords(source), "entrances", entrances, "street_pieces", streets,
|
||||
"crossed_street_sections", witnessedSections, "reachable_standing_cells", reached.size(), "connected", true);
|
||||
}
|
||||
|
||||
private static int verifyInterior(GameTestHelper helper, LostCityPlan.Parcel p, BoundingBox bounds) {
|
||||
var level = helper.getLevel();
|
||||
Map<BlockPos, BlockState> doors = openDoors(helper, bounds, false);
|
||||
try {
|
||||
var targets = LostCityInteriors15.rooms(p);
|
||||
Set<BlockPos> reached = walk(helper, world(p, targets.getFirst()), bounds::isInside);
|
||||
for (var room : targets) helper.assertTrue(reached.contains(world(p, room)),
|
||||
"Native " + p.kind() + " cannot reach room " + room.name() + " at " + world(p, room));
|
||||
helper.assertTrue(!doors.isEmpty(), "Detailed interiors need real two-block doors");
|
||||
for (var entry : doors.entrySet()) if (entry.getValue().getValue(BlockStateProperties.DOUBLE_BLOCK_HALF) == DoubleBlockHalf.LOWER) {
|
||||
Direction normal = entry.getValue().getValue(BlockStateProperties.HORIZONTAL_FACING);
|
||||
helper.assertTrue(reached.contains(entry.getKey().relative(normal)) && reached.contains(entry.getKey().relative(normal.getOpposite())),
|
||||
"A placed native door has an inaccessible side at " + entry.getKey());
|
||||
for (Direction side : new Direction[]{normal.getClockWise(), normal.getCounterClockWise()}) {
|
||||
var neighbor = doors.get(entry.getKey().relative(side));
|
||||
if (neighbor == null || neighbor.getValue(BlockStateProperties.DOUBLE_BLOCK_HALF) != DoubleBlockHalf.LOWER
|
||||
|| neighbor.getValue(BlockStateProperties.HORIZONTAL_FACING) != normal) continue;
|
||||
var opened = level.getBlockState(entry.getKey()).getCollisionShape(level, entry.getKey()).bounds();
|
||||
helper.assertTrue(switch (side) {
|
||||
case EAST -> opened.maxX <= .25; case WEST -> opened.minX >= .75;
|
||||
case SOUTH -> opened.maxZ <= .25; case NORTH -> opened.minZ >= .75;
|
||||
default -> false;
|
||||
}, "Native double door opens into its partner instead of against its outer jamb at " + entry.getKey());
|
||||
}
|
||||
}
|
||||
for (int x = bounds.minX(); x <= bounds.maxX(); x++) for (int z = bounds.minZ(); z <= bounds.maxZ(); z++)
|
||||
for (int y = p.floorY() + 1; y <= p.maxY(); y++) {
|
||||
var at = new BlockPos(x, y, z); var state = level.getBlockState(at);
|
||||
if (state.is(Blocks.CHEST)) helper.assertTrue(adjacent(at, reached), "Native loot chest cannot be approached from its room");
|
||||
if (state.is(Blocks.LANTERN)) {
|
||||
var support = state.getValue(BlockStateProperties.HANGING) ? at.above() : at.below();
|
||||
helper.assertTrue(level.getBlockState(support).isCollisionShapeFullBlock(level, support) && state.canSurvive(level, at),
|
||||
"Native lantern floats against a slab or lacks a complete support at " + at);
|
||||
}
|
||||
if (state.is(Blocks.DARK_OAK_FENCE) && level.getBlockState(at.above()).is(Blocks.DARK_OAK_PRESSURE_PLATE)) {
|
||||
for (var side : HORIZONTAL) {
|
||||
var chair = at.relative(side); var seat = level.getBlockState(chair);
|
||||
if (!seat.is(Blocks.DARK_OAK_STAIRS)) continue;
|
||||
var shape = seat.getCollisionShape(level, chair).toAabbs();
|
||||
double nearX = .5 - side.getStepX() * .25, nearZ = .5 - side.getStepZ() * .25;
|
||||
double farX = .5 + side.getStepX() * .25, farZ = .5 + side.getStepZ() * .25;
|
||||
helper.assertTrue(shape.stream().noneMatch(box -> box.contains(nearX, .75, nearZ))
|
||||
&& shape.stream().anyMatch(box -> box.contains(farX, .75, farZ)),
|
||||
"Native seat has its back toward its table or desk at " + chair);
|
||||
}
|
||||
}
|
||||
if (!state.is(Blocks.BED.white())) continue;
|
||||
var part = state.getValue(BlockStateProperties.BED_PART);
|
||||
var facing = state.getValue(BlockStateProperties.HORIZONTAL_FACING);
|
||||
var partner = at.relative(part == BedPart.FOOT ? facing : facing.getOpposite());
|
||||
helper.assertTrue(level.getBlockState(partner).equals(state.setValue(BlockStateProperties.BED_PART,
|
||||
part == BedPart.FOOT ? BedPart.HEAD : BedPart.FOOT)), "Native placement severed a bed at " + at);
|
||||
helper.assertTrue(adjacent(at, reached) || adjacent(partner, reached), "Native bed cannot be approached");
|
||||
}
|
||||
return targets.size();
|
||||
} finally { doors.forEach((pos, state) -> level.setBlock(pos, state, 2)); }
|
||||
}
|
||||
|
||||
private static Map<BlockPos, BlockState> openDoors(GameTestHelper helper, BoundingBox bounds, boolean hatch) {
|
||||
var level = helper.getLevel(); Map<BlockPos, BlockState> originals = new HashMap<>();
|
||||
for (int x = bounds.minX(); x <= bounds.maxX(); x++) for (int z = bounds.minZ(); z <= bounds.maxZ(); z++)
|
||||
for (int y = bounds.minY(); y <= bounds.maxY(); y++) {
|
||||
var pos = new BlockPos(x, y, z); var state = level.getBlockState(pos);
|
||||
if (state.getBlock() instanceof DoorBlock) {
|
||||
helper.assertTrue(state.is(Blocks.DARK_OAK_DOOR), "Room doors must be manually openable");
|
||||
boolean lower = state.getValue(BlockStateProperties.DOUBLE_BLOCK_HALF) == DoubleBlockHalf.LOWER;
|
||||
helper.assertTrue(level.getBlockState(lower ? pos.above() : pos.below()).equals(state.setValue(
|
||||
BlockStateProperties.DOUBLE_BLOCK_HALF, lower ? DoubleBlockHalf.UPPER : DoubleBlockHalf.LOWER)),
|
||||
"Native door halves disagree at " + pos);
|
||||
originals.put(pos, state);
|
||||
} else if (hatch && state.is(Blocks.DARK_OAK_TRAPDOOR)) originals.put(pos, state);
|
||||
}
|
||||
// The test opens both native halves, measures the route, then restores all original states.
|
||||
originals.forEach((pos, state) -> level.setBlock(pos, state.setValue(BlockStateProperties.OPEN, true), 2));
|
||||
return originals;
|
||||
}
|
||||
|
||||
private static void verifyNetworkAccess(GameTestHelper helper, LostCityUnderground15.Network network) {
|
||||
var access = network.modules().stream().filter(m -> m.kind() == LostCityUnderground15.Kind.ACCESS).findFirst().orElseThrow();
|
||||
var bounds = LostCityUndergroundPiece15.bounds(access);
|
||||
var doors = openDoors(helper, bounds, true);
|
||||
try {
|
||||
BlockPos hatch = doors.keySet().stream().filter(pos -> doors.get(pos).is(Blocks.DARK_OAK_TRAPDOOR)).findFirst().orElseThrow();
|
||||
Predicate<BlockPos> inside = pos -> network.modules().stream().anyMatch(m -> m.contains(pos.getX(), pos.getY(), pos.getZ()));
|
||||
BlockPos approach = java.util.Arrays.stream(HORIZONTAL).map(hatch::relative)
|
||||
.filter(inside).filter(pos -> standable(helper.getLevel(), pos)).findFirst().orElseThrow();
|
||||
Set<BlockPos> reached = walk(helper, approach, inside);
|
||||
helper.assertTrue(reached.contains(hatch), "Surface hatch must be accessible from its street-level approach");
|
||||
for (var m : network.modules()) helper.assertTrue(reached.stream().anyMatch(pos -> pos.getY() == m.floorY() + 1
|
||||
&& pos.getX() > m.minX() && pos.getX() < m.maxX() && pos.getZ() > m.minZ() && pos.getZ() < m.maxZ()),
|
||||
"Native surface hatch cannot reach utility module " + m.kind() + " at " + m.centerX() + "," + m.centerZ());
|
||||
for (var m : network.modules()) if (m.kind() == LostCityUnderground15.Kind.EXPANSION_HALL) {
|
||||
helper.assertTrue(reached.contains(new BlockPos(m.centerX(), m.minY() + 3, m.centerZ())),
|
||||
"Native expansion hall's reserved central plinth cannot be reached from the surface hatch");
|
||||
for (int dx : new int[]{-6, 6}) for (int dz : new int[]{-6, 6})
|
||||
helper.assertTrue(reached.contains(new BlockPos(m.centerX() + dx, m.floorY() + 1, m.centerZ() + dz)),
|
||||
"Native expansion hall has an inaccessible quarter at " + m.centerX() + "," + m.centerZ());
|
||||
}
|
||||
} finally { doors.forEach((pos, state) -> helper.getLevel().setBlock(pos, state, 2)); }
|
||||
}
|
||||
|
||||
private static int verifySupports(GameTestHelper helper, LostCityUnderground15.Module m) {
|
||||
var level = helper.getLevel();
|
||||
for (var support : m.supports()) {
|
||||
helper.assertTrue(support.maxY() - support.minY() < 8, "Native support exceeds its bounded rock anchor distance");
|
||||
for (int y = support.minY(); y <= support.maxY(); y++) {
|
||||
var expected = support.type() == LostCityUnderground15.Type.PILLAR ? Blocks.STONE_BRICKS
|
||||
: y == support.maxY() ? Blocks.POLISHED_ANDESITE : Blocks.IRON_CHAIN;
|
||||
helper.assertTrue(level.getBlockState(new BlockPos(support.x(), y, support.z())).is(expected),
|
||||
"Native supported module has a broken or overwritten pillar/chain");
|
||||
}
|
||||
int neighborY = support.type() == LostCityUnderground15.Type.PILLAR ? support.minY() - 1 : support.maxY() + 1;
|
||||
var neighbor = level.getBlockState(new BlockPos(support.x(), neighborY, support.z()));
|
||||
helper.assertTrue(neighbor.isSolid() && neighbor.getFluidState().isEmpty(), "Native support lost its rock anchor");
|
||||
}
|
||||
return m.supports().size();
|
||||
}
|
||||
|
||||
private static void verifyPier(GameTestHelper helper, LostCitySupportPiece15 piece) {
|
||||
var support = piece.support(); var level = helper.getLevel();
|
||||
helper.assertTrue(support.maxY() - support.minY() < 16, "Native viaduct grew an unbounded pier");
|
||||
for (int x = support.minX(); x <= support.maxX(); x++) for (int z = support.minZ(); z <= support.maxZ(); z++) {
|
||||
var anchor = level.getBlockState(new BlockPos(x, support.minY() - 1, z));
|
||||
helper.assertTrue(anchor.isSolid() && anchor.getFluidState().isEmpty(), "Native viaduct pier lost its natural footing");
|
||||
for (int y = support.minY(); y <= support.maxY(); y++) {
|
||||
var expected = y == support.minY() || y == support.maxY() ? Blocks.SMOOTH_STONE.defaultBlockState()
|
||||
: LostCityPalette.masonry(support.seed(), x - support.minX(), y - support.minY(), z - support.minZ());
|
||||
helper.assertTrue(level.getBlockState(new BlockPos(x, y, z)).equals(expected), "Native viaduct pier is interrupted or overwritten");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> verifySurfaceAccess(GameTestHelper helper, LostCityPlan.Parcel p, BoundingBox bounds) {
|
||||
BlockPos start, target;
|
||||
if (p.kind() == LostCityPlan.Kind.BRIDGE) {
|
||||
boolean eastWest = p.width() >= p.depth();
|
||||
int startX = eastWest ? 0 : p.width() / 2, startZ = eastWest ? p.depth() / 2 : 0;
|
||||
int endX = eastWest ? p.width() - 1 : p.width() / 2, endZ = eastWest ? p.depth() / 2 : p.depth() - 1;
|
||||
start = new BlockPos(p.minX() + startX, p.surfaceY(startX, startZ) + 1, p.minZ() + startZ);
|
||||
target = new BlockPos(p.minX() + endX, p.surfaceY(endX, endZ) + 1, p.minZ() + endZ);
|
||||
if (!standable(helper.getLevel(), start) && helper.getLevel().getBlockState(start).is(Blocks.STONE_BRICK_STAIRS)) start = start.above();
|
||||
if (!standable(helper.getLevel(), target) && helper.getLevel().getBlockState(target).is(Blocks.STONE_BRICK_STAIRS)) target = target.above();
|
||||
} else {
|
||||
start = rotatedWorld(p, 8, 1, 0);
|
||||
target = rotatedWorld(p, 8, p.foundationDepth() >= 8 ? -4 : 1, 10);
|
||||
}
|
||||
Set<BlockPos> reached = walk(helper, start, bounds::isInside);
|
||||
helper.assertTrue(reached.contains(target), "Native " + p.kind() + " lost its traversable access: " + start + " to " + target);
|
||||
return Map.of("kind", p.kind().name(), "start", coords(start), "destination", coords(target),
|
||||
"underground_garage", p.kind() == LostCityPlan.Kind.PARKING && p.foundationDepth() >= 8, "walkable", true);
|
||||
}
|
||||
|
||||
private static Set<BlockPos> expectedSurfaceWater(LostCityPlan.Parcel p) {
|
||||
return surfaceWater(p).sources();
|
||||
}
|
||||
|
||||
private record SurfaceWater(Set<BlockPos> sources, Set<BlockPos> flowEnvelope,
|
||||
Map<BlockPos, BlockState> shell, Map<BlockPos, BlockState> steps) { }
|
||||
|
||||
private static SurfaceWater surfaceWater(LostCityPlan.Parcel p) {
|
||||
Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
LostCitySurface15.render(p, new LostCityRenderer.BlockWriter() {
|
||||
@Override public void set(int x, int y, int z, BlockState state) {
|
||||
blocks.put(new BlockPos(p.minX() + x, p.floorY() + y, p.minZ() + z), state);
|
||||
}
|
||||
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> loot, long seed) { }
|
||||
});
|
||||
Set<BlockPos> sources = new HashSet<>(), basin = new HashSet<>();
|
||||
Map<BlockPos, BlockState> shell = new HashMap<>(), steps = new HashMap<>();
|
||||
blocks.forEach((pos, state) -> { if (!state.getFluidState().isEmpty()) sources.add(pos); });
|
||||
for (int x = 4; x <= p.width() - 5; x++) for (int z = 5; z <= p.depth() - 5; z++)
|
||||
for (int y = -3; y <= -1; y++) basin.add(rotatedWorld(p, x, y, z));
|
||||
// The submerged steps deliberately clear cells above their lower treads. Native water
|
||||
// may fill those cells; this finite closure permits that flow without permitting a leak.
|
||||
Set<BlockPos> envelope = new HashSet<>(sources);
|
||||
ArrayDeque<BlockPos> queue = new ArrayDeque<>(sources);
|
||||
while (!queue.isEmpty()) {
|
||||
var pos = queue.removeFirst();
|
||||
if (!basin.contains(pos)) throw new IllegalStateException("Rendered pool has an open water boundary at " + pos);
|
||||
for (var direction : Direction.values()) if (direction != Direction.UP) {
|
||||
var next = pos.relative(direction);
|
||||
var state = blocks.getOrDefault(next, Blocks.AIR.defaultBlockState());
|
||||
if (state.isAir() || !state.getFluidState().isEmpty()) {
|
||||
if (envelope.add(next)) queue.addLast(next);
|
||||
} else shell.put(next, state);
|
||||
}
|
||||
}
|
||||
for (int step = 1; step <= 3; step++) for (int x = p.width() / 2 - 1; x <= p.width() / 2 + 1; x++) {
|
||||
var pos = rotatedWorld(p, x, -step, step + 4);
|
||||
if (!sources.isEmpty()) steps.put(pos, blocks.get(pos));
|
||||
}
|
||||
return new SurfaceWater(Set.copyOf(sources), Set.copyOf(envelope), Map.copyOf(shell), Map.copyOf(steps));
|
||||
}
|
||||
|
||||
private static Map<String, Object> verifySurfacePool(GameTestHelper helper, LostCityPlan.Parcel p, boolean ticked) {
|
||||
var level = helper.getLevel(); var water = surfaceWater(p);
|
||||
helper.assertTrue(water.sources().size() >= 150, "Naturally planned surface pool must contain a substantial basin");
|
||||
var witness = water.sources().stream().sorted(BlockPos::compareTo).findFirst().orElseThrow();
|
||||
if (ticked) {
|
||||
helper.assertTrue(level.isPositionTickingWithEntitiesLoaded(ChunkPos.containing(witness).pack()), "Natural surface pool must actually tick");
|
||||
}
|
||||
int count = 0;
|
||||
for (int x = p.minX(); x <= p.maxX(); x++) for (int z = p.minZ(); z <= p.maxZ(); z++)
|
||||
for (int y = p.minY(); y <= p.maxY(); y++) {
|
||||
var at = new BlockPos(x, y, z); boolean wet = !level.getFluidState(at).isEmpty();
|
||||
helper.assertTrue(!wet || water.flowEnvelope().contains(at), "Native surface pool leaked outside its sealed basin at " + at);
|
||||
helper.assertTrue(!wet || level.getFluidState(at).is(net.minecraft.tags.FluidTags.WATER), "Native surface pool contains a foreign fluid at " + at);
|
||||
helper.assertTrue(!water.sources().contains(at) || wet && level.getFluidState(at).isSource(),
|
||||
"Native surface pool lost an original water source at " + at);
|
||||
if (wet) count++;
|
||||
}
|
||||
water.shell().forEach((pos, expected) -> helper.assertTrue(level.getBlockState(pos).equals(expected),
|
||||
"Native surface pool lost its continuous floor or walls at " + pos));
|
||||
water.steps().forEach((pos, expected) -> helper.assertTrue(level.getBlockState(pos).equals(expected),
|
||||
"Native pool steps changed or lost their waterlogging after fluid ticks/reload at " + pos));
|
||||
return Map.of("scope", "Naturally planned surface pool, not an isolated fixture", "min", List.of(p.minX(), p.floorY(), p.minZ()),
|
||||
"facing", p.facing(), "retained_water_cells", count, "checked_after_observed_fluid_simulation", ticked,
|
||||
"contained", true, "steps_preserved", true, "original_water_cells", water.sources().size(),
|
||||
"flow_reachable_cells", water.flowEnvelope().size(), "new_intra_basin_water_cells", count - water.sources().size());
|
||||
}
|
||||
|
||||
private static Set<BlockPos> expectedWater(LostCityUnderground15.Module module) {
|
||||
Set<BlockPos> water = new HashSet<>();
|
||||
LostCityUnderground15.render(module, new LostCityRenderer.BlockWriter() {
|
||||
@Override public void set(int x, int y, int z, BlockState state) {
|
||||
var pos = new BlockPos(module.minX() + x, module.minY() + y, module.minZ() + z);
|
||||
if (!state.getFluidState().isEmpty()) water.add(pos); else water.remove(pos);
|
||||
}
|
||||
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> loot, long seed) { }
|
||||
});
|
||||
return water;
|
||||
}
|
||||
|
||||
private static Map<String, Object> verifyWater(GameTestHelper helper, LostCityUnderground15.Module m, boolean ticked) {
|
||||
var level = helper.getLevel(); var water = expectedWater(m);
|
||||
BlockPos witness = water.stream().sorted(BlockPos::compareTo).findFirst().orElseThrow();
|
||||
if (ticked) {
|
||||
helper.assertTrue(level.isPositionTickingWithEntitiesLoaded(ChunkPos.containing(witness).pack()), "Natural poolroom/sewer fixture must actually tick");
|
||||
}
|
||||
int count = 0;
|
||||
for (int x = m.minX(); x <= m.maxX(); x++) for (int z = m.minZ(); z <= m.maxZ(); z++)
|
||||
for (int y = m.minY(); y <= m.maxY(); y++) {
|
||||
var pos = new BlockPos(x, y, z); boolean wet = !level.getFluidState(pos).isEmpty();
|
||||
helper.assertTrue(wet == water.contains(pos), "Natural utility water leaked or disappeared at " + pos + " in " + m.kind());
|
||||
if (wet) count++;
|
||||
}
|
||||
helper.assertTrue(count > 0, "Native wet module needs retained water");
|
||||
return Map.of("scope", "Naturally planned and natively generated utility module", "kind", m.kind().name(),
|
||||
"min", List.of(m.minX(), m.minY(), m.minZ()), "retained_water_cells", count,
|
||||
"checked_after_observed_fluid_simulation", ticked, "contained", true);
|
||||
}
|
||||
|
||||
private static Set<BlockPos> walk(GameTestHelper helper, BlockPos start, Predicate<BlockPos> inside) {
|
||||
var level = helper.getLevel();
|
||||
helper.assertTrue(inside.test(start) && standable(level, start), "Native route has a blocked starting position: " + start);
|
||||
Set<BlockPos> reached = new HashSet<>(); ArrayDeque<BlockPos> queue = new ArrayDeque<>();
|
||||
reached.add(start); queue.add(start);
|
||||
while (!queue.isEmpty()) {
|
||||
var pos = queue.removeFirst();
|
||||
for (Direction d : HORIZONTAL) for (int dy = -1; dy <= 1; dy++) {
|
||||
var next = pos.relative(d).offset(0, dy, 0);
|
||||
if (!inside.test(next) || dy > 0 && !passable(level.getBlockState(pos.above(2)))) continue;
|
||||
if (standable(level, next) && reached.add(next)) queue.add(next);
|
||||
}
|
||||
for (int dy : new int[]{-1, 1}) {
|
||||
var next = pos.offset(0, dy, 0);
|
||||
if (!inside.test(next)) continue;
|
||||
if ((climbable(level.getBlockState(pos)) || climbable(level.getBlockState(next)) || climbable(level.getBlockState(pos.below())))
|
||||
&& standable(level, next) && reached.add(next)) queue.add(next);
|
||||
}
|
||||
helper.assertTrue(reached.size() < 100_000, "Native room traversal exceeded its bounded structural footprint");
|
||||
}
|
||||
return reached;
|
||||
}
|
||||
private static boolean standable(ServerLevel level, BlockPos pos) {
|
||||
var below = level.getBlockState(pos.below());
|
||||
return passable(level.getBlockState(pos)) && passable(level.getBlockState(pos.above()))
|
||||
&& (below.isSolid() || below.getBlock() instanceof StairBlock || climbable(level.getBlockState(pos)) || climbable(below));
|
||||
}
|
||||
private static boolean climbable(BlockState state) { return state.is(Blocks.LADDER); }
|
||||
private static boolean passable(BlockState state) {
|
||||
return state.isAir() || state.is(Blocks.LADDER) || state.is(Blocks.REDSTONE_WALL_TORCH)
|
||||
|| (state.is(Blocks.DARK_OAK_DOOR) || state.is(Blocks.DARK_OAK_TRAPDOOR)) && state.getValue(BlockStateProperties.OPEN);
|
||||
}
|
||||
private static boolean adjacent(BlockPos pos, Set<BlockPos> reached) {
|
||||
for (Direction direction : HORIZONTAL) if (reached.contains(pos.relative(direction))) return true;
|
||||
return false;
|
||||
}
|
||||
private static BlockPos world(LostCityPlan.Parcel p, LostCityInteriors15.Room room) {
|
||||
return new BlockPos(p.minX() + room.x(), p.floorY() + room.y(), p.minZ() + room.z());
|
||||
}
|
||||
private static BlockPos rotatedWorld(LostCityPlan.Parcel p, int x, int y, int z) {
|
||||
int rx = switch (p.facing()) { case 0 -> x; case 1 -> p.width() - 1 - z; case 2 -> p.width() - 1 - x; default -> z; };
|
||||
int rz = switch (p.facing()) { case 0 -> z; case 1 -> x; case 2 -> p.depth() - 1 - z; default -> p.depth() - 1 - x; };
|
||||
return new BlockPos(p.minX() + rx, p.floorY() + y, p.minZ() + rz);
|
||||
}
|
||||
|
||||
private static List<Map<String, Object>> verifyInteriorModels(GameTestHelper helper) {
|
||||
var context = StructurePieceSerializationContext.fromLevel(helper.getLevel());
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (var kind : LostCityPlan.Kind.values()) if (kind.building()) for (int facing = 0; facing < 4; facing++) {
|
||||
var parcel = new LostCityPlan.Parcel(kind, -32, 48, 17, 17, 160, 3, facing, 1515, 4, new int[0]);
|
||||
var piece = new LostCityPiece15(parcel); var tag = piece.createTag(context);
|
||||
var reloaded = new LostCityPiece15(context, tag);
|
||||
helper.assertTrue(reloaded.createTag(context).equals(tag), "Generation-15 interior parcel fields did not persist");
|
||||
helper.assertTrue(LostCityInteriors15.rooms(parcel).equals(LostCityInteriors15.rooms(reloaded.parcel())), "Reload changed room targets");
|
||||
result.add(Map.of("kind", kind.name(), "facing", facing, "native_piece_sha256", hash(tag.toString()),
|
||||
"scope", "Serialization fixture only; natural occurrence is recorded separately in sites"));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void verifyLegacy(GameTestHelper helper, SanctuaryChunkGenerator generator) {
|
||||
var ops = RegistryOps.create(JsonOps.INSTANCE, helper.getLevel().registryAccess());
|
||||
for (int generation : new int[]{13, 14}) {
|
||||
var json = ChunkGenerator.CODEC.encodeStart(ops, generator).getOrThrow().getAsJsonObject();
|
||||
json.addProperty("type", "sanctuary:island_v" + generation);
|
||||
json.getAsJsonObject("biome_source").addProperty("type", "sanctuary:island_v" + generation);
|
||||
var legacy = (SanctuaryChunkGenerator) ChunkGenerator.CODEC.parse(ops, json).getOrThrow();
|
||||
helper.assertTrue(legacy.rootGeneration() == generation && legacy.journalDirectory().equals("sanctuary-world-v" + generation),
|
||||
"Historical codecs must retain their exact generation and journal");
|
||||
helper.assertTrue(legacy.undergroundPlan(ExpansionIsland.origin(generator.initialDiameter(), helper.getLevel().getSeed(), generation)).isEmpty(),
|
||||
"Historical worlds must not acquire generation-15 underground utilities");
|
||||
}
|
||||
}
|
||||
|
||||
private static void reload(GameTestHelper helper, SanctuaryChunkGenerator generator) {
|
||||
var level = helper.getLevel(); var result = report(level);
|
||||
try {
|
||||
var previous = JsonParser.parseString(Files.readString(path(level, false))).getAsJsonObject();
|
||||
helper.assertTrue(previous.get("passed").getAsBoolean() && previous.get("world_flushed_before_shutdown").getAsBoolean(), "Reload requires the successful creation proof");
|
||||
helper.assertTrue(previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Reload must run in a second JVM");
|
||||
helper.assertTrue(previous.get("seed").getAsLong() == level.getSeed() && previous.get("world_directory").getAsString().equals(
|
||||
level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize().toString()), "Reload must use the same development world and seed");
|
||||
var session = ExpansionRuntime.session(level);
|
||||
helper.assertTrue(previous.get("islands").equals(ExpansionIsland.CODEC.listOf().encodeStart(JsonOps.INSTANCE, session.islands()).getOrThrow()), "Island identities changed on reload");
|
||||
helper.assertTrue(hash(Files.readString(journal(level, generator))).equals(previous.get("journal_sha256").getAsString()), "Expansion journal bytes changed on reload");
|
||||
var gold = readPos(previous.getAsJsonArray("gold_witness"));
|
||||
helper.assertTrue(level.getBlockState(gold).is(Blocks.GOLD_BLOCK), "Existing gameplay edit was lost or regenerated");
|
||||
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(LostCityStructures.KEY);
|
||||
var context = StructurePieceSerializationContext.fromLevel(level);
|
||||
int savedPieces = 0, unopened = 0, roomTargets = 0, supports = 0;
|
||||
List<Map<String, Object>> fluids = new ArrayList<>(), surfacePools = new ArrayList<>(), surfaceAccess = new ArrayList<>(), circulation = new ArrayList<>();
|
||||
Set<String> checkedNetworks = new HashSet<>();
|
||||
for (var element : previous.getAsJsonArray("sites")) {
|
||||
var site = element.getAsJsonObject(); var pos = site.getAsJsonArray("start_chunk");
|
||||
var chunk = level.getChunk(pos.get(0).getAsInt(), pos.get(1).getAsInt(), ChunkStatus.FULL, true);
|
||||
var start = chunk.getStartForStructure(structure.value());
|
||||
helper.assertTrue(start != null && start.isValid(), "Saved generation-15 native start was lost");
|
||||
helper.assertTrue(hash(start.createTag(context, start.getChunkPos()).toString()).equals(site.get("native_start_sha256").getAsString()),
|
||||
"Native pieces, utility bodies, openings or support anchors changed on reload");
|
||||
savedPieces += start.getPieces().size();
|
||||
for (var piece : start.getPieces()) {
|
||||
piece.getBoundingBox().intersectingChunks().forEach(at -> {
|
||||
var full = level.getChunk(at.x(), at.z(), ChunkStatus.FULL, true);
|
||||
helper.assertTrue(full.getReferencesForStructure(structure.value()).contains(start.getChunkPos().pack()), "Saved FULL reference disappeared");
|
||||
});
|
||||
if (piece instanceof LostCityPiece15 surface) {
|
||||
var p = surface.parcel();
|
||||
if (p.kind().building()) roomTargets += verifyInterior(helper, p, piece.getBoundingBox());
|
||||
else if (p.kind() == LostCityPlan.Kind.POOL && !expectedSurfaceWater(p).isEmpty()) surfacePools.add(verifySurfacePool(helper, p, false));
|
||||
else if (p.kind() == LostCityPlan.Kind.BRIDGE || p.kind() == LostCityPlan.Kind.PARKING)
|
||||
surfaceAccess.add(verifySurfaceAccess(helper, p, piece.getBoundingBox()));
|
||||
} else if (piece instanceof LostCityUndergroundPiece15 utility) {
|
||||
supports += verifySupports(helper, utility.module());
|
||||
if (!expectedWater(utility.module()).isEmpty()) fluids.add(verifyWater(helper, utility.module(), false));
|
||||
} else if (piece instanceof LostCitySupportPiece15 pier) { verifyPier(helper, pier); supports++; }
|
||||
}
|
||||
var savedParcels = start.getPieces().stream().filter(LostCityPiece15.class::isInstance)
|
||||
.map(LostCityPiece15.class::cast).map(LostCityPiece15::parcel).toList();
|
||||
circulation.add(verifyCityCirculation(helper, savedParcels));
|
||||
for (var chestElement : site.getAsJsonArray("unopened_chests")) {
|
||||
var saved = chestElement.getAsJsonObject(); var at = readPos(saved.getAsJsonArray("position"));
|
||||
helper.assertTrue(level.getBlockEntity(at) instanceof RandomizableContainerBlockEntity, "Saved deferred chest entity disappeared");
|
||||
var chest = (RandomizableContainerBlockEntity) level.getBlockEntity(at);
|
||||
helper.assertTrue(chest.getLootTable() != null && chest.getLootTable().identifier().toString().equals(saved.get("table").getAsString())
|
||||
&& chest.getLootTableSeed() == saved.get("seed").getAsLong(), "Unopened loot table or seed changed on reload");
|
||||
unopened++;
|
||||
}
|
||||
var island = session.island(site.get("island").getAsString());
|
||||
for (var network : generator.undergroundPlan(island).networks()) {
|
||||
String identity = island.id() + "/" + network.startChunkX() + "/" + network.startChunkZ();
|
||||
if (network.startChunkX() == pos.get(0).getAsInt() && network.startChunkZ() == pos.get(1).getAsInt() && checkedNetworks.add(identity))
|
||||
verifyNetworkAccess(helper, network);
|
||||
}
|
||||
}
|
||||
var readback = new ExpansionJournal(journal(level, generator).getParent(), level.getSeed(), generator.initialDiameter(), 15);
|
||||
helper.assertTrue(readback.healthy() && readback.islands().equals(session.islands()), "Generation-15 journal failed its own strict reopen");
|
||||
result.put("saved_native_pieces", savedPieces); result.put("preserved_deferred_chests", unopened);
|
||||
result.put("walkable_room_targets", roomTargets); result.put("preserved_supports", supports);
|
||||
result.put("natural_surface_pool_checks", surfacePools); result.put("native_surface_access", surfaceAccess);
|
||||
result.put("native_city_circulation", circulation);
|
||||
result.put("natural_fluid_checks", fluids); result.put("gold_witness_preserved", true); result.put("journal_preserved", true);
|
||||
result.put("passed", true); write(level, result, true); helper.succeed();
|
||||
} catch (Exception error) {
|
||||
result.put("passed", false); result.put("failure", error.toString());
|
||||
try { write(level, result, true); } catch (IOException ignored) { }
|
||||
throw new IllegalStateException("Lost City15 native reload failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> report(ServerLevel level) {
|
||||
var result = new LinkedHashMap<String, Object>();
|
||||
result.put("seed", level.getSeed()); result.put("process_id", ProcessHandle.current().pid());
|
||||
result.put("world_directory", level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize().toString());
|
||||
result.put("scope", "Naturally planned generation-15 districts and utility networks in a disposable development world. "
|
||||
+ "At most six read-only prospects and two 256-block expansions; inspection loads at most six district footprints per island, not an entire island. "
|
||||
+ "Normal expansion preparation retains its own bounded chunk scope. Interior serialization fixtures are not natural occurrence evidence. "
|
||||
+ "Independent enclosed temporary sky witnesses prove native fluid execution in each forced chunk and are removed before saving; they are excluded from natural water counts. "
|
||||
+ "Native doors/hatches are temporarily opened and restored for player-sized route checks. No personal world is opened.");
|
||||
return result;
|
||||
}
|
||||
private static List<Integer> coords(BlockPos pos) { return List.of(pos.getX(), pos.getY(), pos.getZ()); }
|
||||
private static BlockPos readPos(com.google.gson.JsonArray value) { return new BlockPos(value.get(0).getAsInt(), value.get(1).getAsInt(), value.get(2).getAsInt()); }
|
||||
private static Path journal(ServerLevel level, SanctuaryChunkGenerator generator) {
|
||||
return level.getServer().getWorldPath(LevelResource.ROOT).resolve("data").resolve(generator.journalDirectory()).resolve("expansions.json");
|
||||
}
|
||||
private static Path path(ServerLevel level, boolean reload) {
|
||||
return Path.of("diagnostics", "lost-city-alpha15" + (reload ? "-reload" : "") + "-seed-" + level.getSeed() + ".json");
|
||||
}
|
||||
private static void write(ServerLevel level, Map<String, Object> report, boolean reload) throws IOException {
|
||||
var path = path(level, reload); Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n");
|
||||
}
|
||||
private static String hash(String text) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (NoSuchAlgorithmException impossible) { throw new AssertionError(impossible); }
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -60,9 +60,9 @@ public final class PopulationDiagnostics {
|
||||
}
|
||||
if (level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator) {
|
||||
helper.assertTrue(generator.getBiomeSource() instanceof SanctuaryBiomeSource
|
||||
&& generator.getBiomeSource().possibleBiomes().size() == (((SanctuaryChunkGenerator) level.getChunkSource().getGenerator()).rootGeneration() == 14 ? 45 : 44)
|
||||
&& 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 only in generation 14");
|
||||
"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 {
|
||||
|
||||
+4
-1
@@ -36,6 +36,9 @@ final class UnifiedWorldSettingsDiagnostics {
|
||||
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 generator = (SanctuaryChunkGenerator) standard.overworld();
|
||||
helper.assertTrue(generator.initialDiameter() == 724, "The public preset defaults to Medium / 724 blocks");
|
||||
for (var tag : List.of(WorldPresetTags.NORMAL, WorldPresetTags.EXTENDED)) {
|
||||
@@ -46,7 +49,7 @@ final class UnifiedWorldSettingsDiagnostics {
|
||||
}
|
||||
|
||||
var ops = RegistryOps.create(JsonOps.INSTANCE, access);
|
||||
var activeCodec = generator.rootGeneration() == 14 ? SanctuaryChunkGenerator.CITY_CODEC : SanctuaryChunkGenerator.CODEC;
|
||||
var activeCodec = 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("initial_diameter");
|
||||
helper.assertTrue(activeCodec.codec().parse(ops, serialized).getOrThrow().initialDiameter() == 724,
|
||||
|
||||
+7
-2
@@ -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") || Boolean.getBoolean("sanctuary.test.lostCity") || Boolean.getBoolean("sanctuary.test.voidReuse")) {
|
||||
if (Boolean.getBoolean("sanctuary.test.expansion") || Boolean.getBoolean("sanctuary.test.lostCity")
|
||||
|| 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", 15);
|
||||
if (generation != 13 && generation != 14 && generation != 15) throw new IllegalArgumentException("Unsupported test generation");
|
||||
return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id(generation == 15 ? "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) {
|
||||
|
||||
@@ -53,6 +53,8 @@ public final class SanctuaryMod implements ModInitializer {
|
||||
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);
|
||||
fr.koka.sanctuary.worldgen.city.LostCityStructures.register();
|
||||
Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("population_island"), PopulationIslandDensity.CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("population_island"), PopulationIslandBiomeSource.CODEC);
|
||||
|
||||
@@ -79,13 +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);
|
||||
int limit = island.generation() == 14 ? 8000 : MAX_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++)
|
||||
if (island.generation() != 14 || island.ownsChunk(x, z)) positions.add(new ChunkPos(x, z));
|
||||
if (!ExpansionIsland.spatialGeneration(island.generation()) || island.ownsChunk(x, z)) positions.add(new ChunkPos(x, z));
|
||||
return positions;
|
||||
}
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ public final class ExpansionCommands {
|
||||
String direction = word(c, "direction");
|
||||
if (!ExpansionIsland.DIRECTIONS.contains(direction)) throw new IllegalArgumentException("invalid_direction");
|
||||
String id = "island_" + next;
|
||||
int chosenDiameter = diameter != 0 ? diameter : session.islands().getFirst().generation() == 14
|
||||
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);
|
||||
|
||||
@@ -38,11 +38,11 @@ public record ExpansionIsland(String id, String parent, String direction, String
|
||||
public ExpansionIsland {
|
||||
if (!id.matches("[a-z][a-z0-9_]{0,31}") || !parent.matches("[a-z][a-z0-9_]{0,31}")) throw new IllegalArgumentException("Invalid island identifier");
|
||||
if (!DIRECTIONS.contains(direction) || !CLIMATES.contains(climate) || !RELIEFS.contains(relief)) throw new IllegalArgumentException("Unknown island profile");
|
||||
if (!((generation == 14 ? DIAMETERS : LEGACY_DIAMETERS).contains(diameter) || (id.equals("sanctuary") && (diameter == 724 || (generation == 13 || generation == 14) && 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 != 14 && !(generation == 13 && id.equals("sanctuary")))) throw new IllegalArgumentException("Unsupported island state or generation");
|
||||
if (climate.equals("lost_city") && (generation != 14 || id.equals("sanctuary"))) throw new IllegalArgumentException("Lost City requires a generation 14 expansion");
|
||||
if (reuseToken == null || !reuseToken.isEmpty() && (generation != 14 || id.equals("sanctuary") || !reuseToken.matches("[0-9a-f]{64}")))
|
||||
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 or 15 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");
|
||||
}
|
||||
|
||||
@@ -53,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; }
|
||||
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 || generation == 14) && 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 == 14 ? 0 : 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);
|
||||
@@ -66,7 +67,7 @@ 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 (generation == 14 && other.generation() == 14) return ExpansionSpatialPlacement14.conflicts(this, 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();
|
||||
}
|
||||
|
||||
@@ -53,8 +53,8 @@ public final class ExpansionJournal {
|
||||
}
|
||||
|
||||
ExpansionJournal(Path directory, long seed, int rootDiameter, int rootGeneration, DirectorySync directorySync) throws IOException {
|
||||
if (rootGeneration != 12 && rootGeneration != 13 && rootGeneration != 14) throw new IOException("Unsupported root generation");
|
||||
if (rootDiameter != 512 && rootDiameter != 724 && !((rootGeneration == 13 || rootGeneration == 14) && rootDiameter == 1024))
|
||||
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);
|
||||
@@ -159,7 +159,7 @@ public final class ExpansionJournal {
|
||||
for (JsonElement element : entries) {
|
||||
JsonObject entry = element.getAsJsonObject();
|
||||
var fields = new java.util.HashSet<>(ISLAND_FIELDS);
|
||||
if (rootGeneration == 14 && entry.has("reuse_token")) fields.add("reuse_token");
|
||||
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(),
|
||||
@@ -181,13 +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() != (rootGeneration == 14 ? 14 : 12)) throw new IOException("Expansion generation does not match its world contract");
|
||||
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 (!(island.generation() == 14 ? ExpansionSpatialPlacement14.followsDirection(island.direction(), dx, dz) : 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);
|
||||
}
|
||||
|
||||
@@ -23,15 +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 generation = islands.getFirst().generation() == 14 ? 14 : ExpansionIsland.GENERATION;
|
||||
if ((climate.equals("lost_city") || diameter == 1024) && generation != 14) throw new IllegalArgumentException("city_generation_required");
|
||||
int rootGeneration = islands.getFirst().generation();
|
||||
int generation = ExpansionIsland.spatialGeneration(rootGeneration) ? rootGeneration : ExpansionIsland.GENERATION;
|
||||
if ((climate.equals("lost_city") || diameter == 1024) && !ExpansionIsland.spatialGeneration(generation)) throw new IllegalArgumentException("city_generation_required");
|
||||
var parent = islands.stream().filter(i -> i.id().equals(parentId)).findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("unknown_island"));
|
||||
if (!parent.status().equals("ready")) throw new IllegalArgumentException("parent_not_ready");
|
||||
int ordinal = ExpansionIsland.DIRECTIONS.indexOf(direction);
|
||||
var sample = new ExpansionIsland(id, parentId, direction, climate, diameter, 0, 0,
|
||||
ExpansionIsland.deriveSeed(worldSeed, id), relief, "reserved", generation);
|
||||
if (generation == 14) return ExpansionSpatialPlacement14.candidates(islands, parent, sample, minimumDistance);
|
||||
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;
|
||||
|
||||
@@ -111,7 +111,7 @@ public final class ExpansionRuntime {
|
||||
for (var value : candidates) {
|
||||
ExpansionAdmission.checkDeadline(deadline);
|
||||
try {
|
||||
var inspection = value.generation() == 14 ? ExpansionVoidReuse.inspect(level, value, deadline) : null;
|
||||
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));
|
||||
@@ -271,7 +271,7 @@ public final class ExpansionRuntime {
|
||||
int radius = island.terrainRadius() + ExpansionIsland.DECORATION_MARGIN;
|
||||
for (int x = (island.centerX() - radius) >> 4; x <= (island.centerX() + radius) >> 4; x++)
|
||||
for (int z = (island.centerZ() - radius) >> 4; z <= (island.centerZ() + radius) >> 4; z++)
|
||||
if (island.generation() != 14 || island.ownsChunk(x, z)) chunks.add(new ChunkPos(x, z));
|
||||
if (!ExpansionIsland.spatialGeneration(island.generation()) || island.ownsChunk(x, z)) chunks.add(new ChunkPos(x, z));
|
||||
}
|
||||
}
|
||||
public static boolean safeLanding(ServerLevel level, BlockPos feet) {
|
||||
|
||||
+3
-3
@@ -22,8 +22,8 @@ public final class ExpansionSpatialPlacement14 {
|
||||
* 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 (parent.generation() != 14 || prototype.generation() != 14 || prototype.origin())
|
||||
throw new IllegalArgumentException("Spatial placement requires generation 14 expansions");
|
||||
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);
|
||||
@@ -46,7 +46,7 @@ public final class ExpansionSpatialPlacement14 {
|
||||
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", 14);
|
||||
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);
|
||||
|
||||
@@ -126,6 +126,10 @@ public final class ExpansionStructures {
|
||||
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))
|
||||
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;
|
||||
|
||||
@@ -84,8 +84,8 @@ public final class ExpansionVoidReuse {
|
||||
public static Inspection inspect(ServerLevel level, ExpansionIsland island, long deadline) throws IOException {
|
||||
requireServerThread(level);
|
||||
if (!(level.getChunkSource().getGenerator() instanceof ExpansionChunkGenerator generator)
|
||||
|| generator.rootGeneration() != 14 || island.generation() != 14 || island.origin())
|
||||
throw new IOException("Void reuse is restricted to a new generation-14 expansion");
|
||||
|| !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();
|
||||
@@ -172,7 +172,7 @@ public final class ExpansionVoidReuse {
|
||||
/** 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 (generator.rootGeneration() != 14) return;
|
||||
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.
|
||||
@@ -362,7 +362,7 @@ public final class ExpansionVoidReuse {
|
||||
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/sanctuary-world-v14/void-reuse").resolve(token + ".json");
|
||||
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)
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ public abstract class UnifiedDecorationProtectionMixin {
|
||||
private void sanctuary$keepIslandOwnership(BlockPos pos, CallbackInfoReturnable<Boolean> callback) {
|
||||
var region = (WorldGenRegion) (Object) this;
|
||||
if (!(region.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator generator)
|
||||
|| generator.rootGeneration() != 14) return;
|
||||
|| !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);
|
||||
|
||||
@@ -30,20 +30,25 @@ public final class SanctuaryBiomeSource extends ExpansionBiomeSource {
|
||||
).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, true)));
|
||||
).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)));
|
||||
|
||||
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, false);
|
||||
this(biomes, 13);
|
||||
}
|
||||
private SanctuaryBiomeSource(List<Holder<Biome>> biomes, boolean cities) {
|
||||
private SanctuaryBiomeSource(List<Holder<Biome>> biomes, int generation) {
|
||||
super(biomes);
|
||||
this.cities = cities;
|
||||
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;
|
||||
@@ -56,13 +61,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, cities); }
|
||||
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 cities ? CITY_CODEC : CODEC; }
|
||||
@Override protected MapCodec<SanctuaryBiomeSource> codec() { return generation == 15 ? CITY15_CODEC : cities ? CITY_CODEC : CODEC; }
|
||||
|
||||
@Override public BiomeResolver createResolver(Climate.Sampler sampler) {
|
||||
BiomeResolver woodlands = population.createResolver(sampler);
|
||||
@@ -85,7 +91,7 @@ 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() == 14 && island.contains(x, z)) {
|
||||
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;
|
||||
}
|
||||
|
||||
+68
-4
@@ -7,6 +7,7 @@ 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 java.util.List;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
@@ -28,6 +29,10 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator {
|
||||
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)));
|
||||
|
||||
private volatile Context originContext;
|
||||
private final int generation;
|
||||
@@ -36,6 +41,10 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator {
|
||||
// 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 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();
|
||||
@@ -54,10 +63,10 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator {
|
||||
private SanctuaryChunkGenerator(SanctuaryBiomeSource source, int initialDiameter, int generation) {
|
||||
super(source, initialDiameter, true);
|
||||
this.generation = generation;
|
||||
if ((generation == 14) != source.citiesEnabled()) throw new IllegalArgumentException("Sanctuary generator and biome source versions must agree");
|
||||
if (generation != source.generation()) throw new IllegalArgumentException("Sanctuary generator and biome source versions must agree");
|
||||
}
|
||||
@Override public SanctuaryBiomeSource source() { return (SanctuaryBiomeSource) super.source(); }
|
||||
@Override protected MapCodec<SanctuaryChunkGenerator> codec() { return generation == 14 ? CITY_CODEC : CODEC; }
|
||||
@Override protected MapCodec<SanctuaryChunkGenerator> codec() { return generation == 15 ? CITY15_CODEC : generation == 14 ? CITY_CODEC : CODEC; }
|
||||
public SanctuaryChunkGenerator withInitialDiameter(int diameter) {
|
||||
return new SanctuaryChunkGenerator(source().copy(), diameter, generation);
|
||||
}
|
||||
@@ -89,6 +98,7 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator {
|
||||
@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());
|
||||
@@ -100,9 +110,23 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator {
|
||||
super.setIslands(islands);
|
||||
}
|
||||
private void prepareCities(List<ExpansionIsland> islands) {
|
||||
if (generation != 14) return;
|
||||
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;
|
||||
}
|
||||
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();
|
||||
}
|
||||
// Rejected read-only prospects remain private and never enter the biome source.
|
||||
activeCityPlans = java.util.Map.copyOf(published);
|
||||
source().setCityPlans(published);
|
||||
@@ -117,16 +141,42 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator {
|
||||
var key = cityIdentity(island);
|
||||
if (source().islands().stream().noneMatch(saved -> cityIdentity(saved).equals(key))) {
|
||||
cityPlans.remove(key);
|
||||
undergroundPlans.remove(key);
|
||||
citySupports.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 (generation != 14 || island.generation() != 14 || (!island.origin() && !island.climate().equals("lost_city")))
|
||||
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 == 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);
|
||||
@@ -147,12 +197,26 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator {
|
||||
});
|
||||
}
|
||||
public boolean protectsCityDecoration(net.minecraft.core.BlockPos pos) {
|
||||
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());
|
||||
}
|
||||
/** 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. */
|
||||
|
||||
+468
@@ -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,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,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); }
|
||||
}
|
||||
}
|
||||
+76
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-4
@@ -14,9 +14,9 @@ public final class LostCityStructure extends Structure {
|
||||
|
||||
@Override protected Optional<GenerationStub> findGenerationPoint(GenerationContext context) {
|
||||
if (!(context.chunkGenerator() instanceof SanctuaryChunkGenerator generator)
|
||||
|| generator.rootGeneration() != 14) return Optional.empty();
|
||||
|| !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() != 14) return Optional.empty();
|
||||
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();
|
||||
@@ -28,8 +28,22 @@ public final class LostCityStructure extends Structure {
|
||||
|| 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 ->
|
||||
district.parcels().forEach(parcel -> pieces.addPiece(new LostCityPiece(parcel)))));
|
||||
return Optional.of(new GenerationStub(LostCityStructures.locator(district), pieces -> {
|
||||
if (generator.rootGeneration() == 15) for (var support : generator.citySupports(island)) {
|
||||
if (support.startChunkX() == district.startChunkX() && support.startChunkZ() == district.startChunkZ())
|
||||
pieces.addPiece(new LostCitySupportPiece15(support));
|
||||
}
|
||||
district.parcels().forEach(parcel -> pieces.addPiece(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)));
|
||||
}
|
||||
}));
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
+10
-4
@@ -34,6 +34,12 @@ public final class LostCityStructures {
|
||||
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);
|
||||
private static final ThreadLocal<Integer> PLACEMENT_DEPTH = ThreadLocal.withInitial(() -> 0);
|
||||
|
||||
private LostCityStructures() {}
|
||||
@@ -55,9 +61,9 @@ public final class LostCityStructures {
|
||||
ChunkGeneratorStructureState state, StructureManager manager, ChunkAccess chunk,
|
||||
StructureTemplateManager templates, ResourceKey<Level> level) {
|
||||
if (!manager.shouldGenerateStructures() || !(generator instanceof SanctuaryChunkGenerator sanctuary)
|
||||
|| generator.rootGeneration() != 14) return;
|
||||
|| !fr.koka.sanctuary.expansion.ExpansionIsland.spatialGeneration(generator.rootGeneration())) return;
|
||||
var island = generator.islandForChunk(chunk.getPos().x(), chunk.getPos().z());
|
||||
if (island == null || island.generation() != 14) return;
|
||||
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;
|
||||
@@ -77,13 +83,13 @@ public final class LostCityStructures {
|
||||
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)
|
||||
|| generator.rootGeneration() != 14 || !level.structureManager().shouldGenerateStructures()) return null;
|
||||
|| !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() != 14 || !island.status().equals("ready")) continue;
|
||||
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);
|
||||
|
||||
+68
@@ -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,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,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(); }
|
||||
}
|
||||
+492
@@ -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); }
|
||||
}
|
||||
+88
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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": "Supported diameters are 64, 128, 256, 512 and 1024 blocks (1024 requires generation 14).",
|
||||
"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.",
|
||||
@@ -121,7 +121,7 @@
|
||||
"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 new Sanctuary alpha.14 world. This save keeps its original generation.",
|
||||
"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."
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
"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 nouveau monde Sanctuary alpha.14. Cette sauvegarde conserve sa génération.",
|
||||
"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."
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"minecraft:overworld": {
|
||||
"type": "sanctuary:sanctuary_384",
|
||||
"generator": {
|
||||
"type": "sanctuary:island_v14",
|
||||
"type": "sanctuary:island_v15",
|
||||
"biome_source": {
|
||||
"biomes": [
|
||||
"minecraft:the_void",
|
||||
|
||||
+81
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,22 @@ public final class ExpansionJournalSmoke {
|
||||
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");
|
||||
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");
|
||||
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
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.ArrayDeque;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.level.EmptyBlockGetter;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.DoorBlock;
|
||||
import net.minecraft.world.level.block.Rotation;
|
||||
import net.minecraft.world.level.block.StairBlock;
|
||||
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.storage.loot.LootTable;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
|
||||
/** Exercises final block geometry rather than reconstructing the painter's room allocation. */
|
||||
public final class LostCityInteriors15Smoke {
|
||||
private static final Kind[] KINDS = {Kind.APARTMENT, Kind.HOTEL, Kind.LIBRARY,
|
||||
Kind.RESTAURANT, Kind.BARRACKS, Kind.HALL};
|
||||
private static final Direction[] HORIZONTAL = {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST};
|
||||
|
||||
public static void main(String[] args) {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
run();
|
||||
System.out.println("LostCityInteriors15Smoke: six complete buildings, four rotations, deterministic saved parcels, connected rooms and stairs, outward door hinges, seats facing their tables/stage, solid lantern supports, complete beds and deferred loot passed.");
|
||||
}
|
||||
|
||||
/** Also usable after the engine has already bootstrapped its registries. */
|
||||
public static void run() {
|
||||
Set<Map<BlockPos, BlockState>> distinct = new HashSet<>();
|
||||
for (Kind kind : KINDS) for (int floors : new int[]{2, 3, 5}) for (long seed : new long[]{0, 42}) {
|
||||
Capture canonical = render(parcel(kind, floors, seed, 0));
|
||||
if (floors == 2 && seed == 0) distinct.add(canonical.blocks);
|
||||
for (int facing = 0; facing < 4; facing++) {
|
||||
Parcel parcel = parcel(kind, floors, seed, facing);
|
||||
Capture result = facing == 0 ? canonical : render(parcel);
|
||||
Capture repeated = render(parcel);
|
||||
require(result.blocks.equals(repeated.blocks) && result.loot.equals(repeated.loot), "Saved parcel is not deterministic");
|
||||
rotationMatches(canonical, result, facing);
|
||||
require(result.blocks.values().stream().anyMatch(state -> state.is(Blocks.STONE)), "Missing stone");
|
||||
require(result.blocks.values().stream().anyMatch(state -> state.is(Blocks.SMOOTH_STONE)), "Missing smooth stone");
|
||||
require(result.blocks.values().stream().noneMatch(state -> state.is(Blocks.SPAWNER)
|
||||
|| !state.getFluidState().isEmpty()), "Building introduced a spawner or free flowing fluid");
|
||||
require(result.loot.size() == 1, "Building must keep one deferred loot chest");
|
||||
var entrance = LostCityInteriors15.rooms(parcel).getFirst();
|
||||
Set<BlockPos> reached = walk(result, position(entrance));
|
||||
for (var room : LostCityInteriors15.rooms(parcel)) require(reached.contains(position(room)),
|
||||
label(parcel) + " cannot walk from its entrance to " + room.name() + " at " + position(room));
|
||||
verifyDoors(result, reached);
|
||||
verifyBeds(result, reached);
|
||||
verifyFurnitureDirections(result);
|
||||
verifyLanternSupports(result);
|
||||
for (BlockPos chest : result.loot.keySet()) require(adjacentReached(chest, reached),
|
||||
label(parcel) + " loot cannot be approached");
|
||||
semantics(result);
|
||||
}
|
||||
}
|
||||
require(distinct.size() == KINDS.length, "Building uses only labels to vary an identical interior");
|
||||
boolean rejected = false;
|
||||
try { LostCityInteriors15.rooms(new Parcel(Kind.PARK, 0, 0, 17, 17, 160, 2, 0, 0, 4, new int[0])); }
|
||||
catch (IllegalArgumentException expected) { rejected = true; }
|
||||
require(rejected, "Interior dispatch must reject non-building parcels");
|
||||
}
|
||||
|
||||
private static Parcel parcel(Kind kind, int floors, long seed, int facing) {
|
||||
return new Parcel(kind, -81, 119, 17, 17, 160, floors, facing, seed, 4, new int[0]);
|
||||
}
|
||||
private static Capture render(Parcel parcel) {
|
||||
var result = new Capture(parcel);
|
||||
LostCityInteriors15.render(parcel, result);
|
||||
return result;
|
||||
}
|
||||
private static String label(Parcel p) { return p.kind() + " floors=" + p.floors() + " facing=" + p.facing() + " seed=" + p.seed(); }
|
||||
private static BlockPos position(LostCityInteriors15.Room room) { return new BlockPos(room.x(), room.y(), room.z()); }
|
||||
|
||||
private static void rotationMatches(Capture canonical, Capture rotated, int facing) {
|
||||
require(canonical.blocks.size() == rotated.blocks.size(), "Rotation changed the number of emitted positions");
|
||||
Rotation rotation = switch (facing) {
|
||||
case 0 -> Rotation.NONE; case 1 -> Rotation.CLOCKWISE_90;
|
||||
case 2 -> Rotation.CLOCKWISE_180; default -> Rotation.COUNTERCLOCKWISE_90;
|
||||
};
|
||||
for (var entry : canonical.blocks.entrySet()) {
|
||||
BlockPos target = rotate(entry.getKey(), facing);
|
||||
// Native chest placement owns its final facing, so its loot position is checked separately.
|
||||
if (canonical.loot.containsKey(entry.getKey())) continue;
|
||||
require(rotated.block(target).equals(entry.getValue().rotate(rotation)), "Geometry or block-state rotation drifted at " + target);
|
||||
}
|
||||
for (var entry : canonical.loot.entrySet()) require(entry.getValue().equals(rotated.loot.get(rotate(entry.getKey(), facing))),
|
||||
"Rotation changed deferred loot identity or position");
|
||||
}
|
||||
|
||||
private static void verifyDoors(Capture result, Set<BlockPos> reached) {
|
||||
long lowerDoors = 0, pairedLeaves = 0;
|
||||
for (var entry : result.blocks.entrySet()) {
|
||||
BlockState state = entry.getValue();
|
||||
if (!(state.getBlock() instanceof DoorBlock)) continue;
|
||||
require(state.is(Blocks.DARK_OAK_DOOR) && !state.getValue(BlockStateProperties.POWERED), "A room door cannot be opened by an ordinary player");
|
||||
boolean lower = state.getValue(BlockStateProperties.DOUBLE_BLOCK_HALF) == DoubleBlockHalf.LOWER;
|
||||
BlockPos partner = lower ? entry.getKey().above() : entry.getKey().below();
|
||||
BlockState expected = state.setValue(BlockStateProperties.DOUBLE_BLOCK_HALF,
|
||||
lower ? DoubleBlockHalf.UPPER : DoubleBlockHalf.LOWER);
|
||||
require(result.block(partner).equals(expected), label(result.parcel) + " has an incomplete/mismatched door at " + entry.getKey());
|
||||
if (!lower) continue;
|
||||
lowerDoors++;
|
||||
require(result.block(entry.getKey().below()).isSolid(), "Door has no solid threshold");
|
||||
Direction normal = state.getValue(BlockStateProperties.HORIZONTAL_FACING);
|
||||
require(reached.contains(entry.getKey().relative(normal)) && reached.contains(entry.getKey().relative(normal.getOpposite())),
|
||||
label(result.parcel) + " door does not connect two walkable sides at " + entry.getKey());
|
||||
for (Direction side : new Direction[]{normal.getClockWise(), normal.getCounterClockWise()}) {
|
||||
BlockState neighbor = result.block(entry.getKey().relative(side));
|
||||
if (!neighbor.is(Blocks.DARK_OAK_DOOR)
|
||||
|| neighbor.getValue(BlockStateProperties.DOUBLE_BLOCK_HALF) != DoubleBlockHalf.LOWER
|
||||
|| neighbor.getValue(BlockStateProperties.HORIZONTAL_FACING) != normal) continue;
|
||||
AABB opened = state.setValue(BlockStateProperties.OPEN, true)
|
||||
.getCollisionShape(EmptyBlockGetter.INSTANCE, entry.getKey()).bounds();
|
||||
require(againstOppositeEdge(opened, side), label(result.parcel) + " double door opens into its partner at " + entry.getKey());
|
||||
pairedLeaves++;
|
||||
}
|
||||
}
|
||||
require(lowerDoors >= result.parcel.floors() * 3L + 2, "Missing actual doors between distinct rooms");
|
||||
require(pairedLeaves >= 2, "Entrance must retain a double door with its hinges against the outside jambs");
|
||||
for (var room : LostCityInteriors15.rooms(result.parcel)) if (room.name().endsWith("/stair_landing")) {
|
||||
BlockPos landing = position(room);
|
||||
boolean opensIntoStairwell = false;
|
||||
for (Direction side : HORIZONTAL) {
|
||||
BlockPos at = landing.relative(side);
|
||||
BlockState state = result.block(at);
|
||||
if (state.is(Blocks.DARK_OAK_DOOR)) opensIntoStairwell |=
|
||||
at.relative(state.getValue(BlockStateProperties.HORIZONTAL_FACING)).equals(landing);
|
||||
}
|
||||
require(opensIntoStairwell, "Stairwell door must open into its landing, away from the central corridor");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean againstOppositeEdge(AABB box, Direction towardPartner) {
|
||||
return switch (towardPartner) {
|
||||
case EAST -> box.maxX <= .25; case WEST -> box.minX >= .75;
|
||||
case SOUTH -> box.maxZ <= .25; case NORTH -> box.minZ >= .75;
|
||||
default -> throw new AssertionError("Door partner must be horizontal");
|
||||
};
|
||||
}
|
||||
|
||||
private static void verifyFurnitureDirections(Capture result) {
|
||||
int tableSeats = 0;
|
||||
for (var entry : result.blocks.entrySet()) {
|
||||
if (!entry.getValue().is(Blocks.DARK_OAK_FENCE)
|
||||
|| !result.block(entry.getKey().above()).is(Blocks.DARK_OAK_PRESSURE_PLATE)) continue;
|
||||
int aroundThisTable = 0;
|
||||
for (Direction side : HORIZONTAL) {
|
||||
BlockPos chair = entry.getKey().relative(side);
|
||||
if (!result.block(chair).is(Blocks.DARK_OAK_STAIRS)) continue;
|
||||
seatLooksToward(result, chair, side.getOpposite());
|
||||
aroundThisTable++; tableSeats++;
|
||||
}
|
||||
require(aroundThisTable > 0, "A desk/table lost every chair facing its surface");
|
||||
}
|
||||
if (result.parcel.kind() != Kind.BARRACKS)
|
||||
require(tableSeats >= result.parcel.floors(), "Every furnished storey needs a usable table or desk seat");
|
||||
if (result.parcel.kind() == Kind.HALL) for (int x : new int[]{3, 4, 5, 11, 12, 13})
|
||||
seatLooksToward(result, rotate(new BlockPos(x, 1, 11), result.parcel.facing()), rotated(Direction.SOUTH, result.parcel.facing()));
|
||||
if (result.parcel.kind() == Kind.HOTEL) for (int x = 2; x <= 4; x++)
|
||||
seatLooksToward(result, rotate(new BlockPos(x, 1, 14), result.parcel.facing()), rotated(Direction.NORTH, result.parcel.facing()));
|
||||
if (result.parcel.kind() == Kind.BARRACKS) for (int floor = 0; floor < result.parcel.floors(); floor++)
|
||||
seatLooksToward(result, rotate(new BlockPos(13, floor * 5 + 1, 6), result.parcel.facing()), rotated(Direction.NORTH, result.parcel.facing()));
|
||||
for (var entry : result.blocks.entrySet()) if (entry.getValue().is(Blocks.POLISHED_ANDESITE_STAIRS)) {
|
||||
Direction look = entry.getValue().getValue(BlockStateProperties.HORIZONTAL_FACING).getOpposite();
|
||||
require(clear(result.block(entry.getKey().relative(look))), "Sanitary seat faces a wall instead of its cubicle");
|
||||
}
|
||||
}
|
||||
|
||||
private static void seatLooksToward(Capture result, BlockPos chair, Direction towardUse) {
|
||||
BlockState state = result.block(chair);
|
||||
require(state.is(Blocks.DARK_OAK_STAIRS), "Missing chair at " + chair);
|
||||
var boxes = state.getCollisionShape(EmptyBlockGetter.INSTANCE, chair).toAabbs();
|
||||
double frontX = .5 + towardUse.getStepX() * .25, frontZ = .5 + towardUse.getStepZ() * .25;
|
||||
double backX = .5 - towardUse.getStepX() * .25, backZ = .5 - towardUse.getStepZ() * .25;
|
||||
require(boxes.stream().noneMatch(box -> box.contains(frontX, .75, frontZ))
|
||||
&& boxes.stream().anyMatch(box -> box.contains(backX, .75, backZ)),
|
||||
label(result.parcel) + " chair's native back faces its table/stage at " + chair);
|
||||
require(result.block(chair.below()).isCollisionShapeFullBlock(EmptyBlockGetter.INSTANCE, chair.below()),
|
||||
"Chair does not rest on a complete floor");
|
||||
}
|
||||
|
||||
private static Direction rotated(Direction direction, int facing) {
|
||||
for (int turn = 0; turn < facing; turn++) direction = direction.getClockWise();
|
||||
return direction;
|
||||
}
|
||||
|
||||
private static void verifyLanternSupports(Capture result) {
|
||||
int lanterns = 0;
|
||||
for (var entry : result.blocks.entrySet()) if (entry.getValue().is(Blocks.LANTERN)) {
|
||||
BlockPos support = entry.getValue().getValue(BlockStateProperties.HANGING) ? entry.getKey().above() : entry.getKey().below();
|
||||
require(result.block(support).isCollisionShapeFullBlock(EmptyBlockGetter.INSTANCE, support),
|
||||
label(result.parcel) + " lantern floats against a slab or lacks a full support block at " + entry.getKey());
|
||||
lanterns++;
|
||||
}
|
||||
require(lanterns == result.parcel.floors(), "A junction lost its supported lamp");
|
||||
}
|
||||
|
||||
private static void verifyBeds(Capture result, Set<BlockPos> reached) {
|
||||
for (var entry : result.blocks.entrySet()) {
|
||||
BlockState state = entry.getValue();
|
||||
if (!state.is(Blocks.BED.white())) continue;
|
||||
Direction direction = state.getValue(BlockStateProperties.HORIZONTAL_FACING);
|
||||
BedPart part = state.getValue(BlockStateProperties.BED_PART);
|
||||
BlockPos partner = entry.getKey().relative(part == BedPart.FOOT ? direction : direction.getOpposite());
|
||||
require(result.block(partner).equals(state.setValue(BlockStateProperties.BED_PART,
|
||||
part == BedPart.FOOT ? BedPart.HEAD : BedPart.FOOT)), label(result.parcel) + " has a severed bed at " + entry.getKey());
|
||||
require(result.block(entry.getKey().below()).isSolid(), "Bed does not sit on a floor");
|
||||
require(adjacentReached(entry.getKey(), reached) || adjacentReached(partner, reached),
|
||||
label(result.parcel) + " bed cannot be approached from its room");
|
||||
}
|
||||
}
|
||||
|
||||
private static void semantics(Capture result) {
|
||||
Map<BlockState, Long> counts = new HashMap<>();
|
||||
result.blocks.values().forEach(state -> counts.merge(state, 1L, Long::sum));
|
||||
java.util.function.Predicate<net.minecraft.world.level.block.Block> has = block ->
|
||||
counts.keySet().stream().anyMatch(state -> state.is(block));
|
||||
switch (result.parcel.kind()) {
|
||||
case APARTMENT -> require(has.test(Blocks.FURNACE) && has.test(Blocks.BED.white())
|
||||
&& has.test(Blocks.WATER_CAULDRON), "Apartment lacks cooking, sleeping or washing fixtures");
|
||||
case HOTEL -> require(has.test(Blocks.LOOM) && has.test(Blocks.BED.white())
|
||||
&& has.test(Blocks.WATER_CAULDRON), "Hotel lacks guest rooms or laundry fixtures");
|
||||
case LIBRARY -> require(result.blocks.values().stream().filter(state -> state.is(Blocks.BOOKSHELF)).count() >= result.parcel.floors() * 25L
|
||||
&& has.test(Blocks.LECTERN), "Library is missing usable stacks and reading fixtures");
|
||||
case RESTAURANT -> require(has.test(Blocks.SMOKER) && has.test(Blocks.FURNACE)
|
||||
&& has.test(Blocks.CRAFTING_TABLE) && has.test(Blocks.WATER_CAULDRON), "Restaurant lacks kitchen and washroom services");
|
||||
case BARRACKS -> require(has.test(Blocks.BED.white()) && has.test(Blocks.FLETCHING_TABLE)
|
||||
&& has.test(Blocks.GRINDSTONE), "Barracks lacks dormitory and equipment fixtures");
|
||||
case HALL -> {
|
||||
require(has.test(Blocks.LECTERN) && has.test(Blocks.IRON_BARS), "Hall lacks stage or gallery guards");
|
||||
require(result.block(rotate(new BlockPos(8, 5, 12), result.parcel.facing())).isAir(), "Auditorium lost its double-height volume");
|
||||
}
|
||||
default -> throw new AssertionError("Unexpected building");
|
||||
}
|
||||
}
|
||||
|
||||
/** Player-sized path: two clear cells above support, one-block steps and manually openable doors. */
|
||||
private static Set<BlockPos> walk(Capture result, BlockPos start) {
|
||||
require(standable(result, start), label(result.parcel) + " has a blocked entrance");
|
||||
Set<BlockPos> seen = new HashSet<>();
|
||||
ArrayDeque<BlockPos> queue = new ArrayDeque<>();
|
||||
seen.add(start); queue.add(start);
|
||||
while (!queue.isEmpty()) {
|
||||
BlockPos current = queue.removeFirst();
|
||||
for (Direction direction : HORIZONTAL) for (int dy = -1; dy <= 1; dy++) {
|
||||
BlockPos next = current.relative(direction).offset(0, dy, 0);
|
||||
if (dy > 0 && !clear(result.block(current.above(2)))) continue;
|
||||
if (standable(result, next) && seen.add(next)) queue.addLast(next);
|
||||
}
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
private static boolean standable(Capture result, BlockPos pos) {
|
||||
if (pos.getX() < 0 || pos.getX() > 16 || pos.getZ() < 0 || pos.getZ() > 16
|
||||
|| pos.getY() < 1 || pos.getY() > result.parcel.floors() * 5 + 4) return false;
|
||||
BlockState support = result.block(pos.below());
|
||||
return (support.isSolid() || support.getBlock() instanceof StairBlock)
|
||||
&& clear(result.block(pos)) && clear(result.block(pos.above()));
|
||||
}
|
||||
private static boolean clear(BlockState state) { return state.isAir() || state.is(Blocks.DARK_OAK_DOOR); }
|
||||
private static boolean adjacentReached(BlockPos pos, Set<BlockPos> reached) {
|
||||
for (Direction direction : HORIZONTAL) if (reached.contains(pos.relative(direction))) return true;
|
||||
return false;
|
||||
}
|
||||
private static BlockPos rotate(BlockPos pos, int facing) {
|
||||
return switch (facing) {
|
||||
case 0 -> pos; case 1 -> new BlockPos(16 - pos.getZ(), pos.getY(), pos.getX());
|
||||
case 2 -> new BlockPos(16 - pos.getX(), pos.getY(), 16 - pos.getZ());
|
||||
default -> new BlockPos(pos.getZ(), pos.getY(), 16 - pos.getX());
|
||||
};
|
||||
}
|
||||
|
||||
private record Loot(ResourceKey<LootTable> table, long seed) {}
|
||||
private static final class Capture implements LostCityRenderer.BlockWriter {
|
||||
final Parcel parcel;
|
||||
final Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
final Map<BlockPos, Loot> loot = new HashMap<>();
|
||||
Capture(Parcel parcel) { this.parcel = parcel; }
|
||||
@Override public void set(int x, int y, int z, BlockState state) {
|
||||
require(x >= 0 && x < 17 && z >= 0 && z < 17 && y >= parcel.minY() - parcel.floorY()
|
||||
&& y <= parcel.maxY() - parcel.floorY(), label(parcel) + " writes outside its saved bounds at " + x + "," + y + "," + z);
|
||||
blocks.put(new BlockPos(x, y, z), state);
|
||||
}
|
||||
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long seed) {
|
||||
set(x, y, z, Blocks.CHEST.defaultBlockState());
|
||||
loot.put(new BlockPos(x, y, z), new Loot(table, seed));
|
||||
}
|
||||
BlockState block(BlockPos pos) { return blocks.getOrDefault(pos, Blocks.AIR.defaultBlockState()); }
|
||||
}
|
||||
private static void require(boolean condition, String message) { if (!condition) throw new AssertionError(message); }
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
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.ArrayDeque;
|
||||
import java.util.Arrays;
|
||||
import java.util.EnumSet;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.IntBinaryOperator;
|
||||
|
||||
/** A road-only flood verifies the town's actual saved elevations, without walking over natural soil. */
|
||||
public final class LostCityPlanner15Smoke {
|
||||
public static void main(String[] args) {
|
||||
Fixture flat = new Fixture((x, z) -> 220, 40);
|
||||
var root = LostCityPlanner15.plan(42, 0, 0, 724, true, flat);
|
||||
require(root.surface().districts().size() == 1 && lots(root).count() >= 6, "Starter must contain one compact, populated town");
|
||||
check(root, flat);
|
||||
require(fingerprint(root).equals(fingerprint(LostCityPlanner15.plan(42, 0, 0, 724, true, new Fixture((x, z) -> 220, 40)))), "Planning order changes the city");
|
||||
require(!fingerprint(root).equals(fingerprint(LostCityPlanner15.plan(43, 0, 0, 724, true, new Fixture((x, z) -> 220, 40)))), "City ignores its seed");
|
||||
require(lots(root).allMatch(p -> p.floorY() == 220), "Town remains raised above its natural ground");
|
||||
var legacy = LostCityPlanner.plan(42, 0, 0, 724, true, (x, z) -> new LostCityPlanner.Ground(220, 40, true, false));
|
||||
require(!legacy.isEmpty() && legacy.districts().stream().flatMap(d -> d.parcels().stream()).filter(p -> p.kind().building()).allMatch(p -> p.floorY() == 221), "Generation-14 floor changed");
|
||||
var bounds = root.surface().districts().getFirst();
|
||||
require(bounds.maxX() - bounds.minX() + 1 <= 71 && bounds.maxZ() - bounds.minZ() + 1 <= 71, "Starter town exceeds its compact envelope");
|
||||
require(kinds(root).containsAll(Set.of(Kind.APARTMENT, Kind.PLAZA)), "Town lacks buildings or a public square");
|
||||
int surface = bounds.parcels().stream().mapToInt(p -> p.width() * p.depth()).sum();
|
||||
require(surface == (bounds.maxX() - bounds.minX() + 1) * (bounds.maxZ() - bounds.minZ() + 1), "Flat city leaves a mound or strip of natural dirt between facades");
|
||||
|
||||
for (int sign : new int[] {-1, 1}) {
|
||||
Fixture slope = new Fixture((x, z) -> 220 + sign * Math.floorDiv(z, 6), 40);
|
||||
var plan = LostCityPlanner15.plan(42, 0, 0, 724, true, slope);
|
||||
require(!plan.surface().isEmpty(), "A modest slope eliminates the compact city"); check(plan, slope);
|
||||
}
|
||||
Fixture thin = new Fixture((x, z) -> 220, 4);
|
||||
var shallow = LostCityPlanner15.plan(42, 0, 0, 724, true, thin); check(shallow, thin);
|
||||
require(!shallow.surface().isEmpty() && !kinds(shallow).contains(Kind.POOL) && !kinds(shallow).contains(Kind.EXCAVATION), "A thin rock shelf lost ordinary buildings or admitted excavations");
|
||||
Fixture protectedTerrain = new Fixture((x, z) -> 220, 40) {
|
||||
@Override public boolean protectedAt(int x, int y, int z) { super.protectedAt(x, y, z); return y == 220; }
|
||||
};
|
||||
require(LostCityPlanner15.plan(42, 0, 0, 724, true, protectedTerrain).surface().isEmpty(), "Town overwrites protected water/terrain");
|
||||
Fixture water = new Fixture((x, z) -> 220, 0);
|
||||
require(LostCityPlanner15.plan(42, 0, 0, 724, true, water).surface().isEmpty(), "City has no supporting rock");
|
||||
|
||||
var all = EnumSet.noneOf(Kind.class);
|
||||
LostCityPlanner15.Result large = null;
|
||||
for (int diameter : new int[] {64, 128, 256, 512, 1024}) {
|
||||
Fixture terrain = new Fixture((x, z) -> 220, 40);
|
||||
var plan = LostCityPlanner15.plan(42, 0, 0, diameter, false, terrain);
|
||||
require(plan.surface().districts().size() == 1, "Flat expansion has no compact town: " + diameter);
|
||||
check(plan, terrain); all.addAll(kinds(plan)); if (diameter == 1024) large = plan;
|
||||
}
|
||||
for (int seed = 0; seed < 12; seed++) {
|
||||
Fixture terrain = new Fixture((x, z) -> 220, 40);
|
||||
var plan = LostCityPlanner15.plan(seed, 0, 0, 1024, false, terrain);
|
||||
check(plan, terrain); all.addAll(kinds(plan));
|
||||
}
|
||||
var expected = EnumSet.allOf(Kind.class); expected.remove(Kind.BRIDGE);
|
||||
require(all.containsAll(expected), "A compatible function is never selected: " + difference(expected, all));
|
||||
|
||||
// Keep the same finite candidate envelope and replace one junction by a fissure.
|
||||
// The surviving street graph must cross it through a real, integrated viaduct.
|
||||
var reference = large.surface().districts().getFirst();
|
||||
int midX = reference.minX() + 2 + 44, midZ = reference.minZ() + 2 + 44;
|
||||
Fixture fissure = new Fixture((x, z) -> Math.abs(x - midX) <= 14 ? 210 : 220, 40) {
|
||||
@Override int depthAt(int x, int z) { return Math.abs(x - midX) <= 2 && Math.abs(z - midZ) <= 2 ? 0 : depth; }
|
||||
@Override public boolean owns(int x, int z) { return x >= reference.minX() && x <= reference.maxX() && z >= reference.minZ() && z <= reference.maxZ(); }
|
||||
};
|
||||
var bridged = LostCityPlanner15.plan(42, 0, 0, 1024, false, fissure);
|
||||
check(bridged, fissure);
|
||||
require(!bridged.surface().isEmpty() && kinds(bridged).contains(Kind.BRIDGE), "A supported fissure cannot be crossed by the town's own streets");
|
||||
require(!bridged.supports().isEmpty(), "The integrated viaduct lacks short rock-anchored piers");
|
||||
require(bridged.surface().districts().size() == 1, "A viaduct became an isolated structure");
|
||||
System.out.println("LostCityPlanner15Smoke passed: one compact town, connected road-only flood, flush entrances, graded streets, no dirt strips, integrated piers and bounded terrain certificates");
|
||||
}
|
||||
|
||||
private static void check(LostCityPlanner15.Result result, Fixture terrain) {
|
||||
require(result.columns() == terrain.queries && result.columns() <= LostCityPlanner15.MAX_COLUMNS, "Column budget drifted");
|
||||
require(result.volumeChecks() == terrain.volumeQueries && result.volumeChecks() <= LostCityPlanner15.MAX_VOLUME_CHECKS, "Volume budget drifted");
|
||||
require(result.surface().districts().size() <= 1, "City is dispersed among independent fragments");
|
||||
if (result.surface().isEmpty()) return;
|
||||
var district = result.surface().districts().getFirst();
|
||||
long lotCount = lots(result).count(), buildings = lots(result).filter(p -> p.kind().building()).count();
|
||||
require(buildings >= (lotCount * 3 + 4) / 5, "Town is mostly empty public lots instead of buildings");
|
||||
require(lots(result).filter(p -> p.kind().building()).map(Parcel::kind).distinct().count() == Math.min(buildings, 6),
|
||||
"Buildings repeat before all six archetypes have been used");
|
||||
Map<Long, Cell> roads = new HashMap<>();
|
||||
Set<Long> painted = new HashSet<>();
|
||||
for (Parcel p : district.parcels()) for (int x = p.minX(); x <= p.maxX(); x++) for (int z = p.minZ(); z <= p.maxZ(); z++) {
|
||||
require(painted.add(key(x, z)), "Two pieces write the same surface column");
|
||||
require(terrain.owns(x, z), "City escaped the island");
|
||||
int floor = p.surfaceY(x - p.minX(), z - p.minZ()), top = terrain.height.applyAsInt(x, z);
|
||||
require(top <= floor + LostCityPlanner15.MAX_CUT, "Urban cut exceeds the explicit six-block limit");
|
||||
if (p.kind() != Kind.BRIDGE) require(top >= floor - p.foundationDepth()
|
||||
&& top - (floor - p.foundationDepth()) + 1 <= terrain.depthAt(x, z), "Foundation hangs over a cavity");
|
||||
if (p.kind() == Kind.POOL || p.kind() == Kind.EXCAVATION || p.kind() == Kind.PARKING && p.foundationDepth() >= 8)
|
||||
require(top >= floor - 1 && top - (floor - 8) + 1 <= terrain.depthAt(x, z), "Deep lot lacks continuous rock");
|
||||
if (p.kind() == Kind.STREET || p.kind() == Kind.BRIDGE) {
|
||||
boolean rail = p.kind() == Kind.BRIDGE && (p.width() > p.depth()
|
||||
? z == p.minZ() || z == p.maxZ() : x == p.minX() || x == p.maxX());
|
||||
roads.put(key(x, z), new Cell(x, z, floor, p, !rail));
|
||||
}
|
||||
}
|
||||
Set<Long> walkable = new HashSet<>();
|
||||
roads.forEach((position, cell) -> { if (cell.walkable()) walkable.add(position); });
|
||||
require(!walkable.isEmpty(), "Town has no walkable street");
|
||||
Set<Long> visited = new HashSet<>(); ArrayDeque<Long> queue = new ArrayDeque<>();
|
||||
long start = walkable.iterator().next(); visited.add(start); queue.add(start);
|
||||
while (!queue.isEmpty()) {
|
||||
Cell cell = roads.get(queue.removeFirst());
|
||||
for (int[] offset : new int[][] {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}) {
|
||||
long position = key(cell.x() + offset[0], cell.z() + offset[1]);
|
||||
Cell next = roads.get(position);
|
||||
if (next == null || !next.walkable()) continue;
|
||||
require(Math.abs(cell.floor() - next.floor()) <= 1, "Street has a cliff");
|
||||
if (cell.parcel() != next.parcel()) require(cell.floor() == next.floor(), "A step straddles two parcels and has no stair block");
|
||||
if (visited.add(position)) queue.addLast(position);
|
||||
}
|
||||
}
|
||||
require(visited.equals(walkable), "A street or bridge is disconnected from the town");
|
||||
for (Parcel p : district.parcels()) if (p.kind() != Kind.STREET && p.kind() != Kind.BRIDGE) {
|
||||
require(p.facing() == 0, "The saved doorway no longer faces its certified frontage");
|
||||
for (int x = p.minX(); x <= p.maxX(); x++) {
|
||||
require(roads.containsKey(key(x, p.minZ() - 1)) && roads.containsKey(key(x, p.maxZ() + 1)), "Natural dirt separates a facade from the street");
|
||||
}
|
||||
for (int z = p.minZ(); z <= p.maxZ(); z++) {
|
||||
require(roads.containsKey(key(p.minX() - 1, z)) && roads.containsKey(key(p.maxX() + 1, z)), "A side street is absent beside an accepted lot");
|
||||
}
|
||||
for (int dx = 7; dx <= 9; dx++) {
|
||||
Cell threshold = roads.get(key(p.minX() + dx, p.minZ() - 1));
|
||||
require(threshold != null && threshold.walkable() && threshold.floor() == p.floorY()
|
||||
&& visited.contains(key(threshold.x(), threshold.z())), "A doorway is blocked by a rail, mound or disconnected road");
|
||||
}
|
||||
}
|
||||
for (var support : result.supports()) {
|
||||
require(support.startChunkX() == district.startChunkX() && support.startChunkZ() == district.startChunkZ(), "Pier uses another native start");
|
||||
for (int x = support.minX(); x <= support.maxX(); x++) for (int z = support.minZ(); z <= support.maxZ(); z++) {
|
||||
Cell deck = roads.get(key(x, z));
|
||||
require(deck != null && deck.parcel().kind() == Kind.BRIDGE && support.maxY() == deck.floor() - 1, "Pier does not meet its actual tablier");
|
||||
int top = terrain.height.applyAsInt(x, z);
|
||||
require(top >= support.minY() && top - (support.minY() - 1) + 1 <= terrain.depthAt(x, z), "Pier footing lacks solid rock");
|
||||
require(support.maxY() - support.minY() < 16, "Pier extends towards the void");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static java.util.stream.Stream<Parcel> lots(LostCityPlanner15.Result result) {
|
||||
return result.surface().districts().stream().flatMap(d -> d.parcels().stream()).filter(p -> p.kind() != Kind.STREET && p.kind() != Kind.BRIDGE);
|
||||
}
|
||||
private static Set<Kind> kinds(LostCityPlanner15.Result result) {
|
||||
var set = EnumSet.noneOf(Kind.class); result.surface().districts().forEach(d -> d.parcels().forEach(p -> set.add(p.kind()))); return set;
|
||||
}
|
||||
private static Set<Kind> difference(Set<Kind> a, Set<Kind> b) { Set<Kind> result = new HashSet<>(a); result.removeAll(b); return result; }
|
||||
private static String fingerprint(LostCityPlanner15.Result result) {
|
||||
StringBuilder text = new StringBuilder();
|
||||
result.surface().districts().forEach(d -> {
|
||||
text.append(d.startChunkX()).append(':').append(d.startChunkZ()).append(':').append(d.seed());
|
||||
d.parcels().forEach(p -> text.append('|').append(p.kind()).append(':').append(p.minX()).append(':').append(p.minZ())
|
||||
.append(':').append(p.floorY()).append(':').append(p.facing()).append(':').append(p.seed())
|
||||
.append(':').append(p.foundationDepth()).append(':').append(Arrays.toString(p.surfaceHeights())));
|
||||
}); return text.append(result.supports()).toString();
|
||||
}
|
||||
private record Cell(int x, int z, int floor, Parcel parcel, boolean walkable) {}
|
||||
private static long key(int x, int z) { return ((long) x << 32) ^ (z & 0xffffffffL); }
|
||||
private static class Fixture implements LostCityPlanner15.Terrain {
|
||||
final IntBinaryOperator height; final int depth; int queries, volumeQueries;
|
||||
Fixture(IntBinaryOperator height, int depth) { this.height = height; this.depth = depth; }
|
||||
int depthAt(int x, int z) { return depth; }
|
||||
@Override public LostCityPlanner.Ground ground(int x, int z) { queries++; return new LostCityPlanner.Ground(height.applyAsInt(x, z), depthAt(x, z), true, false); }
|
||||
@Override public boolean solid(int x, int y, int z) { int top = height.applyAsInt(x, z); return y <= top && y > top - depthAt(x, z); }
|
||||
@Override public boolean protectedAt(int x, int y, int z) { volumeQueries++; return false; }
|
||||
@Override public boolean owns(int x, int z) { return true; }
|
||||
}
|
||||
private static void require(boolean condition, String message) { if (!condition) throw new AssertionError(message); }
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
package fr.koka.sanctuary.worldgen.city;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.HashMap;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
|
||||
import net.minecraft.world.level.storage.loot.LootTable;
|
||||
|
||||
/** Construction volumes stay local in all three axes, including negative chunks and support anchors. */
|
||||
public final class LostCityProtection15Smoke {
|
||||
public static void main(String[] args) {
|
||||
SharedConstants.tryDetectVersion(); Bootstrap.bootStrap();
|
||||
run();
|
||||
System.out.println("LostCityProtection15Smoke: surface, ramps, underground, shafts, supports, chunk boundaries and immutable snapshots passed.");
|
||||
}
|
||||
|
||||
public static void run() {
|
||||
var hall = new LostCityPlan.Parcel(LostCityPlan.Kind.HALL, -9, -9, 17, 17,
|
||||
240, 2, 0, 42, 8, new int[0]);
|
||||
int[] heights = new int[25 * 5];
|
||||
for (int z = 0; z < 5; z++) for (int x = 0; x < 25; x++) heights[z * 25 + x] = 180 + x / 5;
|
||||
var ramp = new LostCityPlan.Parcel(LostCityPlan.Kind.STREET, -24, 24, 25, 5,
|
||||
180, 0, 0, 7, 3, heights);
|
||||
var surface = new LostCityPlan(List.of(new LostCityPlan.District(0, 0, 42, List.of(hall, ramp))));
|
||||
var passage = new LostCityUnderground15.Module(LostCityUnderground15.Kind.PASSAGE,
|
||||
14, 120, -18, 5, 6, 23, 5, 42, List.of(
|
||||
new LostCityUnderground15.Support(LostCityUnderground15.Type.PILLAR, 14, 114, -18, 119),
|
||||
new LostCityUnderground15.Support(LostCityUnderground15.Type.CHAIN, 18, 126, 4, 132)));
|
||||
var access = new LostCityUnderground15.Module(LostCityUnderground15.Kind.ACCESS,
|
||||
-20, 170, -20, 5, 26, 5, 1, 7, List.of());
|
||||
var garage = new LostCityUnderground15.Module(LostCityUnderground15.Kind.GARAGE,
|
||||
55, 150, -10, 17, 8, 17, 1, 137, List.of());
|
||||
var expansionHall = new LostCityUnderground15.Module(LostCityUnderground15.Kind.EXPANSION_HALL,
|
||||
45, 110, 40, 25, 10, 25, 5, 1515, List.of());
|
||||
var underground = new LostCityUnderground15.Plan(List.of(
|
||||
new LostCityUnderground15.Network(0, 0, 42, List.of(passage, access, garage, expansionHall))), 0);
|
||||
var builder = new LostCityProtection15.Builder().add(surface, underground);
|
||||
var mask = builder.build();
|
||||
|
||||
require(mask.contains(-9, 232, -9) && mask.contains(7, 255, 7), "Foundation or highest surface parcel boundary is unprotected");
|
||||
require(!mask.contains(-9, 231, -9) && !mask.contains(7, 256, 7), "Surface mask escaped vertically into unrelated terrain");
|
||||
require(mask.contains(-24, 177, 24) && mask.contains(0, 192, 28), "Ramp protection lost its lowest foundation or raised end");
|
||||
require(!mask.contains(-25, 180, 24) && !mask.contains(1, 184, 28), "Ramp protection widened into neighboring land");
|
||||
for (int x : new int[] {14, 15, 16, 18}) for (int z : new int[] {-18, -17, -16, -1, 0, 4})
|
||||
require(mask.contains(x, 122, z), "Underground body has a hole at a signed chunk boundary");
|
||||
require(mask.contains(14, 114, -18) && mask.contains(18, 132, 4), "Certified support anchor is outside the construction mask");
|
||||
require(mask.contains(14, 113, -18) && mask.contains(18, 133, 4), "Untouched rock beside a support anchor is unprotected");
|
||||
require(!mask.contains(14, 112, -18) && !mask.contains(18, 134, 4), "Support bounds protect an unrelated height");
|
||||
require(!mask.contains(14, 113, -17) && !mask.contains(17, 133, 4), "Single rock dependency widened into an unrelated horizontal column");
|
||||
require(!passage.contains(14, 113, -18) && !passage.contains(18, 133, 4), "Protecting rock dependencies changed the native module's write volume");
|
||||
require(mask.contains(-20, 170, -20) && mask.contains(-16, 195, -16), "Surface access or its hatch is unprotected");
|
||||
require(!mask.contains(-16, 196, -16) && !mask.contains(-21, 180, -20), "Access protection expands beyond the saved shaft");
|
||||
|
||||
// Rooms below the island do not create protected columns all the way up to its surface.
|
||||
require(!mask.contains(16, 220, -16) && !mask.contains(0, 170, 0), "Underground mask incorrectly widened into a vertical biome column");
|
||||
require(!mask.contains(-18, 122, 16) && !mask.contains(16, 122, 16), "Signed chunk keys alias a different quadrant");
|
||||
require(!LostCityProtection15.EMPTY.contains(0, 0, 0), "Empty snapshot unexpectedly protects terrain");
|
||||
|
||||
// A neighboring temple fits above the garage, until its real foundation is included.
|
||||
require(!surface.intersects(54, -11, 72, 7), "Underground-only collision fixture accidentally overlaps surface parcels");
|
||||
require(!mask.intersects(54, 180, -11, 72, 195, 7), "A separated temple above the network was rejected");
|
||||
require(!mask.intersects(54, 180 - 12, -11, 72, 195, 7), "A shallow foundation cannot reach the garage");
|
||||
require(mask.intersects(54, 180 - 24, -11, 72, 195, 7), "A temple foundation entering the garage was not rejected");
|
||||
require(mask.intersects(55, 150, -10, 55, 150, -10)
|
||||
&& mask.intersects(71, 157, 6, 71, 157, 6), "An exact corner collision was missed at the garage bounds");
|
||||
require(!mask.intersects(55, 158, -10, 71, 179, 6), "A box just above the garage intersects its body");
|
||||
require(mask.intersects(14, 114, -18, 14, 114, -18)
|
||||
&& mask.intersects(18, 132, 4, 18, 132, 4), "A collision through a support anchor was ignored");
|
||||
require(mask.intersects(14, 113, -18, 14, 113, -18)
|
||||
&& mask.intersects(18, 133, 4, 18, 133, 4), "A native structure can replace the rock carrying a support anchor");
|
||||
require(mask.intersects(-16, 195, -16, -16, 195, -16), "A native start can overwrite the surface hatch");
|
||||
require(!mask.intersects(-15, 170, -20, -15, 195, -16), "A box alongside the access was falsely rejected");
|
||||
require(mask.contains(45, 108, 40) && mask.contains(69, 109, 64), "Large hall lost its two untouched bearing layers");
|
||||
require(mask.intersects(45, 108, 40, 69, 109, 64), "A neighboring structure can carve out the large hall foundation");
|
||||
require(!mask.contains(45, 107, 40) && !mask.contains(44, 108, 40), "Large hall bearing protection escaped its footprint");
|
||||
require(!expansionHall.contains(45, 108, 40), "Hall bearing protection enlarged the painted native volume");
|
||||
require(!mask.intersects(-18, 120, 14, 4, 125, 18), "Intersection query aliases transposed signed chunk coordinates");
|
||||
require(!mask.intersects(-32, -64, -32, 80, -1, 32)
|
||||
&& !mask.intersects(-32, 384, -32, 80, 400, 32), "Queries outside world height intersect city volumes");
|
||||
require(!LostCityProtection15.EMPTY.intersects(-32, 0, -32, 80, 383, 32), "Empty mask rejects a native structure");
|
||||
|
||||
// A later support publication must not mutate the snapshot already used by chunk workers.
|
||||
builder.add(32, 100, -33, 33, 107, -32);
|
||||
require(!mask.contains(32, 100, -33), "A published mask changed when its builder was reused");
|
||||
var expanded = builder.build();
|
||||
require(expanded.contains(32, 100, -33) && expanded.contains(33, 107, -32), "Explicit support volume lost a chunk-edge anchor");
|
||||
require(!expanded.contains(34, 107, -32), "Explicit support protects a neighboring block");
|
||||
require(!mask.intersects(31, 99, -34, 34, 108, -31)
|
||||
&& expanded.intersects(31, 99, -34, 34, 108, -31), "Intersection queries do not respect immutable support publication");
|
||||
require(!expanded.intersects(34, 100, -33, 35, 107, -32), "Box beside an explicit pier was falsely rejected");
|
||||
|
||||
var pier = new LostCityPlanner15.Support(0, 0, 64, 100, -48, 66, 107, -46, 137);
|
||||
var pierMask = new LostCityProtection15.Builder().addViaductSupport(pier).build();
|
||||
require(pierMask.contains(64, 99, -48) && pierMask.contains(66, 99, -46), "Viaduct's entire untouched rock footing is unprotected");
|
||||
require(pierMask.intersects(63, 98, -49, 67, 99, -45), "A new excavation can remove the rock under a viaduct");
|
||||
require(!pierMask.intersects(63, 97, -49, 67, 98, -45), "Viaduct footing protection extends unnecessarily downward");
|
||||
require(pier.minY() == 100, "Protecting a viaduct footing changed its saved paint bounds");
|
||||
slopingBridge();
|
||||
streetCutsAndSeats();
|
||||
|
||||
rejects(() -> new LostCityProtection15.Builder().add(2, 1, 0, 1, 2, 0));
|
||||
rejects(() -> new LostCityProtection15.Builder().add(0, -1, 0, 1, 2, 1));
|
||||
rejects(() -> new LostCityProtection15.Builder().add(0, 0, 0, 1, 384, 1));
|
||||
rejects(() -> new LostCityProtection15.Builder().add(0, 0, 0, 257, 2, 1));
|
||||
rejects(() -> mask.intersects(1, 0, 0, 0, 1, 1));
|
||||
rejects(() -> mask.intersects(0, 1, 0, 1, 0, 1));
|
||||
rejects(() -> mask.intersects(0, 0, 1, 1, 1, 0));
|
||||
}
|
||||
|
||||
private static void slopingBridge() {
|
||||
int[] elevations = new int[17 * 5];
|
||||
for (int z = 0; z < 5; z++) for (int x = 0; x < 17; x++) elevations[z * 17 + x] = 210 + x / 6;
|
||||
var bridge = new LostCityPlan.Parcel(LostCityPlan.Kind.BRIDGE, -24, -2, 17, 5,
|
||||
210, 0, 0, 1515, 0, elevations);
|
||||
var surface = new LostCityPlan(List.of(new LostCityPlan.District(-1, 0, 1515, List.of(bridge))));
|
||||
var mask = new LostCityProtection15.Builder().add(surface, new LostCityUnderground15.Plan(List.of(), 0)).build();
|
||||
for (int z = 0; z < bridge.depth(); z++) for (int x = 0; x < bridge.width(); x++) {
|
||||
int wx = bridge.minX() + x, wz = bridge.minZ() + z, y = bridge.surfaceY(x, z);
|
||||
require(mask.contains(wx, y - 1, wz) && mask.contains(wx, y - 2, wz), "Sloping bridge lost its two local bearing layers");
|
||||
require(!mask.contains(wx, y - 3, wz), "Sloping bridge protects a third rock layer below its bearings");
|
||||
}
|
||||
require(bridge.foundationDepth() == 0 && bridge.minY() == 210,
|
||||
"Protecting bridge bearings changed the saved geometry or added a painted foundation");
|
||||
int[] deckWrites = {0};
|
||||
LostCitySurface15.render(bridge, new LostCityRenderer.BlockWriter() {
|
||||
@Override public void set(int x, int y, int z, BlockState state) {
|
||||
int worldY = bridge.floorY() + y;
|
||||
require(worldY >= bridge.surfaceY(x, z), "The bridge renderer painted an added block in protected natural rock");
|
||||
require(mask.contains(bridge.minX() + x, worldY, bridge.minZ() + z), "Sloping deck write escaped its mask");
|
||||
if (worldY == bridge.surfaceY(x, z) && !state.isAir()) deckWrites[0]++;
|
||||
}
|
||||
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long seed) {
|
||||
throw new AssertionError("A bridge must not add unsolicited loot");
|
||||
}
|
||||
});
|
||||
require(deckWrites[0] >= bridge.width() * bridge.depth(), "The sloping bridge fixture never painted a complete deck");
|
||||
}
|
||||
|
||||
private static void streetCutsAndSeats() {
|
||||
int[] elevations = new int[17 * 5];
|
||||
for (int z = 0; z < 5; z++) for (int x = 0; x < 17; x++) elevations[z * 17 + x] = 210 + x / 6;
|
||||
var road = new LostCityPlan.Parcel(LostCityPlan.Kind.STREET, 0, 0, 17, 5,
|
||||
210, 0, 0, 42, 3, elevations);
|
||||
var writes = render(road);
|
||||
for (int z = 0; z < 5; z++) for (int x = 0; x < 17; x++) {
|
||||
int floor = road.surfaceY(x, z) - road.floorY();
|
||||
for (int y = 2; y <= 8; y++) require(writes.get(new BlockPos(x, floor + y, z)).isAir(),
|
||||
"A certified street cut still contains a terrain obstruction");
|
||||
}
|
||||
var step = writes.get(new BlockPos(5, 1, 2));
|
||||
require(step.is(Blocks.STONE_BRICK_STAIRS)
|
||||
&& step.getValue(BlockStateProperties.HORIZONTAL_FACING) == Direction.EAST,
|
||||
"Street stair does not rise toward its uphill neighboring block");
|
||||
var collision = step.getCollisionShape(net.minecraft.world.level.EmptyBlockGetter.INSTANCE, BlockPos.ZERO);
|
||||
require(collision.max(Direction.Axis.Y) + 211 == road.surfaceY(6, 2) + 1,
|
||||
"Street stair top does not meet the top of its uphill neighbor");
|
||||
require(!writes.get(new BlockPos(5, 0, 2)).isAir(), "Street ramp removed its underlying floor");
|
||||
for (int turn = 0; turn < 4; turn++) {
|
||||
var garden = new LostCityPlan.Parcel(LostCityPlan.Kind.PARK_GARDEN, 0, 0, 17, 17,
|
||||
210, 0, turn, 42, 3, new int[0]);
|
||||
var gardenWrites = render(garden);
|
||||
int bx = switch (turn) { case 1 -> 1; case 2 -> 8; case 3 -> 15; default -> 8; };
|
||||
int bz = switch (turn) { case 1 -> 8; case 2 -> 1; case 3 -> 8; default -> 15; };
|
||||
Direction back = switch (turn) { case 1 -> Direction.WEST; case 2 -> Direction.NORTH;
|
||||
case 3 -> Direction.EAST; default -> Direction.SOUTH; };
|
||||
var bench = gardenWrites.get(new BlockPos(bx, 1, bz));
|
||||
require(bench.is(Blocks.DARK_OAK_STAIRS)
|
||||
&& bench.getValue(BlockStateProperties.HORIZONTAL_FACING) == back,
|
||||
"A public garden bench faces away from its garden after rotation " + turn);
|
||||
}
|
||||
}
|
||||
|
||||
private static HashMap<BlockPos, BlockState> render(LostCityPlan.Parcel p) {
|
||||
var result = new HashMap<BlockPos, BlockState>();
|
||||
LostCitySurface15.render(p, new LostCityRenderer.BlockWriter() {
|
||||
@Override public void set(int x, int y, int z, BlockState state) { result.put(new BlockPos(x, y, z), state); }
|
||||
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long seed) {
|
||||
throw new AssertionError("Unexpected loot in road/park fixture");
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void rejects(Runnable action) {
|
||||
try { action.run(); } catch (IllegalArgumentException expected) { return; }
|
||||
throw new AssertionError("Invalid or unbounded construction volume was accepted");
|
||||
}
|
||||
private static void require(boolean condition, String message) {
|
||||
if (!condition) throw new AssertionError(message);
|
||||
}
|
||||
}
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
package fr.koka.sanctuary.worldgen.city;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
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;
|
||||
|
||||
/** Pure terrain fixtures and actual block-state topology. Does not create or edit a Minecraft world. */
|
||||
public final class LostCityUnderground15Smoke {
|
||||
public static void main(String[] args) {
|
||||
SharedConstants.tryDetectVersion(); Bootstrap.bootStrap(); run();
|
||||
System.out.println("LostCityUnderground15Smoke: deterministic bounded connected networks, surface ladders, sealed basins, terrain/collision masks, anchored supports and sampling limits passed.");
|
||||
}
|
||||
public static void run() {
|
||||
var surface = surface(0, 0);
|
||||
var terrain = new RockTerrain(0);
|
||||
var seen = new HashSet<LostCityUnderground15.Kind>();
|
||||
boolean circuit = false;
|
||||
for (long seed : new long[] {0, 42, 137, -61872}) {
|
||||
var plan = LostCityUnderground15.plan(seed, surface, terrain);
|
||||
require(!plan.isEmpty(), "Natural sloping rock must admit a bounded utility network");
|
||||
require(plan.equals(LostCityUnderground15.plan(seed, surface, terrain)), "Network plan must be stable for the same seed and terrain");
|
||||
var capture = render(plan, terrain);
|
||||
require(capture.blocks.equals(render(plan, terrain).blocks), "Saved module rendering changed between calls");
|
||||
for (var network : plan.networks()) {
|
||||
for (int i = 0; i < network.modules().size(); i++) for (int j = i + 1; j < network.modules().size(); j++)
|
||||
require(!LostCityUnderground15.supportConflict(network.modules().get(i), network.modules().get(j)),
|
||||
"One network can overwrite another module's support or neighboring rock anchor");
|
||||
var access = network.modules().stream().filter(m -> m.kind() == LostCityUnderground15.Kind.ACCESS).findFirst().orElseThrow();
|
||||
BlockPos hatch = capture.blocks.entrySet().stream().filter(e -> e.getValue().is(Blocks.DARK_OAK_TRAPDOOR)
|
||||
&& access.contains(e.getKey().getX(), e.getKey().getY(), e.getKey().getZ())).map(Map.Entry::getKey).findFirst().orElseThrow();
|
||||
Set<BlockPos> reached = walk(capture, hatch.above());
|
||||
for (var module : network.modules()) {
|
||||
seen.add(module.kind());
|
||||
require(reached.stream().anyMatch(p -> p.getY() == module.floorY() + 1
|
||||
&& p.getX() > module.minX() && p.getX() < module.maxX() && p.getZ() > module.minZ() && p.getZ() < module.maxZ()),
|
||||
"No player-sized route from the surface hatch to " + module.kind() + " " + module.centerX() + "," + module.centerZ());
|
||||
verifyBoundsAndSupports(plan, module, terrain, capture);
|
||||
if (module.kind() == LostCityUnderground15.Kind.EXPANSION_HALL) {
|
||||
verifyHall(module, terrain, capture, reached);
|
||||
if (Integer.bitCount(module.openings()) >= 2) {
|
||||
circuit = true;
|
||||
verifyAlternativeRoute(network, module, capture, hatch.above());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sealed(capture);
|
||||
}
|
||||
require(seen.containsAll(Set.of(LostCityUnderground15.Kind.SEWER, LostCityUnderground15.Kind.POOLROOM,
|
||||
LostCityUnderground15.Kind.GARAGE, LostCityUnderground15.Kind.PASSAGE, LostCityUnderground15.Kind.EXPANSION_HALL)),
|
||||
"Reference terrain never demonstrated the dry hall, garage, pools and connected sewers");
|
||||
require(circuit, "Shared network never demonstrated a traversable circuit through the large hall");
|
||||
rejectedHallFoundation(surface, terrain);
|
||||
|
||||
LostCityUnderground15.Terrain voidTerrain = new RockTerrain(0) { @Override public boolean solid(int x, int y, int z) { return false; } };
|
||||
require(LostCityUnderground15.plan(0, surface, voidTerrain).isEmpty(), "Unsupported void must never acquire a network");
|
||||
LostCityUnderground15.Terrain protectedTerrain = new RockTerrain(0) { @Override public boolean protectedAt(int x, int y, int z) { return true; } };
|
||||
require(LostCityUnderground15.plan(0, surface, protectedTerrain).isEmpty(), "Protected hydrology must remain untouched");
|
||||
LostCityUnderground15.Terrain foreignTerrain = new RockTerrain(0) { @Override public boolean owns(int x, int z) { return false; } };
|
||||
require(LostCityUnderground15.plan(0, surface, foreignTerrain).isEmpty(), "Networks cannot enter foreign chunks");
|
||||
var building = new LostCityPlan.Parcel(LostCityPlan.Kind.HALL, -12, -12, 25, 25, 220, 2, 0, 12, 12, new int[0]);
|
||||
var obstructed = new LostCityPlan(List.of(new LostCityPlan.District(0, 0, 0,
|
||||
List.of(surface.districts().getFirst().parcels().getFirst(), building))));
|
||||
require(LostCityUnderground15.plan(0, obstructed, terrain).isEmpty(), "A surface access cannot cut through another planned building");
|
||||
neighboringDistricts(terrain);
|
||||
supportDependencies();
|
||||
|
||||
for (int mode : new int[] {1, 2}) {
|
||||
var caverns = new RockTerrain(mode); boolean witnessed = false;
|
||||
for (int seed = 0; seed < 24 && !witnessed; seed++) {
|
||||
var plan = LostCityUnderground15.plan(seed, surface, caverns);
|
||||
for (var network : plan.networks()) for (var module : network.modules()) {
|
||||
for (var support : module.supports()) if (support.type() == (mode == 1 ? LostCityUnderground15.Type.CHAIN : LostCityUnderground15.Type.PILLAR)) witnessed = true;
|
||||
verifyBoundsAndSupports(plan, module, caverns, render(plan, caverns));
|
||||
}
|
||||
}
|
||||
require(witnessed, "Cavern fixture did not demonstrate a certified " + (mode == 1 ? "ceiling chain" : "short pillar"));
|
||||
}
|
||||
var districts = new ArrayList<LostCityPlan.District>();
|
||||
for (int i = 0; i < 100; i++) districts.addAll(surface(i * 256, 0).districts());
|
||||
var bounded = LostCityUnderground15.plan(42, new LostCityPlan(districts), terrain);
|
||||
require(bounded.samples() <= LostCityUnderground15.MAX_SAMPLES && bounded.networks().size() < 100,
|
||||
"Large inputs must stop at the fixed sampling budget without publishing incomplete networks");
|
||||
}
|
||||
|
||||
private static LostCityPlan surface(int x, int z) {
|
||||
var street = new LostCityPlan.Parcel(LostCityPlan.Kind.STREET, x - 2, z - 12, 5, 25, 220, 0, 0, 7, 3, new int[0]);
|
||||
return new LostCityPlan(List.of(new LostCityPlan.District(x >> 4, z >> 4, 3, List.of(street))));
|
||||
}
|
||||
private static void neighboringDistricts(LostCityUnderground15.Terrain terrain) {
|
||||
var first = surface(0, 0);
|
||||
var neighbor = surface(16, 0);
|
||||
var firstAlone = LostCityUnderground15.plan(0, first, terrain);
|
||||
var neighborAlone = LostCityUnderground15.plan(0, neighbor, terrain);
|
||||
require(firstAlone.networks().size() == 1 && neighborAlone.networks().size() == 1,
|
||||
"Collision fixture must admit both districts independently");
|
||||
require(firstAlone.networks().getFirst().modules().stream().anyMatch(a -> neighborAlone.networks().getFirst().modules()
|
||||
.stream().anyMatch(b -> boundsOverlap(a, b))), "Neighboring networks must really intersect before collision filtering");
|
||||
var districts = new ArrayList<LostCityPlan.District>(first.districts());
|
||||
districts.addAll(neighbor.districts());
|
||||
var combined = LostCityUnderground15.plan(0, new LostCityPlan(districts), terrain);
|
||||
require(combined.networks().equals(firstAlone.networks()),
|
||||
"A colliding district must not alter the first network or publish disconnected branches");
|
||||
|
||||
// Reject the colliding network, then continue with another independent district.
|
||||
districts.addAll(surface(256, 0).districts());
|
||||
var continued = LostCityUnderground15.plan(0, new LostCityPlan(districts), terrain);
|
||||
require(continued.networks().size() == 2, "A nearby collision must not suppress a later independent district");
|
||||
for (int i = 0; i < continued.networks().size(); i++) for (int j = i + 1; j < continued.networks().size(); j++) {
|
||||
var other = continued.networks().get(j);
|
||||
require(continued.networks().get(i).modules().stream().noneMatch(a -> other.modules().stream().anyMatch(b -> boundsOverlap(a, b))),
|
||||
"Accepted districts intersect through a room, surface access or expanded support bounds");
|
||||
require(continued.networks().get(i).modules().stream().noneMatch(a -> other.modules().stream()
|
||||
.anyMatch(b -> LostCityUnderground15.supportConflict(a, b))), "A neighboring district can dig out a network's rock anchor");
|
||||
}
|
||||
render(continued, terrain);
|
||||
}
|
||||
private static boolean boundsOverlap(LostCityUnderground15.Module a, LostCityUnderground15.Module b) {
|
||||
return a.boundsMinX() <= b.boundsMaxX() && a.boundsMaxX() >= b.boundsMinX()
|
||||
&& a.boundsMinY() <= b.boundsMaxY() && a.boundsMaxY() >= b.boundsMinY()
|
||||
&& a.boundsMinZ() <= b.boundsMaxZ() && a.boundsMaxZ() >= b.boundsMinZ();
|
||||
}
|
||||
private static void supportDependencies() {
|
||||
var pillar = new LostCityUnderground15.Module(LostCityUnderground15.Kind.PASSAGE,
|
||||
0, 120, 0, 5, 6, 5, 5, 42, List.of(
|
||||
new LostCityUnderground15.Support(LostCityUnderground15.Type.PILLAR, 2, 115, 2, 119)));
|
||||
var below = new LostCityUnderground15.Module(LostCityUnderground15.Kind.PASSAGE,
|
||||
0, 109, 0, 5, 6, 5, 5, 7, List.of());
|
||||
require(!boundsOverlap(pillar, below), "Footing fixture must have disjoint native write bounds");
|
||||
require(LostCityUnderground15.supportConflict(pillar, below) && LostCityUnderground15.supportConflict(below, pillar),
|
||||
"A later module can replace the untouched rock directly below a pillar");
|
||||
|
||||
var chain = new LostCityUnderground15.Module(LostCityUnderground15.Kind.PASSAGE,
|
||||
0, 120, 0, 5, 6, 5, 5, 42, List.of(
|
||||
new LostCityUnderground15.Support(LostCityUnderground15.Type.CHAIN, 2, 126, 2, 130)));
|
||||
var above = new LostCityUnderground15.Module(LostCityUnderground15.Kind.PASSAGE,
|
||||
0, 131, 0, 5, 6, 5, 5, 7, List.of());
|
||||
require(!boundsOverlap(chain, above), "Ceiling fixture must have disjoint native write bounds");
|
||||
require(LostCityUnderground15.supportConflict(chain, above) && LostCityUnderground15.supportConflict(above, chain),
|
||||
"A later module can replace the untouched rock directly above a ceiling chain");
|
||||
var beside = new LostCityUnderground15.Module(LostCityUnderground15.Kind.PASSAGE,
|
||||
5, 109, 0, 5, 28, 5, 5, 137, List.of());
|
||||
require(!LostCityUnderground15.supportConflict(pillar, beside) && !LostCityUnderground15.supportConflict(chain, beside),
|
||||
"Rock dependencies must not suppress modules in a neighboring horizontal column");
|
||||
var writtenSupport = new LostCityUnderground15.Module(LostCityUnderground15.Kind.PASSAGE,
|
||||
0, 110, 0, 5, 6, 5, 5, 137, List.of());
|
||||
require(LostCityUnderground15.supportConflict(pillar, writtenSupport), "A following module can erase a written pillar segment");
|
||||
var hall = new LostCityUnderground15.Module(LostCityUnderground15.Kind.EXPANSION_HALL,
|
||||
0, 150, 0, 25, 10, 25, 1, 1515, List.of());
|
||||
var underHall = new LostCityUnderground15.Module(LostCityUnderground15.Kind.PASSAGE,
|
||||
7, 143, 7, 5, 6, 5, 5, 42, List.of());
|
||||
require(!boundsOverlap(hall, underHall) && LostCityUnderground15.supportConflict(hall, underHall)
|
||||
&& LostCityUnderground15.supportConflict(underHall, hall), "A following passage can erase the hall's unpainted rock foundation");
|
||||
var deeper = new LostCityUnderground15.Module(LostCityUnderground15.Kind.PASSAGE,
|
||||
7, 142, 7, 5, 6, 5, 5, 42, List.of());
|
||||
require(!LostCityUnderground15.supportConflict(hall, deeper), "Hall foundation protection grew beyond its two certified layers");
|
||||
}
|
||||
private static void verifyHall(LostCityUnderground15.Module hall, LostCityUnderground15.Terrain terrain,
|
||||
Capture capture, Set<BlockPos> reached) {
|
||||
require(hall.width() == 25 && hall.depth() == 25 && hall.height() == 10, "The shared dry hall lost its intended large interior");
|
||||
require(reached.contains(new BlockPos(hall.centerX(), hall.floorY() + 2, hall.centerZ())),
|
||||
"The empty architectural plinth cannot be reached from the surface");
|
||||
int lanterns = 0;
|
||||
for (int z = hall.minZ(); z <= hall.maxZ(); z++) for (int x = hall.minX(); x <= hall.maxX(); x++) {
|
||||
require(terrain.solid(x, hall.minY() - 1, z) && terrain.solid(x, hall.minY() - 2, z),
|
||||
"Large hall was admitted without its two complete rock floor layers");
|
||||
for (int y = hall.minY(); y <= hall.maxY(); y++) {
|
||||
var p = new BlockPos(x, y, z); var state = capture.block(p);
|
||||
require(state.getFluidState().isEmpty(), "The reserved expansion hall must remain entirely dry");
|
||||
if (state.is(Blocks.LANTERN)) {
|
||||
lanterns++;
|
||||
require(state.getValue(BlockStateProperties.HANGING), "Hall lantern must use its actual overhead suspension");
|
||||
var anchor = p.above();
|
||||
while (capture.block(anchor).is(Blocks.IRON_CHAIN) && anchor.getY() <= hall.maxY()) anchor = anchor.above();
|
||||
require(anchor.getY() <= hall.maxY() && solid(capture.block(anchor)), "Hall lantern has no complete ceiling anchor");
|
||||
}
|
||||
}
|
||||
}
|
||||
require(lanterns == 4, "The hall's four supported lights were lost");
|
||||
}
|
||||
private static void verifyAlternativeRoute(LostCityUnderground15.Network network, LostCityUnderground15.Module hall,
|
||||
Capture capture, BlockPos start) {
|
||||
var entrance = network.modules().stream().filter(m -> m.kind() == LostCityUnderground15.Kind.PASSAGE
|
||||
&& boundsOverlap(m, hall)).findFirst().orElseThrow();
|
||||
var obstructed = new Capture(capture.terrain); obstructed.blocks.putAll(capture.blocks);
|
||||
int cx = entrance.centerX(), cz = entrance.centerZ();
|
||||
for (int y = entrance.minY() + 2; y <= entrance.maxY(); y++) {
|
||||
if (entrance.width() >= entrance.depth()) {
|
||||
for (int z = entrance.minZ(); z <= entrance.maxZ(); z++) obstructed.blocks.put(new BlockPos(cx, y, z), Blocks.STONE.defaultBlockState());
|
||||
} else {
|
||||
for (int x = entrance.minX(); x <= entrance.maxX(); x++) obstructed.blocks.put(new BlockPos(x, y, cz), Blocks.STONE.defaultBlockState());
|
||||
}
|
||||
}
|
||||
require(walk(obstructed, start).contains(new BlockPos(hall.centerX(), hall.floorY() + 2, hall.centerZ())),
|
||||
"A nominal network circuit has no alternate walkable route when one hall passage is blocked");
|
||||
}
|
||||
private static void rejectedHallFoundation(LostCityPlan surface, LostCityUnderground15.Terrain terrain) {
|
||||
var original = LostCityUnderground15.plan(0, surface, terrain).networks().stream().flatMap(n -> n.modules().stream())
|
||||
.filter(m -> m.kind() == LostCityUnderground15.Kind.EXPANSION_HALL).findFirst().orElseThrow();
|
||||
int x = original.centerX(), z = original.centerZ(), y = original.minY() - 2;
|
||||
var missingRock = new RockTerrain(0) {
|
||||
@Override public boolean solid(int wx, int wy, int wz) {
|
||||
return !(wx == x && wy == y && wz == z) && super.solid(wx, wy, wz);
|
||||
}
|
||||
};
|
||||
var changed = LostCityUnderground15.plan(0, surface, missingRock);
|
||||
require(changed.networks().stream().flatMap(n -> n.modules().stream()).noneMatch(m -> m.kind() == LostCityUnderground15.Kind.EXPANSION_HALL
|
||||
&& m.minY() == original.minY() && x >= m.minX() && x <= m.maxX() && z >= m.minZ() && z <= m.maxZ()),
|
||||
"Even one missing rock block under the hall must invalidate that emplacement");
|
||||
}
|
||||
/** Broad natural rock, varying upper surface and a narrow cavity beneath the district's access. */
|
||||
private static class RockTerrain implements LostCityUnderground15.Terrain {
|
||||
final int cavern;
|
||||
RockTerrain(int cavern) { this.cavern = cavern; }
|
||||
@Override public boolean solid(int x, int y, int z) {
|
||||
int radius = Math.max(Math.abs(x), Math.abs(z));
|
||||
if (cavern != 0 && radius >= 3 && radius <= 15 && y <= 203 && y >= (cavern == 1 ? 184 : 195)) return false;
|
||||
int top = 220 + (int) Math.floor(Math.sin(x / 37.0) * 3 * Math.min(1, Math.abs(x) / 8.0));
|
||||
// Lower, irregular cavities remain under the proposed room floors instead of making an ideal solid world.
|
||||
return y <= top && y >= 160 && !(y < 182 && (x * x + z * z) % 71 < 14);
|
||||
}
|
||||
@Override public boolean protectedAt(int x, int y, int z) { return false; }
|
||||
@Override public boolean owns(int x, int z) { return true; }
|
||||
}
|
||||
private static Capture render(LostCityUnderground15.Plan plan, LostCityUnderground15.Terrain terrain) {
|
||||
var result = new Capture(terrain);
|
||||
for (var network : plan.networks()) for (var module : network.modules()) {
|
||||
LostCityUnderground15.render(module, new LostCityRenderer.BlockWriter() {
|
||||
@Override public void set(int x, int y, int z, BlockState state) {
|
||||
var p = new BlockPos(module.minX() + x, module.minY() + y, module.minZ() + z);
|
||||
require(p.getX() >= module.boundsMinX() && p.getX() <= module.boundsMaxX()
|
||||
&& p.getY() >= module.boundsMinY() && p.getY() <= module.boundsMaxY()
|
||||
&& p.getZ() >= module.boundsMinZ() && p.getZ() <= module.boundsMaxZ(), "Renderer escaped persisted module bounds");
|
||||
require(!terrain.protectedAt(p.getX(), p.getY(), p.getZ()) && terrain.owns(p.getX(), p.getZ()), "Renderer wrote protected or foreign terrain");
|
||||
require(plan.contains(p.getX(), p.getY(), p.getZ()), "Decoration mask does not protect an actual underground write");
|
||||
result.blocks.put(p, state);
|
||||
}
|
||||
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> loot, long seed) { throw new AssertionError("No unsolicited underground loot"); }
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
private static void verifyBoundsAndSupports(LostCityUnderground15.Plan plan, LostCityUnderground15.Module m,
|
||||
LostCityUnderground15.Terrain terrain, Capture capture) {
|
||||
for (var support : m.supports()) {
|
||||
require(support.maxY() - support.minY() < 8, "Support became a pillar to the bottom of the world");
|
||||
int anchor = support.type() == LostCityUnderground15.Type.PILLAR ? support.minY() : support.maxY();
|
||||
int deeper = anchor + (support.type() == LostCityUnderground15.Type.PILLAR ? -1 : 1);
|
||||
require(terrain.solid(support.x(), anchor, support.z()) && terrain.solid(support.x(), deeper, support.z()), "Support has no two-block rock anchor");
|
||||
for (int y = support.minY(); y <= support.maxY(); y++) {
|
||||
require(plan.contains(support.x(), y, support.z()), "Support missing from the protection mask");
|
||||
require(!capture.block(new BlockPos(support.x(), y, support.z())).isAir(), "Support is interrupted");
|
||||
}
|
||||
}
|
||||
}
|
||||
private static Set<BlockPos> walk(Capture c, BlockPos start) {
|
||||
var seen = new HashSet<BlockPos>(); var queue = new ArrayDeque<BlockPos>(); seen.add(start); queue.add(start);
|
||||
while (!queue.isEmpty()) {
|
||||
var p = queue.removeFirst();
|
||||
for (Direction direction : Direction.Plane.HORIZONTAL) for (int dy : new int[] {0, 1, -1}) {
|
||||
var q = p.relative(direction).offset(0, dy, 0);
|
||||
if (Math.abs(q.getX() - start.getX()) > 110 || Math.abs(q.getZ() - start.getZ()) > 110 || Math.abs(q.getY() - start.getY()) > 36) continue;
|
||||
if (stand(c, q) && seen.add(q)) queue.add(q);
|
||||
}
|
||||
for (int dy : new int[] {-1, 1}) {
|
||||
var q = p.offset(0, dy, 0);
|
||||
if (Math.abs(q.getY() - start.getY()) > 36) continue;
|
||||
if ((climb(c.block(p)) || climb(c.block(q)) || climb(c.block(p.below()))) && stand(c, q) && seen.add(q)) queue.add(q);
|
||||
}
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
private static boolean stand(Capture c, BlockPos p) {
|
||||
return passable(c.block(p)) && passable(c.block(p.above()))
|
||||
&& (solid(c.block(p.below())) || climb(c.block(p)) || climb(c.block(p.below())));
|
||||
}
|
||||
private static boolean climb(BlockState b) { return b.is(Blocks.LADDER) || b.is(Blocks.DARK_OAK_TRAPDOOR); }
|
||||
private static boolean passable(BlockState b) {
|
||||
return b.isAir() || climb(b) || b.is(Blocks.REDSTONE_WALL_TORCH);
|
||||
}
|
||||
private static boolean solid(BlockState b) { return b.canOcclude() && b.getFluidState().isEmpty(); }
|
||||
private static void sealed(Capture c) {
|
||||
var wet = new HashSet<BlockPos>(); var queue = new ArrayDeque<BlockPos>();
|
||||
c.blocks.forEach((p, state) -> { if (!state.getFluidState().isEmpty()) { wet.add(p); queue.add(p); } });
|
||||
require(wet.size() > 20, "Reference poolroom has no substantial retained water");
|
||||
Set<BlockPos> sources = Set.copyOf(wet);
|
||||
while (!queue.isEmpty()) {
|
||||
var p = queue.removeFirst();
|
||||
for (Direction direction : new Direction[] {Direction.DOWN, Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST}) {
|
||||
var q = p.relative(direction);
|
||||
if (!solid(c.block(q)) && wet.add(q)) {
|
||||
require(sources.contains(q), "Water can leave its closed basin: " + q);
|
||||
queue.add(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private static final class Capture {
|
||||
final Map<BlockPos, BlockState> blocks = new HashMap<>(); final LostCityUnderground15.Terrain terrain;
|
||||
Capture(LostCityUnderground15.Terrain terrain) { this.terrain = terrain; }
|
||||
BlockState block(BlockPos p) { return blocks.getOrDefault(p, terrain.solid(p.getX(), p.getY(), p.getZ())
|
||||
? Blocks.STONE.defaultBlockState() : Blocks.AIR.defaultBlockState()); }
|
||||
}
|
||||
private static void require(boolean condition, String message) { if (!condition) throw new AssertionError(message); }
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
name = "Sanctuary"
|
||||
author = "KOKA99CAB"
|
||||
version = "0.1.0-alpha.14"
|
||||
version = "0.1.0-alpha.15"
|
||||
pack-format = "packwiz:1.1.0"
|
||||
|
||||
[index]
|
||||
|
||||
Reference in New Issue
Block a user