From dd9c795695f3d1e2fd7669282e515be35a0aa1e4 Mon Sep 17 00:00:00 2001 From: koka Date: Wed, 9 Sep 2026 16:40:26 +0200 Subject: [PATCH] Add alpha.15 compact Lost City and detailed utility networks --- CHANGELOG.md | 19 + README.md | 23 +- docs/backlog.md | 41 + docs/testing-alpha15.md | 175 ++++ docs/vision.md | 22 +- gradle.properties | 4 +- mods/sanctuary/build.gradle | 41 +- .../gametest/ExpansionVoidReuseGameTests.java | 6 +- .../gametest/LostCity15WorldGameTests.java | 911 ++++++++++++++++++ .../gametest/PopulationDiagnostics.java | 4 +- .../UnifiedWorldSettingsDiagnostics.java | 5 +- .../mixin/SanctuaryGameTestServerMixin.java | 9 +- .../java/fr/koka/sanctuary/SanctuaryMod.java | 2 + .../expansion/ExpansionAdmission.java | 4 +- .../expansion/ExpansionCommands.java | 2 +- .../sanctuary/expansion/ExpansionIsland.java | 15 +- .../sanctuary/expansion/ExpansionJournal.java | 10 +- .../expansion/ExpansionPlacement.java | 7 +- .../sanctuary/expansion/ExpansionRuntime.java | 4 +- .../ExpansionSpatialPlacement14.java | 6 +- .../expansion/ExpansionStructures.java | 4 + .../expansion/ExpansionVoidReuse.java | 8 +- .../UnifiedDecorationProtectionMixin.java | 2 +- .../worldgen/SanctuaryBiomeSource.java | 20 +- .../worldgen/SanctuaryChunkGenerator.java | 72 +- .../worldgen/city/LostCityInteriors15.java | 468 +++++++++ .../worldgen/city/LostCityPiece15.java | 92 ++ .../worldgen/city/LostCityPlanner15.java | 456 +++++++++ .../worldgen/city/LostCityProtection15.java | 76 ++ .../worldgen/city/LostCityStructure.java | 22 +- .../worldgen/city/LostCityStructures.java | 14 +- .../worldgen/city/LostCitySupportPiece15.java | 68 ++ .../worldgen/city/LostCitySurface15.java | 90 ++ .../worldgen/city/LostCityTerrain15.java | 73 ++ .../worldgen/city/LostCityUnderground15.java | 492 ++++++++++ .../city/LostCityUndergroundPiece15.java | 88 ++ .../assets/sanctuary/lang/en_us.json | 4 +- .../assets/sanctuary/lang/fr_fr.json | 2 +- .../worldgen/world_preset/sanctuary.json | 2 +- .../worldgen/world_preset/sanctuary_v14.json | 81 ++ .../expansion/ExpansionJournalSmoke.java | 16 + .../city/LostCityInteriors15Smoke.java | 305 ++++++ .../worldgen/city/LostCityPlanner15Smoke.java | 177 ++++ .../city/LostCityProtection15Smoke.java | 197 ++++ .../city/LostCityUnderground15Smoke.java | 318 ++++++ packwiz/pack.toml | 2 +- 46 files changed, 4389 insertions(+), 70 deletions(-) create mode 100644 docs/testing-alpha15.md create mode 100644 mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/LostCity15WorldGameTests.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityInteriors15.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityPiece15.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityPlanner15.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityProtection15.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCitySupportPiece15.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCitySurface15.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityTerrain15.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityUnderground15.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityUndergroundPiece15.java create mode 100644 mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary_v14.json create mode 100644 mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/city/LostCityInteriors15Smoke.java create mode 100644 mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/city/LostCityPlanner15Smoke.java create mode 100644 mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/city/LostCityProtection15Smoke.java create mode 100644 mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/city/LostCityUnderground15Smoke.java diff --git a/CHANGELOG.md b/CHANGELOG.md index bbd45c9..d846ce2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## 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. Distribution en préparation. 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 diff --git a/README.md b/README.md index 086104a..fc051d3 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,29 @@ 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 publiée** : Lost City ajoute des quartiers en ruine au monde flottant. Le canal packwiz et la même instance Prism Mac sont à jour ; voir [Distribution](docs/packwiz.md). +## 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 livraison packwiz est en préparation. + ## Alpha.14 — Lost City Lost City est un biome de quartiers contemporains abandonnés, composé par du @@ -332,7 +349,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, validée ; livraison en préparation | 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 @@ -361,7 +378,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). diff --git a/docs/backlog.md b/docs/backlog.md index 5a72b9c..a7cac96 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -585,6 +585,47 @@ conservés. Aucun essai Windows natif effectué. Les résultats sont suivis dans 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 : code validé, livraison en préparation.** 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 ; voir [les preuves](testing-alpha15.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. diff --git a/docs/testing-alpha15.md b/docs/testing-alpha15.md new file mode 100644 index 0000000..c7c3754 --- /dev/null +++ b/docs/testing-alpha15.md @@ -0,0 +1,175 @@ +# Alpha.15 — intérieurs, sous-sols et ville compacte + +**État : code et génération validés ; distribution en préparation.** +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 reste 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. diff --git a/docs/vision.md b/docs/vision.md index c074665..d725457 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -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 diff --git a/gradle.properties b/gradle.properties index 9e36698..522d991 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/mods/sanctuary/build.gradle b/mods/sanctuary/build.gradle index 05b2a83..5851c32 100644 --- a/mods/sanctuary/build.gradle +++ b/mods/sanctuary/build.gradle @@ -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') } diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/ExpansionVoidReuseGameTests.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/ExpansionVoidReuseGameTests.java index e49cdb8..d524377 100644 --- a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/ExpansionVoidReuseGameTests.java +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/ExpansionVoidReuseGameTests.java @@ -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); diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/LostCity15WorldGameTests.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/LostCity15WorldGameTests.java new file mode 100644 index 0000000..0dc9437 --- /dev/null +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/LostCity15WorldGameTests.java @@ -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 REQUIRED_UNDERGROUND = Set.of( + LostCityUnderground15.Kind.SEWER, LostCityUnderground15.Kind.POOLROOM, LostCityUnderground15.Kind.GARAGE); + private static final Set 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 report; + final List> sites = new ArrayList<>(); + final List> prospects = new ArrayList<>(); + final List> naturalPlans = new ArrayList<>(); + final List wetModules = new ArrayList<>(); + final List wetSurfacePools = new ArrayList<>(); + final Set observedKinds = new HashSet<>(); + final Set observedSurfaceKinds = new HashSet<>(); + final Set forced = new HashSet<>(); + final List 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) 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(); + underground.networks().forEach(n -> n.modules().forEach(m -> kinds.add(m.kind()))); + var surfaceKinds = new HashSet(); + 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 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 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 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 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 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 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 surfaceKinds = new TreeMap<>(), moduleKinds = new TreeMap<>(); + List> 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(); + 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> 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 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 verifyCityCirculation(GameTestHelper helper, List parcels) { + Set permitted = new HashSet<>(); + List sections = new ArrayList<>(); + List> entrances = new ArrayList<>(); + List 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 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> 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 doors = openDoors(helper, bounds, false); + try { + var targets = LostCityInteriors15.rooms(p); + Set 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 openDoors(GameTestHelper helper, BoundingBox bounds, boolean hatch) { + var level = helper.getLevel(); Map 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 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 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 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 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 expectedSurfaceWater(LostCityPlan.Parcel p) { + return surfaceWater(p).sources(); + } + + private record SurfaceWater(Set sources, Set flowEnvelope, + Map shell, Map steps) { } + + private static SurfaceWater surfaceWater(LostCityPlan.Parcel p) { + Map 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 loot, long seed) { } + }); + Set sources = new HashSet<>(), basin = new HashSet<>(); + Map 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 envelope = new HashSet<>(sources); + ArrayDeque 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 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 expectedWater(LostCityUnderground15.Module module) { + Set 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 loot, long seed) { } + }); + return water; + } + + private static Map 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 walk(GameTestHelper helper, BlockPos start, Predicate inside) { + var level = helper.getLevel(); + helper.assertTrue(inside.test(start) && standable(level, start), "Native route has a blocked starting position: " + start); + Set reached = new HashSet<>(); ArrayDeque 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 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> verifyInteriorModels(GameTestHelper helper) { + var context = StructurePieceSerializationContext.fromLevel(helper.getLevel()); + List> 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> fluids = new ArrayList<>(), surfacePools = new ArrayList<>(), surfaceAccess = new ArrayList<>(), circulation = new ArrayList<>(); + Set 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 report(ServerLevel level) { + var result = new LinkedHashMap(); + 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 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 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); } + } +} diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/PopulationDiagnostics.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/PopulationDiagnostics.java index 40f96cc..6d365cf 100644 --- a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/PopulationDiagnostics.java +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/PopulationDiagnostics.java @@ -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 { diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/UnifiedWorldSettingsDiagnostics.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/UnifiedWorldSettingsDiagnostics.java index 122b15c..2987056 100644 --- a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/UnifiedWorldSettingsDiagnostics.java +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/UnifiedWorldSettingsDiagnostics.java @@ -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, diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/mixin/SanctuaryGameTestServerMixin.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/mixin/SanctuaryGameTestServerMixin.java index 79a0b84..6254ca4 100644 --- a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/mixin/SanctuaryGameTestServerMixin.java +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/mixin/SanctuaryGameTestServerMixin.java @@ -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 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) { diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/SanctuaryMod.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/SanctuaryMod.java index 4c0b485..b9c0ae7 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/SanctuaryMod.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/SanctuaryMod.java @@ -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); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionAdmission.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionAdmission.java index 215d141..077289d 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionAdmission.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionAdmission.java @@ -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 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; } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionCommands.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionCommands.java index 59caeaf..3ceb04e 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionCommands.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionCommands.java @@ -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); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionIsland.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionIsland.java index 06e432e..2398146 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionIsland.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionIsland.java @@ -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(); } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionJournal.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionJournal.java index a481849..caec075 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionJournal.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionJournal.java @@ -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); } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionPlacement.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionPlacement.java index 185cb48..366ec10 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionPlacement.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionPlacement.java @@ -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; diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionRuntime.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionRuntime.java index ee5dc4a..ad83d0d 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionRuntime.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionRuntime.java @@ -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) { diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionSpatialPlacement14.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionSpatialPlacement14.java index 5501548..b075b43 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionSpatialPlacement14.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionSpatialPlacement14.java @@ -22,8 +22,8 @@ public final class ExpansionSpatialPlacement14 { * Subsequent calls reject its complete write footprint, including pending reservations. */ public static List candidates(List 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); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionStructures.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionStructures.java index b64ea71..fa8b6b7 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionStructures.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionStructures.java @@ -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; diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionVoidReuse.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionVoidReuse.java index 44e4a10..e5585ca 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionVoidReuse.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionVoidReuse.java @@ -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 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 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) diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/UnifiedDecorationProtectionMixin.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/UnifiedDecorationProtectionMixin.java index 97c781a..a8c170b 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/UnifiedDecorationProtectionMixin.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/UnifiedDecorationProtectionMixin.java @@ -19,7 +19,7 @@ public abstract class UnifiedDecorationProtectionMixin { private void sanctuary$keepIslandOwnership(BlockPos pos, CallbackInfoReturnable 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); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/SanctuaryBiomeSource.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/SanctuaryBiomeSource.java index 71241a6..5b8a6e4 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/SanctuaryBiomeSource.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/SanctuaryBiomeSource.java @@ -30,20 +30,25 @@ public final class SanctuaryBiomeSource extends ExpansionBiomeSource { ).apply(i, SanctuaryBiomeSource::new)); public static final MapCodec 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 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> allBiomes; private final PopulationIslandBiomeSource population; private final boolean cities; + private final int generation; private final Holder cityBiome; private volatile java.util.Map cityPlans = java.util.Map.of(); public SanctuaryBiomeSource(List> biomes) { - this(biomes, false); + this(biomes, 13); } - private SanctuaryBiomeSource(List> biomes, boolean cities) { + private SanctuaryBiomeSource(List> 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 plans) { cityPlans = java.util.Map.copyOf(plans); } - @Override protected MapCodec codec() { return cities ? CITY_CODEC : CODEC; } + @Override protected MapCodec 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; } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/SanctuaryChunkGenerator.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/SanctuaryChunkGenerator.java index 23b0f2f..4d6c288 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/SanctuaryChunkGenerator.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/SanctuaryChunkGenerator.java @@ -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 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 cityPlans = new java.util.concurrent.ConcurrentHashMap<>(); private volatile java.util.Map activeCityPlans = java.util.Map.of(); + private final java.util.Map undergroundPlans = new java.util.concurrent.ConcurrentHashMap<>(); + private final java.util.Map> 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 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 codec() { return generation == 14 ? CITY_CODEC : CODEC; } + @Override protected MapCodec 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 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 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(); 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 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. */ diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityInteriors15.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityInteriors15.java new file mode 100644 index 0000000..d5e8e23 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityInteriors15.java @@ -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 rooms(Parcel parcel) { + validate(parcel); + List 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 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 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); + } + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityPiece15.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityPiece15.java new file mode 100644 index 0000000..1d682f8 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityPiece15.java @@ -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 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()); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityPlanner15.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityPlanner15.java new file mode 100644 index 0000000..dac54fb --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityPlanner15.java @@ -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 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 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 supports = List.of(); + if (best != null && best.lots().size() >= minimum) { + var pieces = new ArrayList(); + 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 candidates(long seed, int cx, int cz, int diameter, boolean origin, int cells, Probe probe) { + var result = new ArrayList(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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 edges, List lots, int cells) { + Map> 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 visited = new HashSet<>(); Town best = null; + for (Edge first : edges) { + if (!visited.add(first.a())) continue; + Set component = new HashSet<>(); component.add(first.a()); + ArrayDeque 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 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(); + 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 assign(long seed, List 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(); + 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 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 pieces, List supports) {} + private record Town(int startX, int startZ, long seed, List nodes, List edges, List 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 columns = new HashMap<>(); + final Map 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); } + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityProtection15.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityProtection15.java new file mode 100644 index 0000000..423c2ed --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityProtection15.java @@ -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> chunks; + private LostCityProtection15(Map> 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> 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>(); + chunks.forEach((key, value) -> immutable.put(key, List.copyOf(value))); + return new LostCityProtection15(Map.copyOf(immutable)); + } + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityStructure.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityStructure.java index 12595e9..dafd0cd 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityStructure.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityStructure.java @@ -14,9 +14,9 @@ public final class LostCityStructure extends Structure { @Override protected Optional 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(); } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityStructures.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityStructures.java index b7f9fdf..5187808 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityStructures.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityStructures.java @@ -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 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) { 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> findNearest(ServerLevel level, HolderSet 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 holder = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(KEY); if (!structures.contains(holder)) return null; var candidates = new ArrayList(); 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); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCitySupportPiece15.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCitySupportPiece15.java new file mode 100644 index 0000000..939ee5e --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCitySupportPiece15.java @@ -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()); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCitySurface15.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCitySurface15.java new file mode 100644 index 0000000..730a936 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCitySurface15.java @@ -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 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)); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityTerrain15.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityTerrain15.java new file mode 100644 index 0000000..46d3b7a --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityTerrain15.java @@ -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 protectedVolume; + private final Map columns = new HashMap<>(); + private final BlockPos fluidCandidate; + + public LostCityTerrain15(ExpansionIsland island, NoiseBasedChunkGenerator generator, RandomState random, + LevelHeightAccessor height, Predicate 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(); } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityUnderground15.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityUnderground15.java new file mode 100644 index 0000000..77ed98f --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityUnderground15.java @@ -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 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 values) { return new Module(kind, minX, minY, minZ, width, height, depth, openings, seed, values); } + } + public record Network(int startChunkX, int startChunkZ, long seed, List 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 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(); + 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.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(); 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 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 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 anchors(LostCityPlan.Parcel p) { + var values = new ArrayList(); + 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 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 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 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(); + 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 lattice(int min, int max) { + var values = new ArrayList(); 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 blocks = new HashMap<>(); final Map 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); } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityUndergroundPiece15.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityUndergroundPiece15.java new file mode 100644 index 0000000..4a5c153 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityUndergroundPiece15.java @@ -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(); + 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 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()); + } +} diff --git a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json index 329a63f..402ec6a 100644 --- a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json +++ b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json @@ -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." diff --git a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json index 3004ec3..c512f1d 100644 --- a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json +++ b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json @@ -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." diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary.json index 7c37cd0..63c187b 100644 --- a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary.json +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary.json @@ -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", diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary_v14.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary_v14.json new file mode 100644 index 0000000..7c37cd0 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary_v14.json @@ -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" + } + } + } +} diff --git a/mods/sanctuary/src/test/java/fr/koka/sanctuary/expansion/ExpansionJournalSmoke.java b/mods/sanctuary/src/test/java/fr/koka/sanctuary/expansion/ExpansionJournalSmoke.java index 8c5fe57..0c43d39 100644 --- a/mods/sanctuary/src/test/java/fr/koka/sanctuary/expansion/ExpansionJournalSmoke.java +++ b/mods/sanctuary/src/test/java/fr/koka/sanctuary/expansion/ExpansionJournalSmoke.java @@ -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"); diff --git a/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/city/LostCityInteriors15Smoke.java b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/city/LostCityInteriors15Smoke.java new file mode 100644 index 0000000..afa4b9a --- /dev/null +++ b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/city/LostCityInteriors15Smoke.java @@ -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> 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 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 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 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 counts = new HashMap<>(); + result.blocks.values().forEach(state -> counts.merge(state, 1L, Long::sum)); + java.util.function.Predicate 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 walk(Capture result, BlockPos start) { + require(standable(result, start), label(result.parcel) + " has a blocked entrance"); + Set seen = new HashSet<>(); + ArrayDeque 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 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 table, long seed) {} + private static final class Capture implements LostCityRenderer.BlockWriter { + final Parcel parcel; + final Map blocks = new HashMap<>(); + final Map 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 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); } +} diff --git a/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/city/LostCityPlanner15Smoke.java b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/city/LostCityPlanner15Smoke.java new file mode 100644 index 0000000..4c05ead --- /dev/null +++ b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/city/LostCityPlanner15Smoke.java @@ -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 roads = new HashMap<>(); + Set 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 walkable = new HashSet<>(); + roads.forEach((position, cell) -> { if (cell.walkable()) walkable.add(position); }); + require(!walkable.isEmpty(), "Town has no walkable street"); + Set visited = new HashSet<>(); ArrayDeque 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 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 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 difference(Set a, Set b) { Set 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); } +} diff --git a/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/city/LostCityProtection15Smoke.java b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/city/LostCityProtection15Smoke.java new file mode 100644 index 0000000..846186e --- /dev/null +++ b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/city/LostCityProtection15Smoke.java @@ -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 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 render(LostCityPlan.Parcel p) { + var result = new HashMap(); + 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 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); + } +} diff --git a/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/city/LostCityUnderground15Smoke.java b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/city/LostCityUnderground15Smoke.java new file mode 100644 index 0000000..04c31cc --- /dev/null +++ b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/city/LostCityUnderground15Smoke.java @@ -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(); + 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 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(); + 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(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 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 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 walk(Capture c, BlockPos start) { + var seen = new HashSet(); var queue = new ArrayDeque(); 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(); var queue = new ArrayDeque(); + 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 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 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); } +} diff --git a/packwiz/pack.toml b/packwiz/pack.toml index 75f4292..3e74eba 100644 --- a/packwiz/pack.toml +++ b/packwiz/pack.toml @@ -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]