From d6e0c144d06277147c4ea3a23c6f54708780c2d2 Mon Sep 17 00:00:00 2001 From: koka Date: Thu, 10 Sep 2026 11:52:24 +0200 Subject: [PATCH] feat: selective surface placement and independent underground core for alpha22 --- README.md | 29 +- docs/backlog.md | 26 + docs/structures-audit.md | 27 + docs/testing-alpha22.md | 153 +++++ docs/vision.md | 21 + gradle.properties | 4 +- mods/sanctuary/build.gradle | 26 +- .../gametest/PopulationDiagnostics.java | 2 +- .../gametest/Selective22WorldGameTests.java | 156 +++++ .../gametest/Transit21WorldGameTests.java | 2 +- .../gametest/Underground22NativeChecks.java | 126 ++++ .../UnifiedWorldSettingsDiagnostics.java | 43 +- .../mixin/SanctuaryGameTestServerMixin.java | 8 +- .../java/fr/koka/sanctuary/SanctuaryMod.java | 3 + .../client/SanctuaryWorldOptionsScreen.java | 4 +- .../expansion/ExpansionChunkGenerator.java | 1 + .../sanctuary/expansion/ExpansionIsland.java | 4 +- .../UnifiedDecorationProtectionMixin.java | 2 +- .../worldgen/SanctuaryBiomeSource.java | 6 +- .../worldgen/SanctuaryChunkGenerator.java | 131 ++-- .../worldgen/city/LostCityPlanner22.java | 147 +++++ .../worldgen/city/LostCityStructure.java | 14 +- .../worldgen/city/LostCityUnderground22.java | 187 ++++++ .../city/LostCityUndergroundStructure22.java | 41 ++ .../city/LostCityUndergroundStructures22.java | 108 ++++ .../worldgen/heritage/HeritageCommands19.java | 12 +- .../worldgen/heritage/HeritagePlanner22.java | 612 ++++++++++++++++++ .../heritage/HeritageStructure19.java | 6 +- .../heritage/HeritageStructures19.java | 4 +- .../worldgen/ruins/RuinsStructure17.java | 6 +- .../worldgen/ruins/RuinsStructures17.java | 4 +- .../worldgen/transit/TransitPiece22.java | 52 ++ .../worldgen/transit/TransitPlanner22.java | 13 + .../worldgen/transit/TransitRenderer22.java | 24 + .../worldgen/transit/TransitRoute22.java | 236 +++++++ .../worldgen/transit/TransitStructure21.java | 8 +- .../worldgen/transit/TransitStructures21.java | 10 +- .../assets/sanctuary/lang/en_us.json | 2 + .../assets/sanctuary/lang/fr_fr.json | 2 + .../structure/underground_hall22.json | 7 + .../worldgen/world_preset/sanctuary.json | 2 +- .../worldgen/world_preset/sanctuary_v21.json | 81 +++ .../expansion/ExpansionJournalSmoke.java | 4 +- .../worldgen/city/LostCityPlanner22Smoke.java | 109 ++++ .../city/LostCityUnderground22Smoke.java | 94 +++ .../heritage/HeritagePlanner22Smoke.java | 47 ++ .../transit/TransitPlanner22Smoke.java | 34 + .../transit/TransitRenderer22Smoke.java | 76 +++ .../worldgen/transit/TransitRoute22Smoke.java | 112 ++++ packwiz/pack.toml | 2 +- 50 files changed, 2738 insertions(+), 92 deletions(-) create mode 100644 docs/testing-alpha22.md create mode 100644 mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Selective22WorldGameTests.java create mode 100644 mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Underground22NativeChecks.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityPlanner22.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityUnderground22.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityUndergroundStructure22.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityUndergroundStructures22.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritagePlanner22.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitPiece22.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitPlanner22.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitRenderer22.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitRoute22.java create mode 100644 mods/sanctuary/src/main/resources/data/sanctuary/worldgen/structure/underground_hall22.json create mode 100644 mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary_v21.json create mode 100644 mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/city/LostCityPlanner22Smoke.java create mode 100644 mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/city/LostCityUnderground22Smoke.java create mode 100644 mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/heritage/HeritagePlanner22Smoke.java create mode 100644 mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/transit/TransitPlanner22Smoke.java create mode 100644 mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/transit/TransitRenderer22Smoke.java create mode 100644 mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/transit/TransitRoute22Smoke.java diff --git a/README.md b/README.md index 2ae85de..6d84891 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,30 @@ infrastructures qui permettront progressivement d'ouvrir de nouveaux continents. 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) 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.21 est publiée et -vérifiée** : palettes sobres, couvertures portées, petits quartiers reliés et -premières galeries industrielles. Voir [le catalogue alpha.21](docs/architecture-alpha21.md), -[les essais alpha.21](docs/testing-alpha21.md) -et l’état du canal packwiz dans [Distribution](docs/packwiz.md). +décrits dans la vision ne sont pas encore implémentés. **L’alpha.22 est +vérifiée** : le placement des structures redevient sélectif. Les palettes, +intérieurs et filons de l’alpha.21 sont conservés. Voir [les essais22](docs/testing-alpha22.md) +et [la distribution](docs/packwiz.md). + +## Alpha.22 — trouver le bon terrain + +La nouvelle génération préfère refuser une ville ou un bâtiment facultatif +qui ne trouve pas un emplacement adapté. Plus de ville forcée sur une réserve +artificielle ; moins de terrassement, des fondations proches de la roche et des +chemins qui suivent le relief. La grande traversée et la salle d’expansion sont +réservées en priorité et peuvent exister sans ville au-dessus. Les autres lieux +n’ont pas de quota garanti. + +Créer un **nouveau monde Sanctuary** pour utiliser ces règles. Les tailles +512/724/1024 et le bouton natif **Personnaliser** restent inchangés ; les anciens +mondes conservent leurs générateurs. La vision retient désormais **Minecraft +comme archéologie d’un autre Minecraft** : chaque ruine témoigne d’une ancienne +solution, et de ceux qui l’ont construite puis ont disparu. + +Les trois tailles natives, la réouverture, le cas expérimental OFF et la +compatibilité21 passent, ainsi que `check build assemblePack` et ses huit +GameTests généraux. Le MRpack et le ZIP Prism sont vérifiés ; publication et +synchronisation sont suivies dans [la distribution](docs/packwiz.md). ## Alpha.21 — matériaux, quartiers et traversées diff --git a/docs/backlog.md b/docs/backlog.md index 28d9d4f..19fe367 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -811,6 +811,32 @@ puis livrer le pack stable21 et synchroniser la même instance Prism. **État : livré en alpha.21 ; MRpack, release et canal packwiz publiés, même instance Prism synchronisée deux fois. Tests, sauvegardes et limites documentés dans `docs/testing-alpha21.md`.** +### WG-25 — Placement sélectif et archéologie — alpha.22 + +**Branche :** `codex/placement-selectif-alpha22`. + +Annule pour les nouveaux mondes l’assouplissement de WG-24. Chercher un terrain +vraiment adapté et accepter l’absence d’une structure plutôt que de forcer son +implantation. La ville ne reçoit plus de réservation artificielle de secours. +Bâtiments et chemins limitent terrassement et remblai. Exception explicite : +la grande traversée et la salle d’expansion sont des éléments de base, à placer +en priorité indépendamment de la ville ; les égouts peuvent être ailleurs. +Les galeries gardent leur génie civil et la salle reçoit un accès autonome. +Les recherches restent bornées. + +Conserver les palettes, les intérieurs, les filons et le rendu21. Nouveau codec22 +pour les nouveaux mondes, sans migration ni retouche des mondes précédents. +Vérifier l’admission sur les bons terrains, les refus sur les mauvais, la création +native, la réouverture et la compatibilité21. Livrer le MRpack et mettre à jour +le canal et la même instance Prism en conservant les données personnelles. + +Inscrire dans la vision « Minecraft comme archéologie d’un autre Minecraft » : +chaque ruine garde la trace d’une solution ancienne. Les neuf personnages sont +ceux nommés dans la vision ; la découverte et les rôles restent à définir. +Ce cadre narratif ne constitue pas une nouvelle mécanique implémentée. + +**État : vérifications réussies, artefacts préparés ; publication en cours.** Voir [les essais22](testing-alpha22.md). + ## Réserve de thèmes futurs Ces thèmes servent à retrouver la vision, pas à demander leur implémentation immédiate. On en extrait un ticket seulement lorsqu'il devient utile au prochain incrément jouable. diff --git a/docs/structures-audit.md b/docs/structures-audit.md index ce9970a..edd7317 100644 --- a/docs/structures-audit.md +++ b/docs/structures-audit.md @@ -25,6 +25,33 @@ Sources locales : [planificateur17](../mods/sanctuary/src/main/java/fr/koka/sanc [rendu17](../mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsRenderer17.java), [preuves alpha.17](testing-alpha17.md), [vision](vision.md). +## Ligne de conception retenue après cet audit + +**Minecraft comme archéologie d’un autre Minecraft.** Steve, Ari, Sunny, Kai, +Zuri, Alex, Mefe, Makena, Noor sont les personnages disparus de la partie. Ils +ont découvert quelque chose de révolutionnaire et ont continué parce que ça +marchait, jusqu’au moment où ça n’a plus marché. + +> Ils n’ont pas ouvert quelque chose qu’ils n’auraient pas dû ouvrir. Ils ont +> construit quelque chose qu’ils ne savaient plus refermer. + +Chaque ruine doit conserver la trace d’une ancienne solution : une infrastructure +dont l’auteur a progressivement disparu derrière son fonctionnement. Ce canon +ne définit pas la découverte, les rôles individuels ou une catastrophe confirmée. +Il guide les prochaines structures ; il n’ajoute aucun personnage, récit jouable +ou système de progression aux versions déjà livrées. L’état alpha.17 décrit +ci-dessus reste le constat historique de cet audit. + +Pour valider la conception d’un lieu, relever concrètement : + +- **Un usage ancien lisible** dans les pièces, les outils et les circulations. +- **Une implantation fonctionnelle** expliquée par le terrain, une ressource + ou une destination réellement présente. +- **Des traces d’entretien, d’extension ou de chantier** qui montrent comment + l’installation a été utilisée et adaptée. +- **Un lien futur à la progression**, explicitement décrit comme une intention + tant que son système et ses interactions ne sont pas implémentés. + ## Faire comprendre les usages avant d’introduire leurs systèmes Chaque lieu devrait répondre à trois questions : pourquoi était-il construit diff --git a/docs/testing-alpha22.md b/docs/testing-alpha22.md new file mode 100644 index 0000000..b79a298 --- /dev/null +++ b/docs/testing-alpha22.md @@ -0,0 +1,153 @@ +# Alpha.22 — placement sélectif + +Ticket WG-25, branche `codex/placement-selectif-alpha22`. +Minecraft 26.3-pre-2, Fabric Loader 0.19.5, Fabric API 0.160.0+26.3 et Java25, +sans changement de dépendances. + +## Contrat + +Les bâtiments de surface redeviennent sélectifs : une ville peut être absente +si aucun emplacement n’est adéquat. Deux éléments de base sont prioritaires : +**la grande traversée souterraine et la salle d’expansion**, même sans ville. +Leurs emplacements sont réservés avant les petits réseaux et le catalogue +expérimental. Une recherche bornée ne prouve pas qu’aucun autre emplacement +possible n’existe dans toute l’île. + +- Ville : aucune réserve artificielle de secours, aucun pilier de rattrapage, + terrassement/remblai limité à deux blocs par colonne. Les fondations sont + entièrement recertifiées dans la roche. Les quartiers supplémentaires exigent + un chemin complet vers les quartiers retenus. Au plus trois propositions dans + un budget partagé de20 000 colonnes et1 120 000 contrôles de volume. +- Infrastructures : relief maximal de quatre blocs sur l’emprise17×17, coupe et + remblai limités à deux blocs, deux couches de roche intacte sous chaque colonne + de fondation, sans piliers de secours. Jusqu’à24 candidats par famille, avec + les mêmes plafonds globaux de16 000 colonnes et800 000 sondages. +- Chemins patrimoniaux : coupe/remblai limité à un bloc, accès indépendant et + raccord complet requis pour publier une liaison. +- Traversée : conserve le génie civil21, avec deux accès réels, une chaussée + de cinq blocs et deux trottoirs. Les piliers sont bornés à16 blocs ; les + travées sans grille complète sont limitées à34 blocs et reposent sur les + modules adjacents. La recherche22 intervient avant les structures facultatives + pour leur réserver l’emprise. Si les lignes droites sont refusées, une seconde + passe cherche des accès différents et des détours. Chaque passe est limitée à + 12 000 colonnes et3 millions de sondages (24 000 et6 millions au total au plus). + Ses parois peuvent traverser des cavités existantes sans écraser les volumes + d’eau et de lave protégés. +- Salle : recherche autonome d’un accès de surface et d’une salle25×25, sans + faux quartier ni socle de ville. Une salle déjà présente sous une ville évite + un doublon. Les égouts et annexes peuvent accompagner ce lieu indépendant. + + +Les palettes, les plans intérieurs et les petits filons minables restent ceux +de21. Les anciens petits réseaux expérimentaux17 et les sous-sols des villes gardent +leur géométrie ; leurs protections restent respectées. +Le nouveau principe narratif est documenté dans [la vision](vision.md) et +[l’audit](structures-audit.md), sans activer de système de progression. + +## Sauvegardes + +Le seul preset public utilise désormais `sanctuary:island_v22`. Le preset caché +`sanctuary_v21` est la copie de l’ancien preset public. Les anciens codecs, +planificateurs et rendus restent disponibles. Les pièces21 sont réutilisées +avec leur format existant ; les nouveaux coudes possèdent une pièce22 distincte +pour fermer leurs murs sans modifier les jonctions21 sauvegardées. +La salle indépendante possède un type de structure22 +et réutilise les pièces souterraines20. +Le journal identifie la génération22 et refuse une lecture croisée avec21. +Aucun monde personnel n’est ouvert, converti ou régénéré. + +Les nouvelles règles nécessitent un **nouveau monde Sanctuary**. Petit512, +Moyen724 par défaut et Grand1024 restent proposés. L’option **Structures +expérimentales** reste enregistrée à la création ; en22 elle retire le catalogue +facultatif, tandis que la traversée et la salle restent des éléments de base. +Le commutateur Minecraft général des structures demeure respecté. + +## Témoin natif Moyen, graine0 + +La création et la réouverture dans deux JVM distinctes passent. La ville est +refusée ; la traversée comporte23 modules, dont deux accès et une salle de +maintenance. Son axe s’étend sur340 blocs. Les accès sont `-170 253 -100` et +`170 254 -100`. Le hall d’expansion indépendant se rejoint par la trappe +`2 252 0` ; son réseau comprend l’accès, la salle, deux passages et un égout. +La piscine est une annexe conditionnelle, absente sur ce témoin. + +Les14 bâtiments patrimoniaux trouvent un terrain adapté ; sept liaisons sont +retenues. Les115 chunks de l’emprise sont générés en statut FULL. Les plans, +pièces natives et une modification témoin persistent après réouverture. Le +contrôle natif de circulation vérifie la vraie trappe, les échelles soutenues, +les ouvertures et le trajet jusqu’au socle central. Aucun déplacement de joueur +ou chargement d’un monde personnel n’a été utilisé pour ces essais. + +## Vérifications complémentaires + +**Petit512, graine42 :**17 modules, dont un coude sauvegardé avec sa pièce22, +et une salle indépendante. Les anciens alignements droits sont refusés ; le +repli trouve une paire d’accès naturels et conserve une chaussée continue. +Les portiques sont `-68 250 -93` et `68 250 43`, la trappe du hall `43 244 -10`. +La marche native relie les sorties en564 déplacements et atteint53 points de +contrôle. Huit bâtiments patrimoniaux et six liaisons sont admis ; aucune ville +n’est forcée. Les88 chunks de l’emprise sont vérifiés en statut FULL. + +**Grand1024, graine42 :** un quartier naturel est admis avec son réseau de +sous-sols : salle d’expansion, piscine, égout et parking. Aucun hall autonome +supplémentaire n’est créé. La traversée comporte31 modules et s’étend sur476 +blocs ; portiques `-272 249 -8` et `204 250 -8`. La marche native les relie +en740 déplacements et atteint98 points de contrôle. Quatorze bâtiments +patrimoniaux et huit liaisons sont admis. Les179 chunks de l’emprise sont +vérifiés en statut FULL ; la salle et ses17 ouvertures sont accessibles. + +Les tests purs des trois tailles passent sur des terrains plats adaptés ; +falaises, eau/protections, vides et appuis insuffisants sont refusés pour les +bâtiments. Le hall autonome et ses annexes sont vérifiés sans fixture de ville. +Les suites historiques restent actives. Avec l’option expérimentale désactivée, +le témoin Moyen conserve ses23 modules de traversée et sa salle indépendante, +sans bâtiments patrimoniaux ni petits réseaux expérimentaux. La marche native +relie les deux sorties en548 déplacements et atteint74 points de contrôle ; +les échelles et l’accès au hall restent praticables. + +Les créations et réouvertures de génération21 sont identiques aux rapports de +la release21, hors identifiants de processus et dossier de test. Les391 fichiers +de production historiques qui ne nécessitent pas de raccord22 restent identiques +octet pour octet ; le preset caché21 est la copie exacte du précédent preset +public. Les masques sont vérifiés à l’intérieur et hors des structures. + +`check build assemblePack` passe :75 tâches, dont les huit GameTests généraux. +Le JAR, l’index packwiz, le MRpack et le ZIP Prism sont vérifiés. Aucun code de +test n’est embarqué dans le JAR. SHA-256 du JAR : + +```text +10d7ef32ac2e81a977ea4854503bafa29e0bc10929984b76df01d6f5f61f9056 +``` + +SHA-256 du MRpack : + +```text +0b553fcdd8b6a2ec26f72059e428d2cc0ce526f36ef8580dd3e50c1dfa40ec78 +``` + +Les reçus de publication et de synchronisation sont suivis dans +[la distribution](packwiz.md). + +Ces témoins vérifient les trois tailles, avec et sans ville ; ils ne constituent +pas une preuve de présence pour toutes les graines. La recherche reste bornée +et refuse un volume protégé ou insuffisamment porté. Aucun essai visuel client +ou essai Windows natif n’est revendiqué. + +## Reproduire dans le dossier de développement + +Avec Java25, création du témoin Moyen puis réouverture du même monde de test : + +```sh +./gradlew :sanctuary:runGameTest -PsanctuarySelective22Tests=true -PsanctuaryDensityMaps=false +./gradlew :sanctuary:runGameTest -PsanctuarySelective22Tests=true -PsanctuaryDensityMaps=false -PsanctuaryExpansionReload=true +``` + +Pour une nouvelle création Petit ou Grand, ajouter `-PsanctuaryTestSeed=42` +et `-PsanctuaryTestDiameter=512` ou `1024`. Le cas OFF utilise +`-PsanctuaryExperimentalStructures=false`. Chaque réouverture doit suivre la +création correspondante, avec les mêmes paramètres. Les rapports sont dans +`mods/sanctuary/build/run/gameTest/diagnostics/selective22--/`. + +Le témoin historique utilise `-PsanctuaryHeritage21Tests=true`, puis le même +drapeau avec `-PsanctuaryExpansionReload=true`. La vérification de livraison +reste `./gradlew check build assemblePack`. Exécuter ces JVM séquentiellement. diff --git a/docs/vision.md b/docs/vision.md index f5388da..d478275 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -22,6 +22,27 @@ L'objectif mythologique est de progresser d'une condition très limitée, proche - **Humour, surprise et souvenirs.** Les noms d'entités, les événements, les animaux rares et les objets insolites comptent autant que les systèmes économiques. - **Un développement par tickets.** Livrer une petite mécanique jouable et vérifiable, puis l'ajuster avec des tickets de fonctionnalités et de bugs. Ne pas construire tous les systèmes avant de pouvoir jouer. +## Minecraft comme archéologie d’un autre Minecraft + +**Canon et ligne de conception retenus.** Steve, Ari, Sunny, Kai, Zuri, Alex, +Mefe, Makena, Noor sont les personnages disparus de la partie. Ils ont découvert +quelque chose de révolutionnaire et ont continué parce que ça marchait. Jusqu’au +moment où ça n’a plus marché. + +> Ils n’ont pas ouvert quelque chose qu’ils n’auraient pas dû ouvrir. Ils ont +> construit quelque chose qu’ils ne savaient plus refermer. + +Chaque ruine doit être une trace d’une ancienne solution, pas seulement la trace +d’un problème : une infrastructure dont l’auteur a progressivement disparu +derrière son fonctionnement. Son ancien usage, ses circulations, ses réparations, +ses extensions et ses chantiers doivent permettre de comprendre ce qui avait +été résolu ici, même quand l’installation est abandonnée. + +La nature de la découverte et les rôles individuels restent à définir. Aucune +catastrophe précise n’est confirmée par ce principe. **La mise en scène de cette +histoire et ses liens avec la progression restent à implémenter** ; les versions +livrées et leurs tests établissent ce que chaque structure contient effectivement. + ## Périmètre de départ Le premier incrément porte sur le socle Fabric et la génération du monde : retrouver le générateur 26.2, produire une île principale dans un vrai vide, assurer une arrivée commune et sûre, puis poser une manière reproductible de générer des continents à une distance, une direction et une taille choisies. diff --git a/gradle.properties b/gradle.properties index 9783016..5d8dc5c 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.21 -pack_version=0.1.0-alpha.21 +mod_version=0.1.0-alpha.22 +pack_version=0.1.0-alpha.22 maven_group=fr.koka.sanctuary diff --git a/mods/sanctuary/build.gradle b/mods/sanctuary/build.gradle index a50a801..74304ff 100644 --- a/mods/sanctuary/build.gradle +++ b/mods/sanctuary/build.gradle @@ -40,7 +40,9 @@ tasks.named('runGameTest') { systemProperty('sanctuary.test.unified', providers.gradleProperty('sanctuaryUnifiedTests').getOrElse('true')) systemProperty('sanctuary.test.diameter', providers.gradleProperty('sanctuaryTestDiameter').getOrElse('724')) systemProperty('sanctuary.test.generation', providers.gradleProperty('sanctuaryTestGeneration').getOrElse( - providers.gradleProperty('sanctuaryLostCityTests').map { it.toBoolean() }.getOrElse(false) ? '14' : providers.gradleProperty('sanctuaryLostCity15Tests').map { it.toBoolean() }.getOrElse(false) ? '15' : providers.gradleProperty('sanctuaryLostCity16Tests').map { it.toBoolean() }.getOrElse(false) ? '16' : providers.gradleProperty('sanctuaryRuins17Tests').map { it.toBoolean() }.getOrElse(false) ? '17' : providers.gradleProperty('sanctuaryHeritage19Tests').map { it.toBoolean() }.getOrElse(false) ? '19' : (providers.gradleProperty('sanctuaryHeritage20Tests').map { it.toBoolean() }.getOrElse(false) || providers.gradleProperty('sanctuaryLostCity20Tests').map { it.toBoolean() }.getOrElse(false)) ? '20' : '21')) + providers.gradleProperty('sanctuaryLostCityTests').map { it.toBoolean() }.getOrElse(false) ? '14' : providers.gradleProperty('sanctuaryLostCity15Tests').map { it.toBoolean() }.getOrElse(false) ? '15' : providers.gradleProperty('sanctuaryLostCity16Tests').map { it.toBoolean() }.getOrElse(false) ? '16' : providers.gradleProperty('sanctuaryRuins17Tests').map { it.toBoolean() }.getOrElse(false) ? '17' : providers.gradleProperty('sanctuaryHeritage19Tests').map { it.toBoolean() }.getOrElse(false) ? '19' : (providers.gradleProperty('sanctuaryHeritage20Tests').map { it.toBoolean() }.getOrElse(false) || providers.gradleProperty('sanctuaryLostCity20Tests').map { it.toBoolean() }.getOrElse(false)) ? '20' : (providers.gradleProperty('sanctuaryHeritage21Tests').map { it.toBoolean() }.getOrElse(false) || providers.gradleProperty('sanctuaryLostCity21Tests').map { it.toBoolean() }.getOrElse(false) || providers.gradleProperty('sanctuaryTransit21Tests').map { it.toBoolean() }.getOrElse(false)) ? '21' : '22')) + systemProperty('sanctuary.test.selective22', providers.gradleProperty('sanctuarySelective22Tests').getOrElse('false')) + systemProperty('sanctuary.transit.plannerDiagnostics', providers.gradleProperty('sanctuaryTransitDiagnostics').getOrElse('false')) systemProperty('sanctuary.test.heritage21', providers.gradleProperty('sanctuaryHeritage21Tests').getOrElse('false')) systemProperty('sanctuary.test.lostCity21', providers.gradleProperty('sanctuaryLostCity21Tests').getOrElse('false')) systemProperty('sanctuary.test.transit21', providers.gradleProperty('sanctuaryTransit21Tests').getOrElse('false')) @@ -83,6 +85,8 @@ tasks.named('processGametestResources') { inputs.property('heritage21', heritage21) def lostCity21 = providers.gradleProperty('sanctuaryLostCity21Tests').map { it.toBoolean() }.getOrElse(false) inputs.property('lostCity21', lostCity21) + def selective22 = providers.gradleProperty('sanctuarySelective22Tests').map { it.toBoolean() }.getOrElse(false) + inputs.property('selective22', selective22) def transit21 = providers.gradleProperty('sanctuaryTransit21Tests').map { it.toBoolean() }.getOrElse(false) inputs.property('transit21', transit21) def heritage19 = providers.gradleProperty('sanctuaryHeritage19Tests').map { it.toBoolean() }.getOrElse(false) @@ -90,7 +94,7 @@ tasks.named('processGametestResources') { def voidReuse = providers.gradleProperty('sanctuaryVoidReuseTests').map { it.toBoolean() }.getOrElse(false) inputs.property('voidReuse', voidReuse) filesMatching('fabric.mod.json') { - expand(gametest_entrypoint: transit21 ? 'fr.koka.sanctuary.gametest.Transit21WorldGameTests' : lostCity21 ? 'fr.koka.sanctuary.gametest.LostCity21WorldGameTests' : heritage21 ? 'fr.koka.sanctuary.gametest.Heritage21WorldGameTests' : lostCity20 ? 'fr.koka.sanctuary.gametest.LostCity20WorldGameTests' : heritage20 ? 'fr.koka.sanctuary.gametest.Heritage20WorldGameTests' : heritage19 ? 'fr.koka.sanctuary.gametest.Heritage19WorldGameTests' : ruins17 ? 'fr.koka.sanctuary.gametest.Ruins17WorldGameTests' : voidReuse ? 'fr.koka.sanctuary.gametest.ExpansionVoidReuseGameTests' : lostCity16 ? 'fr.koka.sanctuary.gametest.LostCity16WorldGameTests' : lostCity15 ? 'fr.koka.sanctuary.gametest.LostCity15WorldGameTests' : lostCity ? 'fr.koka.sanctuary.gametest.LostCityWorldGameTests' : expansion ? 'fr.koka.sanctuary.gametest.ExpansionWorldGameTests' : benchmark + expand(gametest_entrypoint: selective22 ? 'fr.koka.sanctuary.gametest.Selective22WorldGameTests' : transit21 ? 'fr.koka.sanctuary.gametest.Transit21WorldGameTests' : lostCity21 ? 'fr.koka.sanctuary.gametest.LostCity21WorldGameTests' : heritage21 ? 'fr.koka.sanctuary.gametest.Heritage21WorldGameTests' : lostCity20 ? 'fr.koka.sanctuary.gametest.LostCity20WorldGameTests' : heritage20 ? 'fr.koka.sanctuary.gametest.Heritage20WorldGameTests' : heritage19 ? 'fr.koka.sanctuary.gametest.Heritage19WorldGameTests' : ruins17 ? 'fr.koka.sanctuary.gametest.Ruins17WorldGameTests' : voidReuse ? 'fr.koka.sanctuary.gametest.ExpansionVoidReuseGameTests' : lostCity16 ? 'fr.koka.sanctuary.gametest.LostCity16WorldGameTests' : lostCity15 ? 'fr.koka.sanctuary.gametest.LostCity15WorldGameTests' : lostCity ? 'fr.koka.sanctuary.gametest.LostCityWorldGameTests' : expansion ? 'fr.koka.sanctuary.gametest.ExpansionWorldGameTests' : benchmark ? 'fr.koka.sanctuary.gametest.WorldgenPerformanceGameTests' : unified ? 'fr.koka.sanctuary.gametest.UnifiedWorldGameTests' : 'fr.koka.sanctuary.gametest.SanctuaryWorldGameTests') @@ -508,3 +512,21 @@ tasks.named('lostCitySurface21Smoke') { if (providers.gradleProperty('sanctuaryArchitecturePreview').isPresent()) systemProperty('sanctuary.city21.exteriorPreview', rootProject.layout.buildDirectory.file('alpha21-preview/exterior.json').get().asFile.absolutePath) } + +// Generation22 filters surface placement and adds an independent core hall; historical tests still run. +[ + lostCity22PlannerSmoke: 'city.LostCityPlanner22Smoke', + underground22Smoke: 'city.LostCityUnderground22Smoke', + heritage22PlannerSmoke: 'heritage.HeritagePlanner22Smoke', + transit22PlannerSmoke: 'transit.TransitPlanner22Smoke', + transit22RouteSmoke: 'transit.TransitRoute22Smoke', + transit22RendererSmoke: 'transit.TransitRenderer22Smoke' +].each { name, harness -> + def smoke = tasks.register(name, JavaExec) { + group = 'verification' + dependsOn('testClasses') + classpath = sourceSets.test.runtimeClasspath + mainClass = "fr.koka.sanctuary.worldgen.${harness}" + } + tasks.named('check') { dependsOn(smoke) } +} 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 944ce67..db3f639 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 @@ -211,7 +211,7 @@ public final class PopulationDiagnostics { void prepare() throws IOException { boolean heritage = level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator sanctuary - && (sanctuary.rootGeneration() == 19 || sanctuary.rootGeneration() == 20 || sanctuary.rootGeneration() == 21) && sanctuary.experimentalStructures(); + && (sanctuary.rootGeneration() == 22 || (sanctuary.rootGeneration() == 19 || sanctuary.rootGeneration() == 20 || sanctuary.rootGeneration() == 21) && sanctuary.experimentalStructures()); // Generation19 explicitly prospects destinations across the root island before publishing them. // A consulted hydrology region is a bounded immutable plan, not a loaded/generated chunk. // Historical generators retain their original near-spawn contract. diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Selective22WorldGameTests.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Selective22WorldGameTests.java new file mode 100644 index 0000000..358448d --- /dev/null +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Selective22WorldGameTests.java @@ -0,0 +1,156 @@ +package fr.koka.sanctuary.gametest; + +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mojang.serialization.JsonOps; +import fr.koka.sanctuary.expansion.ExpansionIsland; +import fr.koka.sanctuary.worldgen.PopulationGenerationContext; +import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator; +import fr.koka.sanctuary.worldgen.city.*; +import fr.koka.sanctuary.worldgen.heritage.*; +import fr.koka.sanctuary.worldgen.transit.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import net.fabricmc.fabric.api.gametest.v1.GameTest; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.resources.RegistryOps; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext; +import net.minecraft.world.level.storage.LevelResource; + +/** Read-only admission audit, then native starts and reload in the disposable development world. */ +public final class Selective22WorldGameTests { + @GameTest(maxTicks = 12000) + public void naturalSitesAndRefusalsPersist(GameTestHelper helper) throws Exception { + var level = helper.getLevel(); + var generator = (SanctuaryChunkGenerator) level.getChunkSource().getGenerator(); + helper.assertTrue(generator.rootGeneration() == 22, "Use a fresh22 development world"); + var root = ExpansionIsland.origin(generator.initialDiameter(), level.getSeed(), 22); + var city = generator.cityPlacement21(root); + var heritage = generator.heritagePlan19(); + var transit = generator.transitPlan21(); + helper.assertTrue(city != null && city.reservation() == null && city.supports().isEmpty(), "No forced city envelope or rescue piers"); + var binding = Objects.requireNonNull(PopulationGenerationContext.of(level)); + var terrain = new HeritageTerrain21(root, binding.generator(), binding.random(), level, p -> false); + int columns = 0; + for (var district : city.surface().districts()) for (var parcel : district.parcels()) { + for (int x = parcel.minX(); x <= parcel.maxX(); x++) for (int z = parcel.minZ(); z <= parcel.maxZ(); z++) { + int floor = parcel.surfaceY(x - parcel.minX(), z - parcel.minZ()); + helper.assertTrue(Math.abs(floor - terrain.surfaceY(x, z)) <= 2, "City must fit natural relief"); columns++; + } + } + // The three samplers have independent bounded caches, just like production planning. + terrain = new HeritageTerrain21(root, binding.generator(), binding.random(), level, p -> false); + for (var site : heritage.sites()) { + helper.assertTrue(site.supports().isEmpty(), "No heritage rescue piers"); + for (int x = site.minX(); x <= site.maxX(); x++) for (int z = site.minZ(); z <= site.maxZ(); z++) { + helper.assertTrue(Math.abs(site.floorY() - terrain.surfaceY(x, z)) <= 2, "Building cut/fill stays within two blocks"); + helper.assertTrue(terrain.solid(x, site.minY() - 1, z) && terrain.solid(x, site.minY() - 2, z), "Every foundation column rests on original rock"); columns++; + } + } + helper.assertTrue(!transit.isEmpty(), "The reference island retains its core traversal"); + var hall = generator.undergroundHall22(); + helper.assertTrue(!hall.isEmpty() || generator.undergroundPlan16(root).networks().stream().flatMap(n -> n.modules().stream()) + .anyMatch(m -> m.kind() == LostCityUnderground16.Kind.EXPANSION_HALL), "Core hall exists with or without a surface town"); + for (var module : transit.modules()) for (var support : module.supports()) + helper.assertTrue(support.maxY() - support.minY() + 1 <= 16, "Core traversal retains bounded engineered supports"); + if (!generator.experimentalStructures()) helper.assertTrue(heritage.isEmpty() && generator.ruinsPlan17().isEmpty(), "OFF removes optional catalogue, not core hall and traversal"); + var gson = new GsonBuilder().setPrettyPrinting().create(); + var encoded = ChunkGenerator.CODEC.encodeStart(RegistryOps.create(JsonOps.INSTANCE, level.registryAccess()), generator).getOrThrow(); + var geometry = gson.toJsonTree(Map.of("city", city.surface().districts(), "heritage_sites", heritage.sites(), + "heritage_links", heritage.links(), "heritage_segments", heritage.segments(), "transit", transit.modules(), "independent_hall", hall.networks())); + var report = new LinkedHashMap(); + report.put("seed", level.getSeed()); report.put("size", generator.initialDiameter()); + report.put("process_id", ProcessHandle.current().pid()); report.put("generator_codec", encoded); report.put("geometry", geometry); + report.put("experimental", generator.experimentalStructures()); report.put("city_districts", city.surface().districts().size()); + report.put("heritage_sites", heritage.sites().size()); report.put("heritage_links", heritage.links().size()); report.put("transit_modules", transit.modules().size()); + report.put("independent_hall_networks", hall.networks().size()); + report.put("core_entries", transit.modules().stream().filter(m -> m.kind() == TransitPlan21.Kind.ACCESS).map(m -> List.of(m.centerX(), m.surfaceY() + 1, m.minZ() - 2)).toList()); + report.put("hall_entries", hall.networks().stream().map(n -> { var at = LostCityUndergroundStructures22.locator(n); return List.of(at.getX(), at.getY(), at.getZ()); }).toList()); + report.put("natural_columns_audited", columns); report.put("city_diagnostics", city.diagnostics()); + report.put("heritage_diagnostics", heritage.diagnostics()); report.put("transit_diagnostics", transit.diagnostic()); + var starts = new ArrayList(); + var origins = new LinkedHashMap, Set>(); + origins.put(LostCityStructures.KEY, new HashSet<>()); origins.put(HeritageStructures19.KEY, new HashSet<>()); origins.put(TransitStructures21.KEY, new HashSet<>()); origins.put(LostCityUndergroundStructures22.KEY, new HashSet<>()); + city.surface().districts().forEach(d -> origins.get(LostCityStructures.KEY).add(new ChunkPos(d.startChunkX(), d.startChunkZ()))); + heritage.sites().forEach(s -> origins.get(HeritageStructures19.KEY).add(new ChunkPos(s.startChunkX(), s.startChunkZ()))); + heritage.segments().forEach(s -> origins.get(HeritageStructures19.KEY).add(new ChunkPos(s.startChunkX(), s.startChunkZ()))); + transit.modules().forEach(m -> origins.get(TransitStructures21.KEY).add(new ChunkPos(m.startChunkX(), m.startChunkZ()))); + hall.networks().forEach(n -> origins.get(LostCityUndergroundStructures22.KEY).add(new ChunkPos(n.startChunkX(), n.startChunkZ()))); + var chunks = new TreeSet(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z)); + var nativeTags = new TreeMap(); + var context = StructurePieceSerializationContext.fromLevel(level); + for (var entry : origins.entrySet()) for (var origin : entry.getValue()) { + var type = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(entry.getKey()).value(); + var start = level.getChunk(origin.x(), origin.z(), ChunkStatus.STRUCTURE_STARTS, true).getStartForStructure(type); + helper.assertTrue(start != null && start.isValid(), "Accepted geometry must have a native start"); + for (var piece : start.getPieces()) { + helper.assertTrue(!(piece instanceof LostCityFoundationPiece16), "No artificial city foundation emitted"); + if (entry.getKey().equals(HeritageStructures19.KEY)) helper.assertTrue(piece instanceof HeritagePiece21, "Use published21 architecture"); + if (entry.getKey().equals(LostCityUndergroundStructures22.KEY)) helper.assertTrue(piece instanceof LostCityUndergroundPiece20, "Independent hall keeps published20 room geometry"); + if (entry.getKey().equals(TransitStructures21.KEY)) helper.assertTrue(piece instanceof TransitPiece21 || piece instanceof TransitPiece22, "Published21 tunnel geometry with sealed22 corners"); + piece.getBoundingBox().intersectingChunks().forEach(chunks::add); + } + var tag = start.createTag(context, origin); + var loaded = StructureStart.loadStaticStart(context, tag, level.getSeed()); + helper.assertTrue(loaded != null && tag.equals(loaded.createTag(context, origin)), "Native start round trip"); + nativeTags.put(entry.getKey().identifier() + "/" + origin.x() + "/" + origin.z(), tag.toString()); starts.add(start); + } + if (chunks.isEmpty()) chunks.add(new ChunkPos(0, 0)); + report.put("native_starts", nativeTags); report.put("full_chunks", chunks.size()); + Path world = level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize(); + Path proof = world.resolve("selective22-create.json"); + boolean reload = Boolean.getBoolean("sanctuary.test.expansionReload"); + JsonObject previous = reload ? JsonParser.parseString(Files.readString(proof)).getAsJsonObject() : null; + if (reload) { + helper.assertTrue(previous.get("passed").getAsBoolean() && previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Reload follows a successful separate JVM"); + helper.assertTrue(previous.get("generator_codec").equals(encoded) && previous.get("geometry").equals(geometry), "Generation, options and accepted/refused placements survive reload"); + helper.assertTrue(previous.get("native_starts").equals(gson.toJsonTree(nativeTags)), "Native saved starts survive reload"); + } + var iterator = chunks.iterator(); + Runnable finish = () -> { + try { + // Refresh already-generated transit chunks: the earlier short FULL tickets can expire + // while the catalogue loads. The walk checks real native collision shapes, including turns. + var transitChunks = new HashSet(); + transit.modules().forEach(m -> TransitPiece21.bounds(m).intersectingChunks().forEach(transitChunks::add)); + for (var pos : transitChunks) level.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true); + report.put("transit_native_walk", Transit21WorldGameTests.verifyAccess(helper, transit)); + report.put("hall_native_access", Underground22NativeChecks.verify(helper, hall.isEmpty() ? generator.undergroundPlan16(root) : hall)); + if (!heritage.sites().isEmpty()) { + var site = heritage.sites().getFirst(); + var witness = new BlockPos(site.centerX(), site.floorY() - 1, site.centerZ()); + if (reload) helper.assertTrue(level.getBlockState(witness).is(Blocks.GOLD_BLOCK), "Personal edit in disposable test survives reload"); + else { + helper.assertTrue(level.getBlockState(witness).isSolid(), "Accepted building foundation generated in real FULL chunks"); + level.setBlock(witness, Blocks.GOLD_BLOCK.defaultBlockState(), 3); + } + report.put("saved_edit", List.of(witness.getX(), witness.getY(), witness.getZ())); + } + helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush native world before shutdown"); + report.put("passed", true); + var directory = Path.of("diagnostics", "selective22-" + level.getSeed() + "-" + generator.initialDiameter()); Files.createDirectories(directory); + var text = gson.toJson(report) + "\n"; Files.writeString(directory.resolve(reload ? "reload.json" : "create.json"), text); + if (!reload) Files.writeString(proof, text); + helper.succeed(); + } catch (Exception failure) { throw new IllegalStateException(failure); } + }; + Runnable[] load = new Runnable[1]; + load[0] = () -> { + if (!iterator.hasNext()) { finish.run(); return; } + var pos = iterator.next(); level.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true); + helper.runAtTickTime(helper.getTick() + 1, () -> load[0].run()); + }; + load[0].run(); + } +} diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Transit21WorldGameTests.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Transit21WorldGameTests.java index 5cc892b..95d75f5 100644 --- a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Transit21WorldGameTests.java +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Transit21WorldGameTests.java @@ -339,7 +339,7 @@ public final class Transit21WorldGameTests { masks.put(room, quarters); return checked; } - private static Map verifyAccess(GameTestHelper helper, TransitPlan21 plan) { + static Map verifyAccess(GameTestHelper helper, TransitPlan21 plan) { var level = helper.getLevel(); var accesses = plan.modules().stream().filter(m -> m.kind() == TransitPlan21.Kind.ACCESS).toList(); helper.assertTrue(accesses.size() == 2, "Native transit requires exactly two surface gates"); diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Underground22NativeChecks.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Underground22NativeChecks.java new file mode 100644 index 0000000..28fd5d1 --- /dev/null +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Underground22NativeChecks.java @@ -0,0 +1,126 @@ +package fr.koka.sanctuary.gametest; + +import fr.koka.sanctuary.worldgen.city.LostCityUnderground16; +import fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Kind; +import fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Module; +import java.util.ArrayDeque; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.StairBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.chunk.status.ChunkStatus; + +/** Native circulation proof on the already generated piece footprint. */ +public final class Underground22NativeChecks { + private static final Direction[] HORIZONTAL = {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST}; + private Underground22NativeChecks() {} + public static Map verify(GameTestHelper helper, LostCityUnderground16.Plan plan) { + var level = helper.getLevel(); var chunks = new HashSet(); + int halls = 0, ladders = 0, apertures = 0, walked = 0; + helper.assertTrue(!plan.isEmpty(), "Independent underground hall plan must be present"); + for (var network : plan.networks()) { + for (var m : network.modules()) { + for (int cx = m.minX() >> 4; cx <= m.maxX() >> 4; cx++) for (int cz = m.minZ() >> 4; cz <= m.maxZ() >> 4; cz++) { + long key = ((long) cx << 32) ^ (cz & 0xffffffffL); + // Short-lived read tickets can expire during the caller’s asynchronous footprint load. + // Refresh only these already generated chunks before inspecting their blocks. + if (chunks.add(key)) helper.assertTrue(level.getChunk(cx, cz, ChunkStatus.FULL, true) instanceof LevelChunk, + "Underground22 piece has no loaded FULL chunk at " + cx + "," + cz); + } + for (int d = 0; d < 4; d++) if ((m.openings() & 1 << d) != 0) { + for (int side = -1; side <= 1; side++) for (int dy = 1; dy <= 3; dy++) { + var at = mouth(m, d, side, dy); + helper.assertTrue(passable(level.getBlockState(at)), "Native utility doorway blocked at " + at + " in " + m.kind()); + } + apertures++; + } + } + var access = network.modules().stream().filter(m -> m.kind() == Kind.ACCESS).findFirst().orElseThrow(); + BlockPos hatch = null; + for (int x = access.minX(); x <= access.maxX(); x++) for (int z = access.minZ(); z <= access.maxZ(); z++) { + var at = new BlockPos(x, access.maxY() - 1, z); + if (level.getBlockState(at).is(Blocks.DARK_OAK_TRAPDOOR)) { + helper.assertTrue(hatch == null, "The access has more than one surface hatch"); hatch = at; + } + } + helper.assertTrue(hatch != null, "Independent access has no real wooden surface hatch"); + final BlockPos entrance = hatch; + var closed = level.getBlockState(entrance); + for (int y = access.floorY() + 1; y < entrance.getY(); y++) { + var at = new BlockPos(entrance.getX(), y, entrance.getZ()); var ladder = level.getBlockState(at); + helper.assertTrue(ladder.is(Blocks.LADDER), "Access ladder is interrupted at " + at); + var wall = at.relative(ladder.getValue(BlockStateProperties.HORIZONTAL_FACING).getOpposite()); + helper.assertTrue(level.getBlockState(wall).isCollisionShapeFullBlock(level, wall), "Ladder has no real backing wall at " + at); + ladders++; + } + level.setBlock(entrance, closed.setValue(BlockStateProperties.OPEN, true), 2); + try { + Predicate inside = pos -> network.modules().stream().anyMatch(m -> m.contains(pos.getX(), pos.getY(), pos.getZ())); + BlockPos approach = java.util.Arrays.stream(HORIZONTAL).map(entrance::relative) + .filter(inside).filter(pos -> standable(level, pos)).findFirst().orElseThrow(); + var reached = walk(helper, approach, inside); walked += reached.size(); + helper.assertTrue(reached.contains(entrance), "Public deck cannot reach its hatch"); + for (var m : network.modules()) { + helper.assertTrue(reached.stream().anyMatch(pos -> pos.getY() == m.floorY() + 1 && pos.getX() > m.minX() + && pos.getX() < m.maxX() && pos.getZ() > m.minZ() && pos.getZ() < m.maxZ()), + "Surface access cannot reach emitted utility module " + m.kind() + " at " + m.centerX() + "," + m.centerZ()); + if (m.kind() != Kind.EXPANSION_HALL) continue; + halls++; + var plinth = new BlockPos(m.centerX(), m.minY() + 2, m.centerZ()); + helper.assertTrue(level.getBlockState(plinth).is(Blocks.SMOOTH_STONE) && reached.contains(plinth.above()), + "Reserved expansion plinth must be emitted and reachable"); + for (int dx : new int[]{-6, 6}) for (int dz : new int[]{-6, 6}) + helper.assertTrue(reached.contains(new BlockPos(m.centerX() + dx, m.floorY() + 1, m.centerZ() + dz)), + "Expansion hall has a blocked quarter"); + } + } finally { level.setBlock(entrance, closed, 2); } + } + helper.assertTrue(halls > 0, "Native networks have no usable expansion hall"); + var report = new LinkedHashMap(); report.put("full_chunks", chunks.size()); report.put("usable_halls", halls); + report.put("ladder_blocks", ladders); report.put("clear_openings", apertures); report.put("reached_positions", walked); + return Map.copyOf(report); + } + private static BlockPos mouth(Module m, int direction, int across, int dy) { + return new BlockPos(direction == 1 ? m.maxX() : direction == 3 ? m.minX() : m.centerX() + across, + m.floorY() + dy, direction == 0 ? m.minZ() : direction == 2 ? m.maxZ() : m.centerZ() + across); + } + /** Same finite walking/ladder rule used by the native city16/20/21 access proofs. */ + private static Set walk(GameTestHelper helper, BlockPos start, Predicate inside) { + var level = helper.getLevel(); var reached = new HashSet(); var queue = new ArrayDeque(); + helper.assertTrue(inside.test(start) && standable(level, start), "Blocked utility approach"); reached.add(start); queue.add(start); + while (!queue.isEmpty()) { + var pos = queue.remove(); + for (var direction : HORIZONTAL) for (int dy = -1; dy <= 1; dy++) { + var next = pos.relative(direction).offset(0, dy, 0); + if (!inside.test(next) || dy > 0 && !passable(level.getBlockState(pos.above(2)))) continue; + if (standable(level, next) && reached.add(next)) queue.add(next); + } + for (int dy : new int[]{-1, 1}) { + var next = pos.offset(0, dy, 0); + if (inside.test(next) && (level.getBlockState(pos).is(Blocks.LADDER) || level.getBlockState(next).is(Blocks.LADDER) + || level.getBlockState(pos.below()).is(Blocks.LADDER)) && standable(level, next) && reached.add(next)) queue.add(next); + } + helper.assertTrue(reached.size() < 100_000, "Utility traversal exceeded its bounded footprint"); + } + return reached; + } + private static boolean standable(ServerLevel level, BlockPos pos) { + var below = level.getBlockState(pos.below()); var at = level.getBlockState(pos); + return passable(at) && passable(level.getBlockState(pos.above())) + && (below.isSolid() || below.getBlock() instanceof StairBlock || at.is(Blocks.LADDER) || below.is(Blocks.LADDER)); + } + private static boolean passable(BlockState state) { + return state.isAir() || state.is(Blocks.LADDER) || state.is(Blocks.REDSTONE_WALL_TORCH) + || state.is(Blocks.DARK_OAK_TRAPDOOR) && state.getValue(BlockStateProperties.OPEN); + } +} 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 e656a72..0208de8 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 @@ -55,8 +55,12 @@ final class UnifiedWorldSettingsDiagnostics { var historical20 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v20"))); helper.assertTrue(historical20.value().createWorldDimensions().overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 20 && old.source().generation() == 20 && old.experimentalStructures(), "Historical20 keeps its original architecture"); - helper.assertTrue(generator.rootGeneration() == 21 && generator.source().generation() == 21 && generator.experimentalStructures(), "The public preset uses generation21 with its new-world option"); + var historical21 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v21"))); + helper.assertTrue(historical21.value().createWorldDimensions().overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 21 + && old.source().generation() == 21 && old.experimentalStructures(), "Historical21 keeps its original placement rules"); + helper.assertTrue(generator.rootGeneration() == 22 && generator.source().generation() == 22 && generator.experimentalStructures(), "The public preset uses generation22 with its new-world option"); helper.assertTrue(generator.initialDiameter() == 724, "The public preset defaults to Medium / 724 blocks"); + verifyConstructionMasks(helper, (SanctuaryChunkGenerator) historical21.value().createWorldDimensions().overworld(), generator); for (var tag : List.of(WorldPresetTags.NORMAL, WorldPresetTags.EXTENDED)) { var visible = presets.getOrThrow(tag).stream().map(holder -> holder.getRegisteredName()) .filter(name -> name.startsWith("sanctuary:")).collect(Collectors.toSet()); @@ -65,11 +69,11 @@ final class UnifiedWorldSettingsDiagnostics { } var ops = RegistryOps.create(JsonOps.INSTANCE, access); - var activeCodec = generator.rootGeneration() == 21 ? SanctuaryChunkGenerator.NETWORK21_CODEC : generator.rootGeneration() == 20 ? SanctuaryChunkGenerator.ARCHITECTURE20_CODEC : generator.rootGeneration() == 19 ? SanctuaryChunkGenerator.HERITAGE19_CODEC : generator.rootGeneration() == 17 ? SanctuaryChunkGenerator.RUINS17_CODEC : generator.rootGeneration() == 16 ? SanctuaryChunkGenerator.CITY16_CODEC : generator.rootGeneration() == 15 ? SanctuaryChunkGenerator.CITY15_CODEC : generator.rootGeneration() == 14 ? SanctuaryChunkGenerator.CITY_CODEC : SanctuaryChunkGenerator.CODEC; + var activeCodec = generator.rootGeneration() == 22 ? SanctuaryChunkGenerator.SELECTIVE22_CODEC : generator.rootGeneration() == 21 ? SanctuaryChunkGenerator.NETWORK21_CODEC : generator.rootGeneration() == 20 ? SanctuaryChunkGenerator.ARCHITECTURE20_CODEC : generator.rootGeneration() == 19 ? SanctuaryChunkGenerator.HERITAGE19_CODEC : generator.rootGeneration() == 17 ? SanctuaryChunkGenerator.RUINS17_CODEC : generator.rootGeneration() == 16 ? SanctuaryChunkGenerator.CITY16_CODEC : generator.rootGeneration() == 15 ? SanctuaryChunkGenerator.CITY15_CODEC : generator.rootGeneration() == 14 ? SanctuaryChunkGenerator.CITY_CODEC : SanctuaryChunkGenerator.CODEC; var serialized = activeCodec.codec().encodeStart(ops, generator).getOrThrow().getAsJsonObject(); serialized.remove("experimental_structures"); helper.assertTrue(activeCodec.codec().parse(ops, serialized).getOrThrow().experimentalStructures(), - "Generation21 omitted experimental option defaults to enabled"); + "Generation22 omitted experimental option defaults to enabled"); helper.assertTrue(!((SanctuaryChunkGenerator) historical16.overworld()).withExperimentalStructures(true).experimentalStructures(), "The new-world switch cannot activate experimental generation in a historical16 generator"); serialized.remove("initial_diameter"); @@ -112,4 +116,37 @@ final class UnifiedWorldSettingsDiagnostics { "public_presets", List.of("sanctuary:sanctuary"), "sizes", results, "invalid_diameters_rejected", true, "copied_biome_source", true, "passed", true)) + "\n"); } + + private static void verifyConstructionMasks(GameTestHelper helper, SanctuaryChunkGenerator historical21, + SanctuaryChunkGenerator generation22) { + var outside = new net.minecraft.core.BlockPos(200_000, 192, 200_000); + // Decoding a generation must not reserve the world before any structures are planned. + // These checks also cover the old OFF contract without initializing another island. + for (var generator : List.of(historical21, historical21.withExperimentalStructures(false), + generation22, generation22.withExperimentalStructures(false))) { + helper.assertTrue(!generator.protectsCityDecoration(outside), + "Unplanned generation" + generator.rootGeneration() + " does not protect arbitrary blocks"); + helper.assertTrue(!generator.intersectsExperimentalStructures(outside.getX(), outside.getY(), outside.getZ(), + outside.getX() + 1, outside.getY() + 1, outside.getZ() + 1), + "Unplanned generation" + generator.rootGeneration() + " does not reject arbitrary structure bounds"); + } + // Reuse the attached world's real plans: no extra native-noise planning or chunk loads. + if (!(helper.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator active) + || active.rootGeneration() != 21 && active.rootGeneration() != 22) return; + helper.assertTrue(!active.protectsCityDecoration(outside) + && !active.intersectsExperimentalStructures(outside.getX(), outside.getY(), outside.getZ(), + outside.getX() + 1, outside.getY() + 1, outside.getZ() + 1), + "Prepared construction masks remain local to their real structures"); + var interior = new ArrayList(); + for (var module : active.transitPlan21().modules()) + interior.add(new net.minecraft.core.BlockPos(module.centerX(), module.floorY(), module.centerZ())); + for (var network : active.undergroundHall22().networks()) for (var module : network.modules()) + interior.add(new net.minecraft.core.BlockPos(module.centerX(), module.floorY(), module.centerZ())); + if (active.rootGeneration() == 22 && helper.getLevel().structureManager().shouldGenerateStructures()) + helper.assertTrue(!interior.isEmpty(), "The attached generation22 world has core construction to protect"); + for (var position : interior) helper.assertTrue(active.protectsCityDecoration(position) + && active.intersectsExperimentalStructures(position.getX(), position.getY(), position.getZ(), + position.getX(), position.getY(), position.getZ()), + "Real underground construction remains protected from decoration and overlapping native structures"); + } } 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 45c1bdd..37294aa 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,7 @@ 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.heritage20") || Boolean.getBoolean("sanctuary.test.heritage21") || Boolean.getBoolean("sanctuary.test.lostCity21") || Boolean.getBoolean("sanctuary.test.transit21")) || Boolean.getBoolean("sanctuary.test.lostCity20") + if (Boolean.getBoolean("sanctuary.test.selective22") || (Boolean.getBoolean("sanctuary.test.heritage20") || Boolean.getBoolean("sanctuary.test.heritage21") || Boolean.getBoolean("sanctuary.test.lostCity21") || Boolean.getBoolean("sanctuary.test.transit21")) || Boolean.getBoolean("sanctuary.test.lostCity20") || Boolean.getBoolean("sanctuary.test.heritage19") || Boolean.getBoolean("sanctuary.test.ruins17") || Boolean.getBoolean("sanctuary.test.expansion") || Boolean.getBoolean("sanctuary.test.lostCity") || Boolean.getBoolean("sanctuary.test.lostCity16") || Boolean.getBoolean("sanctuary.test.lostCity15") || Boolean.getBoolean("sanctuary.test.voidReuse")) { long until = System.nanoTime() + 20_000_000L; @@ -47,9 +47,9 @@ public abstract class SanctuaryGameTestServerMixin { "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")) { - int generation = Integer.getInteger("sanctuary.test.generation", 21); - if (generation != 13 && generation != 14 && generation != 15 && generation != 16 && generation != 17 && generation != 19 && generation != 20 && generation != 21) throw new IllegalArgumentException("Unsupported test generation"); - return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id(generation == 21 ? "sanctuary" : "sanctuary_v" + generation)); + int generation = Integer.getInteger("sanctuary.test.generation", 22); + if (generation != 13 && generation != 14 && generation != 15 && generation != 16 && generation != 17 && generation != 19 && generation != 20 && generation != 21 && generation != 22) throw new IllegalArgumentException("Unsupported test generation"); + return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id(generation == 22 ? "sanctuary" : "sanctuary_v" + generation)); } if (Boolean.getBoolean("sanctuary.test.expansion")) return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("expansion_lab")); int players = Integer.getInteger("sanctuary.test.players", 10); 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 06630df..3e5b213 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/SanctuaryMod.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/SanctuaryMod.java @@ -67,7 +67,10 @@ public final class SanctuaryMod implements ModInitializer { Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v20"), SanctuaryBiomeSource.ARCHITECTURE20_CODEC); Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v21"), SanctuaryChunkGenerator.NETWORK21_CODEC); Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v21"), SanctuaryBiomeSource.NETWORK21_CODEC); + Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v22"), SanctuaryChunkGenerator.SELECTIVE22_CODEC); + Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v22"), SanctuaryBiomeSource.SELECTIVE22_CODEC); fr.koka.sanctuary.worldgen.transit.TransitStructures21.register(); + fr.koka.sanctuary.worldgen.city.LostCityUndergroundStructures22.register(); fr.koka.sanctuary.worldgen.heritage.HeritageStructures19.register(); fr.koka.sanctuary.worldgen.ruins.RuinsStructures17.register(); Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("population_island"), PopulationIslandDensity.CODEC); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/SanctuaryWorldOptionsScreen.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/SanctuaryWorldOptionsScreen.java index 5e50c0f..1b8b2ab 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/SanctuaryWorldOptionsScreen.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/SanctuaryWorldOptionsScreen.java @@ -51,11 +51,11 @@ public final class SanctuaryWorldOptionsScreen extends Screen { contents.addChild(sizeButton); diameterHint = contents.addChild(new MultiLineTextWidget(diameterHint(), font) .setMaxWidth(310).setCentered(true)); - if (generator.rootGeneration() == 17 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21) { + if (generator.rootGeneration() == 17 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22) { contents.addChild(CycleButton.builder((Boolean enabled) -> enabled ? CommonComponents.OPTION_ON : CommonComponents.OPTION_OFF, experimental) .withValues(true, false).create(0, 0, 280, 20, Component.translatable("sanctuary.world_options.experimental"), (button, value) -> experimental = value)); - contents.addChild(new MultiLineTextWidget(Component.translatable((generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21) ? "sanctuary.world_options.heritage_hint" : "sanctuary.world_options.experimental_hint"), font) + contents.addChild(new MultiLineTextWidget(Component.translatable(generator.rootGeneration() == 22 ? "sanctuary.world_options.heritage22_hint" : (generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22) ? "sanctuary.world_options.heritage_hint" : "sanctuary.world_options.experimental_hint"), font) .setMaxWidth(310).setCentered(true)); } layout.addToContents(contents); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionChunkGenerator.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionChunkGenerator.java index f0fa01c..6da2bd8 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionChunkGenerator.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionChunkGenerator.java @@ -124,6 +124,7 @@ public class ExpansionChunkGenerator extends ChunkGenerator { fr.koka.sanctuary.worldgen.ruins.RuinsStructures17.generate(this, registries, state, structures, chunk, templates, dimension); fr.koka.sanctuary.worldgen.heritage.HeritageStructures19.generate(this, registries, state, structures, chunk, templates, dimension); fr.koka.sanctuary.worldgen.transit.TransitStructures21.generate(this, registries, state, structures, chunk, templates, dimension); + fr.koka.sanctuary.worldgen.city.LostCityUndergroundStructures22.generate(this, registries, state, structures, chunk, templates, dimension); ExpansionStructures.generate(this, registries, state, structures, chunk, templates, dimension); } @Override public void spawnOriginalMobs(WorldGenRegion region) { 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 7ed628b..d83b40e 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 @@ -41,7 +41,7 @@ public record ExpansionIsland(String id, String parent, String direction, String if (!((spatialGeneration(generation) ? DIAMETERS : LEGACY_DIAMETERS).contains(diameter) || (id.equals("sanctuary") && (diameter == 724 || (generation == 13 || spatialGeneration(generation)) && diameter == 1024)))) throw new IllegalArgumentException("Unsupported nominal diameter"); if (Math.abs((long) centerX) > 100_000 || Math.abs((long) centerZ) > 100_000 || (centerX & 15) != 0 || (centerZ & 15) != 0) throw new IllegalArgumentException("Invalid island centre"); if (!List.of("reserved", "ready").contains(status) || (generation != GENERATION && !spatialGeneration(generation) && !(generation == 13 && id.equals("sanctuary")))) throw new IllegalArgumentException("Unsupported island state or generation"); - if (climate.equals("lost_city") && (!spatialGeneration(generation) || id.equals("sanctuary"))) throw new IllegalArgumentException("Lost City requires a generation 14, 15, 16, 17, 19, 20 or 21 expansion"); + if (climate.equals("lost_city") && (!spatialGeneration(generation) || id.equals("sanctuary"))) throw new IllegalArgumentException("Lost City requires a generation 14, 15, 16, 17, 19, 20, 21 or 22 expansion"); if (reuseToken == null || !reuseToken.isEmpty() && (!spatialGeneration(generation) || id.equals("sanctuary") || !reuseToken.matches("[0-9a-f]{64}"))) throw new IllegalArgumentException("Invalid void reuse certificate token"); } @@ -53,7 +53,7 @@ public record ExpansionIsland(String id, String parent, String direction, String return new ExpansionIsland("sanctuary", "sanctuary", "north", "temperate", diameter, 0, 0, seed, "natural", "ready", generation); } - public static boolean spatialGeneration(int generation) { return generation == 14 || generation == 15 || generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21; } + public static boolean spatialGeneration(int generation) { return generation == 14 || generation == 15 || generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22; } public boolean origin() { return id.equals("sanctuary"); } public double referenceDiameter() { return origin() && diameter == 724 ? Math.sqrt(2) * 512 : 512; } public double horizontalScale() { return origin() ? 1.0 : diameter / referenceDiameter(); } 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 ee06640..628f30f 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 @@ -42,7 +42,7 @@ public abstract class UnifiedDecorationProtectionMixin { if (state.is(Blocks.SUGAR_CANE)) return; // Generation16 reserves its architectural volume before decoration. Only its own // native pieces may replace water there; published generations retain the old rule. - if ((generator.rootGeneration() == 16 || generator.rootGeneration() == 17 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21) + if ((generator.rootGeneration() == 16 || generator.rootGeneration() == 17 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22) && fr.koka.sanctuary.worldgen.city.LostCityStructures.isPlacing() && generator.protectsCityDecoration(pos)) return; if (generator.protectsDecoration(pos) && !region.getBlockState(pos).equals(state)) { 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 fa1d027..dc6d64e 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 @@ -54,6 +54,10 @@ public final class SanctuaryBiomeSource extends ExpansionBiomeSource { Biome.CODEC.listOf().fieldOf("biomes").forGetter(source -> source.allBiomes) ).apply(i, biomes -> new SanctuaryBiomeSource(biomes, 21))); + public static final MapCodec SELECTIVE22_CODEC = RecordCodecBuilder.mapCodec(i -> i.group( + Biome.CODEC.listOf().fieldOf("biomes").forGetter(source -> source.allBiomes) + ).apply(i, biomes -> new SanctuaryBiomeSource(biomes, 22))); + private final List> allBiomes; private final PopulationIslandBiomeSource population; private final boolean cities; @@ -87,7 +91,7 @@ public final class SanctuaryBiomeSource extends ExpansionBiomeSource { cityPlans = java.util.Map.copyOf(plans); } - @Override protected MapCodec codec() { return generation == 21 ? NETWORK21_CODEC : generation == 20 ? ARCHITECTURE20_CODEC : generation == 19 ? HERITAGE19_CODEC : generation == 17 ? RUINS17_CODEC : generation == 16 ? CITY16_CODEC : generation == 15 ? CITY15_CODEC : cities ? CITY_CODEC : CODEC; } + @Override protected MapCodec codec() { return generation == 22 ? SELECTIVE22_CODEC : generation == 21 ? NETWORK21_CODEC : generation == 20 ? ARCHITECTURE20_CODEC : generation == 19 ? HERITAGE19_CODEC : generation == 17 ? RUINS17_CODEC : generation == 16 ? CITY16_CODEC : generation == 15 ? CITY15_CODEC : cities ? CITY_CODEC : CODEC; } @Override public BiomeResolver createResolver(Climate.Sampler sampler) { BiomeResolver woodlands = population.createResolver(sampler); 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 bc264d2..54066c5 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 @@ -63,6 +63,12 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { Codec.BOOL.optionalFieldOf("experimental_structures", true).forGetter(SanctuaryChunkGenerator::experimentalStructures) ).apply(i, (source, diameter, experimental) -> new SanctuaryChunkGenerator(source, diameter, 21, experimental))); + public static final MapCodec SELECTIVE22_CODEC = RecordCodecBuilder.mapCodec(i -> i.group( + SanctuaryBiomeSource.SELECTIVE22_CODEC.fieldOf("biome_source").forGetter(SanctuaryChunkGenerator::source), + DIAMETER_CODEC.optionalFieldOf("initial_diameter", 724).forGetter(SanctuaryChunkGenerator::initialDiameter), + Codec.BOOL.optionalFieldOf("experimental_structures", true).forGetter(SanctuaryChunkGenerator::experimentalStructures) + ).apply(i, (source, diameter, experimental) -> new SanctuaryChunkGenerator(source, diameter, 22, experimental))); + private final boolean experimentalStructures; private volatile fr.koka.sanctuary.worldgen.ruins.RuinsPlan17 ruinsPlan17 = fr.koka.sanctuary.worldgen.ruins.RuinsPlan17.EMPTY; private boolean ruinsPrepared; @@ -71,6 +77,10 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { private final java.util.Map cityPlacements21 = new java.util.concurrent.ConcurrentHashMap<>(); private volatile fr.koka.sanctuary.worldgen.transit.TransitPlan21 transitPlan21 = fr.koka.sanctuary.worldgen.transit.TransitPlan21.EMPTY; private boolean transitPrepared; + private volatile fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan undergroundHall22 = new fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan(List.of(), 0); + private volatile fr.koka.sanctuary.worldgen.city.LostCityProtection16 undergroundHallProtection22 = fr.koka.sanctuary.worldgen.city.LostCityProtection16.EMPTY; + private boolean undergroundHallPrepared22; + public fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan undergroundHall22() { return undergroundHall22; } public fr.koka.sanctuary.worldgen.transit.TransitPlan21 transitPlan21() { return transitPlan21; } public fr.koka.sanctuary.worldgen.city.LostCityPlanner21.Result cityPlacement21(ExpansionIsland island) { lostCityPlan(island); @@ -111,12 +121,12 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { } private SanctuaryChunkGenerator(SanctuaryBiomeSource source, int initialDiameter, int generation, boolean experimental) { super(source, initialDiameter, true); - this.experimentalStructures = (generation == 17 || generation == 19 || generation == 20 || generation == 21) && experimental; + this.experimentalStructures = (generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22) && experimental; this.generation = generation; if (generation != source.generation()) throw new IllegalArgumentException("Sanctuary generator and biome source versions must agree"); } @Override public SanctuaryBiomeSource source() { return (SanctuaryBiomeSource) super.source(); } - @Override protected MapCodec codec() { return generation == 21 ? NETWORK21_CODEC : generation == 20 ? ARCHITECTURE20_CODEC : generation == 19 ? HERITAGE19_CODEC : generation == 17 ? RUINS17_CODEC : generation == 16 ? CITY16_CODEC : generation == 15 ? CITY15_CODEC : generation == 14 ? CITY_CODEC : CODEC; } + @Override protected MapCodec codec() { return generation == 22 ? SELECTIVE22_CODEC : generation == 21 ? NETWORK21_CODEC : generation == 20 ? ARCHITECTURE20_CODEC : generation == 19 ? HERITAGE19_CODEC : generation == 17 ? RUINS17_CODEC : generation == 16 ? CITY16_CODEC : generation == 15 ? CITY15_CODEC : generation == 14 ? CITY_CODEC : CODEC; } public SanctuaryChunkGenerator withInitialDiameter(int diameter) { return new SanctuaryChunkGenerator(source().copy(), diameter, generation, experimentalStructures); } @@ -127,9 +137,10 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { public fr.koka.sanctuary.worldgen.ruins.RuinsPlan17 ruinsPlan17() { return ruinsPlan17; } public fr.koka.sanctuary.worldgen.heritage.HeritagePlan19 heritagePlan19() { return heritagePlan19; } public boolean intersectsExperimentalStructures(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { - return (generation == 17 || generation == 19 || generation == 20 || generation == 21) && ruinsPlan17.intersectsProtection(minX, minY, minZ, maxX, maxY, maxZ) - || (generation == 19 || generation == 20 || generation == 21) && heritagePlan19.intersectsProtection(minX, minY, minZ, maxX, maxY, maxZ) - || generation == 21 && transitPlan21.intersectsProtection(minX, minY, minZ, maxX, maxY, maxZ); + return (generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22) && ruinsPlan17.intersectsProtection(minX, minY, minZ, maxX, maxY, maxZ) + || (generation == 19 || generation == 20 || generation == 21 || generation == 22) && heritagePlan19.intersectsProtection(minX, minY, minZ, maxX, maxY, maxZ) + || (generation == 21 || generation == 22) && transitPlan21.intersectsProtection(minX, minY, minZ, maxX, maxY, maxZ) + || generation == 22 && undergroundHallProtection22.intersects(minX, minY, minZ, maxX, maxY, maxZ); } public IslandCapacity capacity() { return switch (initialDiameter()) { @@ -164,6 +175,7 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { // Publish one root context before any generation workers use it. context(islands.getFirst()); prepareCities(islands); + if (generation == 22) { prepareTransit(islands); prepareUndergroundHall22(islands); } prepareRuins(islands); prepareHeritage(islands); prepareTransit(islands); @@ -171,6 +183,7 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { @Override public void setIslands(List islands) { // Complete and publish city geometry before workers can observe the new owner list. prepareCities(islands); + if (generation == 22) { prepareTransit(islands); prepareUndergroundHall22(islands); } prepareRuins(islands); prepareHeritage(islands); prepareTransit(islands); @@ -184,7 +197,7 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { source().setCityPlans(java.util.Map.of()); return; } - if ((generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21) && !cityStructuresEnabled) { + if ((generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22) && !cityStructuresEnabled) { cityProtection16 = fr.koka.sanctuary.worldgen.city.LostCityProtection16.EMPTY; activeCityPlans = java.util.Map.of(); source().setCityPlans(java.util.Map.of()); @@ -200,7 +213,7 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { } cityProtection15 = protection.build(); } - if (generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21) { + if (generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22) { var protection = new fr.koka.sanctuary.worldgen.city.LostCityProtection16.Builder(); for (var island : islands) { protection.add(published.get(island.id()), undergroundPlan16(island)); @@ -215,11 +228,11 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { source().setCityPlans(published); } private void prepareRuins(List islands) { - if ((generation != 17 && generation != 19 && generation != 20 && generation != 21) || !experimentalStructures || !cityStructuresEnabled || ruinsPrepared) return; + if ((generation != 17 && generation != 19 && generation != 20 && generation != 21 && generation != 22) || !experimentalStructures || !cityStructuresEnabled || ruinsPrepared) return; var root = islands.stream().filter(ExpansionIsland::origin).findFirst().orElseThrow(); var terrain = context(root); var sampled = new fr.koka.sanctuary.worldgen.ruins.RuinsTerrain17(root, terrain.generator(), terrain.random(), - heightAccessor, pos -> protectsDecoration(pos) || cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ())); + heightAccessor, pos -> protectsCore22(pos) || protectsDecoration(pos) || cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ())); long start = System.nanoTime(); var plan = fr.koka.sanctuary.worldgen.ruins.RuinsPlanner17.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled); ruinsPlan17 = plan; @@ -228,25 +241,27 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { root.seed(), plan.networks().size(), plan.samples(), sampled.columnsUsed(), (System.nanoTime() - start) / 1_000_000); } private void prepareHeritage(List islands) { - if ((generation != 19 && generation != 20 && generation != 21) || !experimentalStructures || !cityStructuresEnabled || heritagePrepared) return; + if ((generation != 19 && generation != 20 && generation != 21 && generation != 22) || !experimentalStructures || !cityStructuresEnabled || heritagePrepared) return; var root = islands.stream().filter(ExpansionIsland::origin).findFirst().orElseThrow(); var terrain = context(root); - if (generation == 21) { + if (generation == 21 || generation == 22) { var sampled = new fr.koka.sanctuary.worldgen.heritage.HeritageTerrain21(root, terrain.generator(), terrain.random(), - heightAccessor, pos -> protectsDecoration(pos) || cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ()) + heightAccessor, pos -> protectsCore22(pos) || protectsDecoration(pos) || cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ()) || ruinsPlan17.protects(pos.getX(), pos.getY(), pos.getZ())); long start = System.nanoTime(); sampled.discoverLandmarks(lostCityPlan(root), ruinsPlan17); - var plan = fr.koka.sanctuary.worldgen.heritage.HeritagePlanner21.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled); + var plan = generation == 22 + ? fr.koka.sanctuary.worldgen.heritage.HeritagePlanner22.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled) + : fr.koka.sanctuary.worldgen.heritage.HeritagePlanner21.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled); heritagePlan19 = plan; heritagePrepared = true; - SanctuaryMod.LOGGER.info("Heritage21 seed {}: {} sites, {} routes, {} segments, {} probes, {} native columns in {} ms; {}", + SanctuaryMod.LOGGER.info("Heritage" + generation + " seed {}: {} sites, {} routes, {} segments, {} probes, {} native columns in {} ms; {}", root.seed(), plan.sites().size(), plan.links().size(), plan.segments().size(), plan.samples(), sampled.columnsUsed(), (System.nanoTime() - start) / 1_000_000, plan.diagnostics()); return; } var sampled = new fr.koka.sanctuary.worldgen.heritage.HeritageTerrain19(root, terrain.generator(), terrain.random(), - heightAccessor, pos -> protectsDecoration(pos) || cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ()) + heightAccessor, pos -> protectsCore22(pos) || protectsDecoration(pos) || cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ()) || ruinsPlan17.protects(pos.getX(), pos.getY(), pos.getZ())); long start = System.nanoTime(); sampled.discoverLandmarks(lostCityPlan(root), ruinsPlan17); @@ -258,17 +273,55 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { (System.nanoTime() - start) / 1_000_000, plan.diagnostics()); } private void prepareTransit(List islands) { - if (generation != 21 || !experimentalStructures || !cityStructuresEnabled || transitPrepared) return; + if ((generation != 21 && generation != 22) || generation == 21 && !experimentalStructures || !cityStructuresEnabled || transitPrepared) return; var root = islands.stream().filter(ExpansionIsland::origin).findFirst().orElseThrow(); var terrain = context(root); var sampled = new fr.koka.sanctuary.worldgen.transit.TransitTerrain21(root, terrain.generator(), terrain.random(), - heightAccessor, pos -> protectsDecoration(pos) || cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ()) + heightAccessor, pos -> protectsCore22(pos) || protectsDecoration(pos) || cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ()) || ruinsPlan17.protects(pos.getX(), pos.getY(), pos.getZ()) || heritagePlan19.protects(pos.getX(), pos.getY(), pos.getZ())); long start = System.nanoTime(); - transitPlan21 = fr.koka.sanctuary.worldgen.transit.TransitPlanner21.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled); + transitPlan21 = generation == 22 + ? fr.koka.sanctuary.worldgen.transit.TransitPlanner22.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled) + : fr.koka.sanctuary.worldgen.transit.TransitPlanner21.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled); + int directColumns = 0; + if (generation == 22 && transitPlan21.isEmpty()) { + directColumns = sampled.columnsUsed(); + sampled = new fr.koka.sanctuary.worldgen.transit.TransitTerrain21(root, terrain.generator(), terrain.random(), + heightAccessor, pos -> protectsDecoration(pos) || cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ())); + transitPlan21 = fr.koka.sanctuary.worldgen.transit.TransitRoute22.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled); + } transitPrepared = true; - SanctuaryMod.LOGGER.info("Transit21 seed {}: {} modules, {} probes, {} columns in {} ms; {}", root.seed(), - transitPlan21.modules().size(), transitPlan21.samples(), sampled.columnsUsed(), (System.nanoTime() - start) / 1_000_000, transitPlan21.diagnostic()); + SanctuaryMod.LOGGER.info("Transit" + generation + " seed {}: {} modules, {} probes, {} columns in {} ms; {}", root.seed(), + transitPlan21.modules().size(), transitPlan21.samples(), directColumns + sampled.columnsUsed(), (System.nanoTime() - start) / 1_000_000, transitPlan21.diagnostic()); + } + private boolean protectsCore22(net.minecraft.core.BlockPos pos) { + return generation == 22 && (transitPlan21.protects(pos.getX(), pos.getY(), pos.getZ()) + || undergroundHallProtection22.contains(pos.getX(), pos.getY(), pos.getZ())); + } + private void prepareUndergroundHall22(List islands) { + if (generation != 22 || !cityStructuresEnabled || undergroundHallPrepared22) return; + var root = islands.stream().filter(ExpansionIsland::origin).findFirst().orElseThrow(); + if (undergroundPlan16(root).networks().stream().flatMap(n -> n.modules().stream()) + .anyMatch(m -> m.kind() == fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Kind.EXPANSION_HALL)) { + undergroundHallPrepared22 = true; return; + } + var terrain = context(root); + var sampled = new fr.koka.sanctuary.worldgen.city.LostCityTerrain21(root, terrain.generator(), terrain.random(), heightAccessor, + pos -> protectsDecoration(pos) || cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ()) || protectsCore22(pos)); + long start = System.nanoTime(); + undergroundHall22 = fr.koka.sanctuary.worldgen.city.LostCityUnderground22.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), + new fr.koka.sanctuary.worldgen.city.LostCityUnderground22.Terrain() { + @Override public int surfaceY(int x, int z) { var ground = sampled.ground(x, z); return ground.dry() ? ground.top() : -1; } + @Override public boolean owns(int x, int z) { return sampled.owns(x, z); } + @Override public boolean solid(int x, int y, int z) { return sampled.solid(x, y, z); } + @Override public boolean protectedAt(int x, int y, int z) { return sampled.protectedAt(x, y, z); } + @Override public boolean exteriorAir(int x, int y, int z, int dx, int dz) { return sampled.exteriorAir(x, y, z, dx, dz); } + }); + var protection = new fr.koka.sanctuary.worldgen.city.LostCityProtection16.Builder(); + protection.add(fr.koka.sanctuary.worldgen.city.LostCityPlan.EMPTY, undergroundHall22); + undergroundHallProtection22 = protection.build(); undergroundHallPrepared22 = true; + SanctuaryMod.LOGGER.info("Independent hall22 seed {}: {} networks, {} probes, {} columns in {} ms", root.seed(), + undergroundHall22.networks().size(), undergroundHall22.samples(), sampled.columnsUsed(), (System.nanoTime() - start) / 1_000_000); } private static ExpansionIsland cityIdentity(ExpansionIsland island) { // State and receipt do not change the terrain geometry used by a cached plan. @@ -296,11 +349,12 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { return cityPlans.computeIfAbsent(cityIdentity(island), ignored -> { long start = System.nanoTime(); var terrain = context(island); - if (generation == 21) { + if (generation == 21 || generation == 22) { var sampled = new fr.koka.sanctuary.worldgen.city.LostCityTerrain21(island, terrain.generator(), terrain.random(), heightAccessor, pos -> island.origin() && protectsDecoration(pos)); - var placement = fr.koka.sanctuary.worldgen.city.LostCityPlanner21.plan(island.seed(), island.centerX(), island.centerZ(), - island.diameter(), island.origin(), sampled); + var placement = generation == 22 + ? fr.koka.sanctuary.worldgen.city.LostCityPlanner22.plan(island.seed(), island.centerX(), island.centerZ(), island.diameter(), island.origin(), sampled) + : fr.koka.sanctuary.worldgen.city.LostCityPlanner21.plan(island.seed(), island.centerX(), island.centerZ(), island.diameter(), island.origin(), sampled); var surface = placement.surface(); cityPlacements21.put(cityIdentity(island), placement); citySupports16.put(cityIdentity(island), placement.supports()); @@ -323,10 +377,10 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { } }); undergroundPlans16.put(cityIdentity(island), underground); - SanctuaryMod.LOGGER.info("Lost City21 {} seed {}: {} city districts, {} underground networks, {} noise columns in {} ms", + SanctuaryMod.LOGGER.info("Lost City" + generation + " {} seed {}: {} city districts, {} underground networks, {} noise columns in {} ms", island.id(), island.seed(), surface.districts().size(), underground.networks().size(), sampled.columnsUsed(), (System.nanoTime() - start) / 1_000_000); - SanctuaryMod.LOGGER.info("City21 extensions: {}", placement.diagnostics()); + SanctuaryMod.LOGGER.info("City" + generation + " extensions: {}", placement.diagnostics()); return surface; } if (generation == 16 || generation == 17 || generation == 19 || generation == 20) { @@ -400,10 +454,11 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { }); } public boolean protectsCityDecoration(net.minecraft.core.BlockPos pos) { - if (generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21) return cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ()) - || (generation == 17 || generation == 19 || generation == 20 || generation == 21) && ruinsPlan17.protects(pos.getX(), pos.getY(), pos.getZ()) - || (generation == 19 || generation == 20 || generation == 21) && heritagePlan19.protects(pos.getX(), pos.getY(), pos.getZ()) - || generation == 21 && transitPlan21.protects(pos.getX(), pos.getY(), pos.getZ()); + if (generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22) return cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ()) + || (generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22) && ruinsPlan17.protects(pos.getX(), pos.getY(), pos.getZ()) + || (generation == 19 || generation == 20 || generation == 21 || generation == 22) && heritagePlan19.protects(pos.getX(), pos.getY(), pos.getZ()) + || (generation == 21 || generation == 22) && transitPlan21.protects(pos.getX(), pos.getY(), pos.getZ()) + || generation == 22 && undergroundHallProtection22.contains(pos.getX(), pos.getY(), pos.getZ()); if (generation == 15) return cityProtection15.contains(pos.getX(), pos.getY(), pos.getZ()); if (generation != 14) return false; var island = islandAt(pos.getX(), pos.getZ()); @@ -425,20 +480,20 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { return citySupports.getOrDefault(cityIdentity(island), List.of()); } public boolean intersectsCityConstruction16(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { - return (generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21) && cityProtection16.intersects(minX, minY, minZ, maxX, maxY, maxZ); + return (generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22) && cityProtection16.intersects(minX, minY, minZ, maxX, maxY, maxZ); } public fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan undergroundPlan16(ExpansionIsland island) { - if (generation != 16 && generation != 17 && generation != 19 && generation != 20 && generation != 21) return new fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan(List.of(), 0); + if (generation != 16 && generation != 17 && generation != 19 && generation != 20 && generation != 21 && generation != 22) return new fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan(List.of(), 0); lostCityPlan(island); return undergroundPlans16.getOrDefault(cityIdentity(island), new fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan(List.of(), 0)); } public List citySupports16(ExpansionIsland island) { - if (generation != 16 && generation != 17 && generation != 19 && generation != 20 && generation != 21) return List.of(); + if (generation != 16 && generation != 17 && generation != 19 && generation != 20 && generation != 21 && generation != 22) return List.of(); lostCityPlan(island); return citySupports16.getOrDefault(cityIdentity(island), List.of()); } public LostCityPlanner16.Fallback cityReservation16(ExpansionIsland island) { - if (generation != 16 && generation != 17 && generation != 19 && generation != 20 && generation != 21) return null; + if (generation != 16 && generation != 17 && generation != 19 && generation != 20 && generation != 21 && generation != 22) return null; lostCityPlan(island); return cityReservations16.get(cityIdentity(island)); } @@ -448,13 +503,15 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { @Override public com.mojang.datafixers.util.Pair> findNearestMapStructure( ServerLevel level, net.minecraft.core.HolderSet structures, net.minecraft.core.BlockPos origin, int radius, boolean skipReferenced) { + if (generation == 22 && structures.size() == 1 && structures.get(0).is(fr.koka.sanctuary.worldgen.city.LostCityUndergroundStructures22.KEY)) + return fr.koka.sanctuary.worldgen.city.LostCityUndergroundStructures22.findNearest(level, structures, origin, radius, skipReferenced); if (structures.size() == 1 && structures.get(0).is(fr.koka.sanctuary.worldgen.city.LostCityStructures.KEY)) return fr.koka.sanctuary.worldgen.city.LostCityStructures.findNearest(level, structures, origin, radius, skipReferenced); - if ((generation == 17 || generation == 19 || generation == 20 || generation == 21) && structures.size() == 1 && structures.get(0).is(fr.koka.sanctuary.worldgen.ruins.RuinsStructures17.KEY)) + if ((generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22) && structures.size() == 1 && structures.get(0).is(fr.koka.sanctuary.worldgen.ruins.RuinsStructures17.KEY)) return fr.koka.sanctuary.worldgen.ruins.RuinsStructures17.findNearest(level, structures, origin, radius, skipReferenced); - if ((generation == 19 || generation == 20 || generation == 21) && structures.size() == 1 && structures.get(0).is(fr.koka.sanctuary.worldgen.heritage.HeritageStructures19.KEY)) + if ((generation == 19 || generation == 20 || generation == 21 || generation == 22) && structures.size() == 1 && structures.get(0).is(fr.koka.sanctuary.worldgen.heritage.HeritageStructures19.KEY)) return fr.koka.sanctuary.worldgen.heritage.HeritageStructures19.findNearest(level, structures, origin, radius, skipReferenced); - if (generation == 21 && structures.size() == 1 && structures.get(0).is(fr.koka.sanctuary.worldgen.transit.TransitStructures21.KEY)) + if ((generation == 21 || generation == 22) && structures.size() == 1 && structures.get(0).is(fr.koka.sanctuary.worldgen.transit.TransitStructures21.KEY)) return fr.koka.sanctuary.worldgen.transit.TransitStructures21.findNearest(level, structures, origin, radius, skipReferenced); return super.findNearestMapStructure(level, structures, origin, radius, skipReferenced); } @@ -483,7 +540,7 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { @Override protected void afterTerrain(ExpansionIsland island, ChunkAccess chunk) { // Root water/lava are already applied by the Population hook on the named delegate. if (!island.origin()) super.afterTerrain(island, chunk); - if ((generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21) && cityStructuresEnabled) { + if ((generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22) && cityStructuresEnabled) { var reserved = cityReservations16.get(cityIdentity(island)); if (reserved != null) { var mutable = new net.minecraft.core.BlockPos.MutableBlockPos(); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityPlanner22.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityPlanner22.java new file mode 100644 index 0000000..e4a661e --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityPlanner22.java @@ -0,0 +1,147 @@ +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.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Generation22 only: earlier planners propose geometry, but cannot authorize artificial terrain. + * Proposals and their forced reservations are read-only; only fully recertified natural towns + * reach the returned plan. The published generation21 solver and all renderers stay unchanged. */ +public final class LostCityPlanner22 { + public static final int MAX_COLUMNS = LostCityPlanner21.MAX_COLUMNS; + public static final int MAX_VOLUME_CHECKS = LostCityPlanner21.MAX_VOLUME_CHECKS; + public static final int MAX_ATTEMPTS = 3, MAX_CUT = 2, MAX_FILL = 2; + private static final LostCityPlanner.Ground INVALID = new LostCityPlanner.Ground(0, 0, false, true); + private LostCityPlanner22() {} + + public static LostCityPlanner21.Result plan(long seed, int cx, int cz, int diameter, boolean origin, + LostCityPlanner16.Terrain terrain) { + if (diameter < 64 || diameter > 1024 || origin && diameter < 512) + throw new IllegalArgumentException("Unsupported natural city island size"); + var probe = new Probe(terrain); + for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + probe.reject("proposal-attempt"); + long proposalSeed = attempt == 0 ? seed : mix(seed ^ attempt * 0x22C175L); + try { + var proposal = LostCityPlanner21.plan(proposalSeed, cx, cz, diameter, origin, probe); + if (proposal.reservation() != null) { probe.reject("forced-reservation-rejected"); continue; } + if (proposal.core().surface().isEmpty()) { probe.reject("no-natural-core"); continue; } + if (!proposal.core().supports().isEmpty()) { probe.reject("core-pier-rejected"); continue; } + boolean valid = true; + for (var district : proposal.core().surface().districts()) + if (!validate(district, probe)) { valid = false; break; } + if (!valid) continue; + + var districts = new ArrayList<>(proposal.core().surface().districts()); + var connections = new ArrayList(); + for (var connection : proposal.connections()) { + try { + var district = proposal.surface().districts().get(connection.districtIndex()); + if (proposal.supports().stream().anyMatch(s -> s.startChunkX() == district.startChunkX() + && s.startChunkZ() == district.startChunkZ())) { + probe.reject("satellite-pier-rejected"); continue; + } + if (!validate(district, probe)) continue; + var retained = new ArrayList<>(districts); retained.add(district); + if (!connected(connection.path(), retained)) { + probe.reject("satellite-depends-on-rejected-road"); continue; + } + connections.add(new LostCityPlanner21.Connection(districts.size(), connection.path())); + districts.add(district); + } catch (Exhausted exhausted) { probe.reject("satellite-budget"); break; } + } + probe.reject("natural-core-accepted"); + return result(proposal.core(), new LostCityPlan(districts), connections, probe); + } catch (Exhausted exhausted) { + probe.reject("natural-search-budget"); break; + } catch (IllegalArgumentException rejectedReservation) { + // The immutable16 proposal solver requires an owned fallback envelope. In22 an + // island with no such envelope is simply unsuitable; unrelated errors still fail. + String message = rejectedReservation.getMessage(); + if (!"Starter terrain does not own a central 49x49 city envelope".equals(message) + && !"Starter city reservation crosses another island's ownership".equals(message)) throw rejectedReservation; + probe.reject("unowned-reservation-rejected"); + } + } + return result(new LostCityPlanner16.Result(LostCityPlan.EMPTY, List.of(), 0, 0), + LostCityPlan.EMPTY, List.of(), probe); + } + + private static LostCityPlanner21.Result result(LostCityPlanner16.Result core, LostCityPlan surface, + List connections, Probe probe) { + return new LostCityPlanner21.Result(core, surface, List.of(), probe.columns.size(), probe.checks, + connections, probe.diagnostics); + } + + /** Validate actual emitted footprint and bearing columns, not just a few terrain samples. + * Eight-block pool/garage basements remain possible only when entirely inside native rock. */ + private static boolean validate(District district, Probe probe) { + for (Parcel parcel : district.parcels()) { + if (parcel.kind() == Kind.BRIDGE) { probe.reject("suspended-bridge-rejected"); return false; } + for (int z = parcel.minZ(); z <= parcel.maxZ(); z++) for (int x = parcel.minX(); x <= parcel.maxX(); x++) { + var ground = probe.ground(x, z); + if (!ground.dry() || ground.protectedTerrain() || ground.solidDepth() < 4) { + probe.reject("not-dry-natural-ground"); return false; + } + int floor = parcel.surfaceY(x - parcel.minX(), z - parcel.minZ()); + if (ground.top() - floor > MAX_CUT || floor - ground.top() > MAX_FILL) { + probe.reject("excess-cut-or-fill"); return false; + } + int bottom = floor - parcel.foundationDepth(); + // Two unpainted rock layers must carry every column. The entire native part of + // each footing stays solid: no borrowed corner certificate across a cavity. + for (int y = bottom - 2; y <= Math.min(floor - 1, ground.top()); y++) { + if (!probe.solid(x, y, z)) { probe.reject("incomplete-natural-bearing"); return false; } + } + for (int y = bottom - 2; y <= parcel.maxY(); y++) { + if (probe.protectedAt(x, y, z)) { probe.reject("protected-construction-volume"); return false; } + } + } + } + return true; + } + + private static boolean connected(List path, List retained) { + for (var point : path) { + boolean found = false; + for (var district : retained) for (var parcel : district.parcels()) { + if (parcel.kind() == Kind.STREET && point.x() >= parcel.minX() && point.x() <= parcel.maxX() + && point.z() >= parcel.minZ() && point.z() <= parcel.maxZ() + && parcel.surfaceY(point.x() - parcel.minX(), point.z() - parcel.minZ()) == point.floorY()) found = true; + } + if (!found) return false; + } + return true; + } + + private static final class Probe implements LostCityPlanner16.Terrain { + final LostCityPlanner16.Terrain terrain; + final Map columns = new HashMap<>(); + final Map diagnostics = new LinkedHashMap<>(); + int checks; + Probe(LostCityPlanner16.Terrain terrain) { this.terrain = java.util.Objects.requireNonNull(terrain); } + @Override public boolean owns(int x, int z) { return terrain.owns(x, z); } + @Override public LostCityPlanner.Ground ground(int x, int z) { + if (!owns(x, z)) return INVALID; + long key = (long) x << 32 ^ z & 0xffffffffL; + var cached = columns.get(key); if (cached != null) return cached; + if (columns.size() >= MAX_COLUMNS) throw new Exhausted(); + var result = java.util.Objects.requireNonNull(terrain.ground(x, z)); columns.put(key, result); return result; + } + @Override public boolean solid(int x, int y, int z) { + count(); return ground(x, z) != INVALID && terrain.solid(x, y, z); + } + @Override public boolean protectedAt(int x, int y, int z) { + count(); return ground(x, z) == INVALID || terrain.protectedAt(x, y, z); + } + void count() { if (checks >= MAX_VOLUME_CHECKS) throw new Exhausted(); checks++; } + void reject(String reason) { diagnostics.merge(reason, 1, Integer::sum); } + } + private static long mix(long v) { v = (v ^ v >>> 30) * 0xbf58476d1ce4e5b9L; v = (v ^ v >>> 27) * 0x94d049bb133111ebL; return v ^ v >>> 31; } + private static final class Exhausted extends RuntimeException { private static final long serialVersionUID = 1L; } +} 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 598baea..7838b98 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 @@ -29,18 +29,18 @@ public final class LostCityStructure extends Structure { throw new IllegalStateException("Lost City district exceeds native structure reference range"); } return Optional.of(new GenerationStub(LostCityStructures.locator(district), pieces -> { - if ((generator.rootGeneration() == 16 || generator.rootGeneration() == 17 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21) && generator.cityReservation16(island) != null - && (generator.rootGeneration() != 21 || generator.cityPlacement21(island).core().surface().districts().contains(district))) + if ((generator.rootGeneration() == 16 || generator.rootGeneration() == 17 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22) && generator.cityReservation16(island) != null + && ((generator.rootGeneration() != 21 && generator.rootGeneration() != 22) || generator.cityPlacement21(island).core().surface().districts().contains(district))) pieces.addPiece(new LostCityFoundationPiece16(generator.cityReservation16(island), district.seed())); if (generator.rootGeneration() == 15) for (var support : generator.citySupports(island)) { if (support.startChunkX() == district.startChunkX() && support.startChunkZ() == district.startChunkZ()) pieces.addPiece(new LostCitySupportPiece15(support)); } - if ((generator.rootGeneration() == 16 || generator.rootGeneration() == 17 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21)) for (var support : generator.citySupports16(island)) { + if ((generator.rootGeneration() == 16 || generator.rootGeneration() == 17 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22)) for (var support : generator.citySupports16(island)) { if (support.startChunkX() == district.startChunkX() && support.startChunkZ() == district.startChunkZ()) pieces.addPiece(new LostCitySupportPiece16(support)); } - district.parcels().forEach(parcel -> pieces.addPiece(generator.rootGeneration() == 21 ? new LostCityPiece21(parcel) : generator.rootGeneration() == 20 ? new LostCityPiece20(parcel) : (generator.rootGeneration() == 16 || generator.rootGeneration() == 17 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21) ? new LostCityPiece16(parcel) : generator.rootGeneration() == 15 + district.parcels().forEach(parcel -> pieces.addPiece((generator.rootGeneration() == 21 || generator.rootGeneration() == 22) ? new LostCityPiece21(parcel) : generator.rootGeneration() == 20 ? new LostCityPiece20(parcel) : (generator.rootGeneration() == 16 || generator.rootGeneration() == 17 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22) ? new LostCityPiece16(parcel) : generator.rootGeneration() == 15 ? new LostCityPiece15(parcel) : new LostCityPiece(parcel))); if (generator.rootGeneration() == 15) for (var network : generator.undergroundPlan(island).networks()) { if (network.startChunkX() != district.startChunkX() || network.startChunkZ() != district.startChunkZ()) continue; @@ -50,13 +50,13 @@ public final class LostCityStructure extends Structure { network.modules().stream().filter(m -> m.kind() == LostCityUnderground15.Kind.ACCESS) .forEach(m -> pieces.addPiece(new LostCityUndergroundPiece15(m))); } - if ((generator.rootGeneration() == 16 || generator.rootGeneration() == 17 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21)) for (var network : generator.undergroundPlan16(island).networks()) { + if ((generator.rootGeneration() == 16 || generator.rootGeneration() == 17 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22)) for (var network : generator.undergroundPlan16(island).networks()) { if (network.startChunkX() != district.startChunkX() || network.startChunkZ() != district.startChunkZ()) continue; // Surface comes first, rooms/corridors next, shafts last so every entrance remains open. network.modules().stream().filter(m -> m.kind() != LostCityUnderground16.Kind.ACCESS) - .forEach(m -> pieces.addPiece((generator.rootGeneration() == 20 || generator.rootGeneration() == 21) ? new LostCityUndergroundPiece20(m) : new LostCityUndergroundPiece16(m))); + .forEach(m -> pieces.addPiece((generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22) ? new LostCityUndergroundPiece20(m) : new LostCityUndergroundPiece16(m))); network.modules().stream().filter(m -> m.kind() == LostCityUnderground16.Kind.ACCESS) - .forEach(m -> pieces.addPiece((generator.rootGeneration() == 20 || generator.rootGeneration() == 21) ? new LostCityUndergroundPiece20(m) : new LostCityUndergroundPiece16(m))); + .forEach(m -> pieces.addPiece((generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22) ? new LostCityUndergroundPiece20(m) : new LostCityUndergroundPiece16(m))); } })); } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityUnderground22.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityUnderground22.java new file mode 100644 index 0000000..efe55ac --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityUnderground22.java @@ -0,0 +1,187 @@ +package fr.koka.sanctuary.worldgen.city; + +import fr.koka.sanctuary.worldgen.city.LostCityUnderground16.*; +import fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Module; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** An independent public undercroft. No surface district, fake parcel or city reservation is needed. + * Only complete hall/access pairs are published; optional utility wings cannot invalidate them. */ +public final class LostCityUnderground22 { + public static final int MAX_COLUMNS = 6_000, MAX_SAMPLES = LostCityUnderground16.MAX_SAMPLES; + private static final int[] DX = {0, 1, 0, -1}, DZ = {-1, 0, 1, 0}; + private LostCityUnderground22() {} + public interface Terrain extends LostCityUnderground16.Terrain { int surfaceY(int x, int z); } + + public static Plan plan(long seed, int cx, int cz, int diameter, Terrain terrain) { + if (!List.of(512, 724, 1024).contains(diameter)) throw new IllegalArgumentException("Unsupported undercroft island size"); + var probe = new Probe(terrain, cx, cz, diameter); + List accepted = null; + long acceptedSeed = seed; + try { + search: for (int attempt = 0; attempt < 40; attempt++) { + double angle = (mix(seed ^ 0x22A11L) >>> 11) * 0x1.0p-53 * Math.PI * 2 + attempt * 2.399963229728653; + double radius = attempt == 0 ? 0 : diameter * (attempt < 9 ? .09 : attempt < 21 ? .18 : .28); + int x = cx + (int) Math.round(Math.cos(angle) * radius), z = cz + (int) Math.round(Math.sin(angle) * radius); + int top = accessHeight(x, z, probe); + if (top < 0) continue; + for (int drop : new int[]{22, 32, 46, 62, 86, 110}) { + int floor = top - drop; + if (floor < 24) continue; + long networkSeed = mix(seed ^ ((long) x << 32) ^ z ^ drop); + for (int turn = 0; turn < 4; turn++) { + int direction = (int) Math.floorMod(networkSeed + turn, 4); + var access = new Module(Kind.ACCESS, x - 3, floor - 1, z - 3, 7, drop + 4, 7, + 1 << direction, networkSeed, List.of()); + var hall = attached(access, Kind.EXPANSION_HALL, direction, mix(networkSeed + 1)); + // Cheap hall witnesses come before repeatedly scanning a tall access shaft. + if (!coarse(hall, probe)) continue; + var modules = new ArrayList(); + access = certify(access, probe); + if (access == null) continue; + modules.add(access); + if (!attach(modules, 0, hall, direction, probe)) continue; + accepted = modules; acceptedSeed = networkSeed; + break search; + } + } + } + } catch (BudgetExceeded exhausted) { probe.reject("required-search-budget"); } + if (accepted != null) { + // A sewer and a pool are additional destinations, never conditions for the public hall. + int forward = Integer.numberOfTrailingZeros(accepted.getFirst().openings()); + try { + for (int side : new int[]{(forward + 1) & 3, (forward + 3) & 3}) + if (attach(accepted, 1, attached(accepted.get(1), Kind.SEWER, side, mix(acceptedSeed + 2)), side, probe)) break; + if (probe.samples < MAX_SAMPLES - 70_000 && probe.columns.size() < MAX_COLUMNS - 1_900) + attach(accepted, 1, attached(accepted.get(1), Kind.POOLROOM, forward, mix(acceptedSeed + 3)), forward, probe); + } catch (BudgetExceeded exhausted) { probe.reject("optional-wing-budget"); } + } + var networks = accepted == null ? List.of() : List.of(new Network( + accepted.getFirst().centerX() >> 4, accepted.getFirst().centerZ() >> 4, acceptedSeed, accepted)); + System.getLogger(LostCityUnderground22.class.getName()).log(System.Logger.Level.INFO, + "Independent undercroft22 seed=" + seed + " networks=" + networks.size() + " columns=" + probe.columns.size() + + " samples=" + probe.samples + " kinds=" + (accepted == null ? List.of() : accepted.stream().map(Module::kind).toList()) + + " admission=" + probe.rejections); + return new Plan(networks, probe.samples); + } + + /** The whole visible seven-square hatch deck stays within two blocks of existing dry ground. */ + private static int accessHeight(int cx, int cz, Probe p) { + int low = 384, high = -1; + for (int z = cz - 3; z <= cz + 3; z++) for (int x = cx - 3; x <= cx + 3; x++) { + int top = p.surface(x, z); + if (top < 64 || top > 365 || !p.solid(x, top, z) || p.protectedAt(x, top, z)) return p.rejectHeight("dry-access"); + low = Math.min(low, top); high = Math.max(high, top); + if (high - low > 2) return p.rejectHeight("access-relief"); + } + return high; + } + + private static Module attached(Module parent, Kind kind, int direction, long seed) { + int width = kind == Kind.POOLROOM ? 57 : kind == Kind.EXPANSION_HALL ? 25 : 9; + int depth = kind == Kind.POOLROOM ? 31 : width; + int height = kind == Kind.POOLROOM ? 13 : kind == Kind.EXPANSION_HALL ? 10 : 7; + int offset = (DX[direction] == 0 ? parent.depth() / 2 + depth / 2 : parent.width() / 2 + width / 2) + 8; + int x = parent.centerX() + DX[direction] * offset, z = parent.centerZ() + DZ[direction] * offset; + return new Module(kind, x - width / 2, parent.floorY() - (kind == Kind.POOLROOM ? 5 : 1), + z - depth / 2, width, height, depth, 1 << ((direction + 2) & 3), seed, List.of()); + } + private static boolean attach(List modules, int parentIndex, Module room, int direction, Probe p) { + var parent = modules.get(parentIndex); + int x = DX[direction] == 0 ? parent.centerX() - 2 : direction == 1 ? parent.maxX() + 1 : room.maxX() + 1; + int z = DZ[direction] == 0 ? parent.centerZ() - 2 : direction == 2 ? parent.maxZ() + 1 : room.maxZ() + 1; + var passage = new Module(Kind.PASSAGE, x, parent.floorY() - 1, z, DX[direction] == 0 ? 5 : 7, 6, + DZ[direction] == 0 ? 5 : 7, (1 << direction) | (1 << ((direction + 2) & 3)), mix(room.seed()), List.of()); + for (var old : modules) if (overlaps(room, old) || overlaps(passage, old)) { p.reject("wing-collision"); return false; } + if (!coarse(room, p) || !coarse(passage, p)) return false; + room = certify(room, p); if (room == null) return false; + passage = certify(passage, p); if (passage == null) return false; + if (LostCityUnderground16.supportConflict(room, passage)) return false; + for (var old : modules) if (LostCityUnderground16.supportConflict(room, old) + || LostCityUnderground16.supportConflict(passage, old)) return false; + modules.set(parentIndex, new Module(parent.kind(), parent.minX(), parent.minY(), parent.minZ(), parent.width(), + parent.height(), parent.depth(), parent.openings() | 1 << direction, parent.seed(), parent.supports())); + modules.add(room); modules.add(passage); + return true; + } + private static boolean overlaps(Module a, Module b) { + return a.minX() <= b.maxX() && a.maxX() >= b.minX() && a.minZ() <= b.maxZ() && a.maxZ() >= b.minZ() + && a.boundsMinY() <= b.boundsMaxY() && a.boundsMaxY() >= b.boundsMinY(); + } + private static boolean coarse(Module m, Probe p) { + int solid = 0; + for (int x : new int[]{m.minX(), m.centerX(), m.maxX()}) for (int z : new int[]{m.minZ(), m.centerZ(), m.maxZ()}) { + if (!p.owns(x, z)) { p.reject("outside-island"); return false; } + for (int y : new int[]{m.minY(), m.floorY() + 2, m.maxY()}) { + if (p.protectedAt(x, y, z)) { p.reject("coarse-protected"); return false; } + if (p.solid(x, y, z)) solid++; + } + } + if (solid < 5) { p.reject("coarse-rock"); return false; } + return true; + } + private static Module certify(Module m, Probe p) { + var supports = new ArrayList(); + int stride = m.kind() == Kind.EXPANSION_HALL || m.kind() == Kind.POOLROOM ? 8 : 3; + for (int z : lattice(m.minZ(), m.maxZ(), stride)) for (int x : lattice(m.minX(), m.maxX(), stride)) { + var support = support(m, x, z, p); + if (support == null) { p.reject("no-short-anchor"); return null; } + supports.add(support); + } + int solid = 0, count = 0; + for (int z = m.minZ(); z <= m.maxZ(); z++) for (int x = m.minX(); x <= m.maxX(); x++) { + if (!p.owns(x, z)) { p.reject("outside-island"); return null; } + for (int y = m.minY(); y <= m.maxY(); y++) { + if (p.protectedAt(x, y, z)) { p.reject("protected-volume"); return null; } + if (p.solid(x, y, z)) solid++; count++; + } + } + // The constructed shell can cross a native cavity; it must still be embedded in real terrain. + if (solid * 100L < count * (m.kind() == Kind.ACCESS ? 50 : 20)) { p.reject("insufficient-rock"); return null; } + return new Module(m.kind(), m.minX(), m.minY(), m.minZ(), m.width(), m.height(), m.depth(), + m.openings(), m.seed(), supports); + } + private static Support support(Module m, int x, int z, Probe p) { + // Save even direct bearings: the existing protection builder then retains their untouched rock. + for (int distance = 1; distance <= 8; distance++) { + int y = m.minY() - distance; + if (y < 2 || p.protectedAt(x, y, z)) break; + if (p.solid(x, y, z) && p.solid(x, y - 1, z) && !p.protectedAt(x, y - 1, z)) + return new Support(Type.PILLAR, x, y, z, m.minY() - 1); + } + for (int distance = 1; distance <= 8; distance++) { + int y = m.maxY() + distance; + if (y > 381 || p.protectedAt(x, y, z)) break; + if (p.solid(x, y, z) && p.solid(x, y + 1, z) && !p.protectedAt(x, y + 1, z)) + return new Support(Type.CHAIN, x, m.maxY() + 1, z, y); + } + return null; + } + private static List lattice(int min, int max, int stride) { + var result = new ArrayList(); for (int n = min; n <= max; n += stride) result.add(n); + if (result.getLast() != max) result.add(max); return result; + } + private static final class Probe { + final Terrain terrain; final int cx, cz; final double radius; + final Map columns = new HashMap<>(); final Map rejections = new LinkedHashMap<>(); int samples; + Probe(Terrain terrain, int cx, int cz, int diameter) { this.terrain = terrain; this.cx = cx; this.cz = cz; radius = diameter / 2.0 - 4; } + void count() { if (samples >= MAX_SAMPLES) throw new BudgetExceeded(); samples++; } + boolean owns(int x, int z) { return Math.hypot((double) x - cx, (double) z - cz) <= radius && terrain.owns(x, z); } + int surface(int x, int z) { + if (!owns(x, z)) return -1; + long key = ((long) x << 32) ^ (z & 0xffffffffL); var value = columns.get(key); if (value != null) return value; + if (columns.size() >= MAX_COLUMNS) throw new BudgetExceeded(); + count(); int y = terrain.surfaceY(x, z); columns.put(key, y); return y; + } + boolean solid(int x, int y, int z) { if (y < 1 || y > 383 || surface(x, z) < 0) return false; count(); return terrain.solid(x, y, z); } + boolean protectedAt(int x, int y, int z) { if (y < 1 || y > 383 || surface(x, z) < 0) return true; count(); return terrain.protectedAt(x, y, z); } + void reject(String reason) { rejections.merge(reason, 1, Integer::sum); } + int rejectHeight(String reason) { reject(reason); return -1; } + } + private static final class BudgetExceeded extends RuntimeException { private static final long serialVersionUID = 1L; } + private static long mix(long x) { x = (x ^ x >>> 30) * 0xbf58476d1ce4e5b9L; x = (x ^ x >>> 27) * 0x94d049bb133111ebL; return x ^ x >>> 31; } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityUndergroundStructure22.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityUndergroundStructure22.java new file mode 100644 index 0000000..b978f20 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityUndergroundStructure22.java @@ -0,0 +1,41 @@ +package fr.koka.sanctuary.worldgen.city; + +import com.mojang.serialization.MapCodec; +import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator; +import java.util.Optional; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureType; + +/** An independent native start keeps a certified underground hall available without a surface city. */ +public final class LostCityUndergroundStructure22 extends Structure { + public static final MapCodec CODEC = simpleCodec(LostCityUndergroundStructure22::new); + + public LostCityUndergroundStructure22(StructureSettings settings) { super(settings); } + + @Override protected Optional findGenerationPoint(GenerationContext context) { + if (!(context.chunkGenerator() instanceof SanctuaryChunkGenerator generator) + || generator.rootGeneration() != 22) return Optional.empty(); + var island = generator.islandForChunk(context.chunkPos().x(), context.chunkPos().z()); + if (island == null || !island.origin() || island.generation() != 22) return Optional.empty(); + var networks = generator.undergroundHall22().networks().stream() + .filter(n -> n.startChunkX() == context.chunkPos().x() && n.startChunkZ() == context.chunkPos().z()).toList(); + if (networks.isEmpty()) return Optional.empty(); + for (var network : networks) for (var module : network.modules()) { + var bounds = LostCityUndergroundPiece20.bounds(module); + if (Math.abs((long) Math.floorDiv(bounds.minX(), 16) - network.startChunkX()) > 7 + || Math.abs((long) Math.floorDiv(bounds.maxX(), 16) - network.startChunkX()) > 7 + || Math.abs((long) Math.floorDiv(bounds.minZ(), 16) - network.startChunkZ()) > 7 + || Math.abs((long) Math.floorDiv(bounds.maxZ(), 16) - network.startChunkZ()) > 7) + throw new IllegalStateException("Underground hall exceeds native structure reference range"); + } + return Optional.of(new GenerationStub(LostCityUndergroundStructures22.locator(networks.getFirst()), pieces -> { + // Shafts are last so connected rooms cannot refill their surface hatches. + networks.forEach(n -> n.modules().stream().filter(m -> m.kind() != LostCityUnderground16.Kind.ACCESS) + .forEach(m -> pieces.addPiece(new LostCityUndergroundPiece20(m)))); + networks.forEach(n -> n.modules().stream().filter(m -> m.kind() == LostCityUnderground16.Kind.ACCESS) + .forEach(m -> pieces.addPiece(new LostCityUndergroundPiece20(m)))); + })); + } + + @Override public StructureType type() { return LostCityUndergroundStructures22.TYPE; } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityUndergroundStructures22.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityUndergroundStructures22.java new file mode 100644 index 0000000..86b1432 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityUndergroundStructures22.java @@ -0,0 +1,108 @@ +package fr.koka.sanctuary.worldgen.city; + +import com.mojang.datafixers.util.Pair; +import fr.koka.sanctuary.SanctuaryMod; +import fr.koka.sanctuary.expansion.ExpansionChunkGenerator; +import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderSet; +import net.minecraft.core.Registry; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.StructureManager; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.ChunkGeneratorStructureState; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.levelgen.densityfunction.SamplerContext; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.StructureType; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; + +/** Native lifecycle for generation22's standalone hall; its saved modules reuse the generation20 pieces. */ +public final class LostCityUndergroundStructures22 { + public static final ResourceKey KEY = ResourceKey.create(Registries.STRUCTURE, + SanctuaryMod.id("underground_hall22")); + public static final StructureType TYPE = Registry.register( + BuiltInRegistries.STRUCTURE_TYPE, SanctuaryMod.id("underground_hall_v22"), () -> LostCityUndergroundStructure22.CODEC); + + private LostCityUndergroundStructures22() {} + public static void register() { /* Initialize the codec before reading the dynamic structure registry. */ } + + public static void generate(ExpansionChunkGenerator generator, RegistryAccess registries, + ChunkGeneratorStructureState state, StructureManager manager, ChunkAccess chunk, + StructureTemplateManager templates, ResourceKey level) { + if (!Level.OVERWORLD.equals(level) || !manager.shouldGenerateStructures() + || !(generator instanceof SanctuaryChunkGenerator sanctuary) || generator.rootGeneration() != 22) return; + var island = generator.islandForChunk(chunk.getPos().x(), chunk.getPos().z()); + if (island == null || !island.origin() || island.generation() != 22) return; + if (sanctuary.undergroundHall22().networks().stream().noneMatch(n -> n.startChunkX() == chunk.getPos().x() + && n.startChunkZ() == chunk.getPos().z())) return; + Holder holder = registries.lookupOrThrow(Registries.STRUCTURE).getOrThrow(KEY); + Structure structure = holder.value(); + StructureStart previous = manager.getStartForStructure(structure, chunk); + if (previous != null && previous.isValid()) return; + var random = generator.randomState(island); + var sampler = random.createClimateSampler(SamplerContext.builder().enableCaches().build()); + var start = structure.generate(holder, level, registries, generator, generator.getBiomeSource(), sampler, + random, templates, island.seed(), chunk.getPos(), previous == null ? 0 : previous.getReferences(), + chunk, structure.biomes()::contains); + if (start.isValid()) manager.setStartForStructure(structure, start, chunk); + } + + /** Locate returns the persisted surface hatch, loading only the native structure start when necessary. */ + public static Pair> findNearest(ServerLevel level, HolderSet structures, + BlockPos origin, int radius, boolean skipReferenced) { + if (!Level.OVERWORLD.equals(level.dimension()) + || !(level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator generator) + || generator.rootGeneration() != 22 || !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 network : generator.undergroundHall22().networks()) { + var island = generator.islandForChunk(network.startChunkX(), network.startChunkZ()); + if (island == null || !island.origin() || island.generation() != 22 || !island.status().equals("ready")) continue; + if (Math.abs((long) network.startChunkX() - originChunkX) <= radius + && Math.abs((long) network.startChunkZ() - originChunkZ) <= radius) candidates.add(network); + } + candidates.sort(Comparator.comparingDouble(n -> origin.distSqr(locator(n)))); + var visited = new HashSet(); + for (var network : candidates) { + long key = ((long) network.startChunkX() << 32) ^ (network.startChunkZ() & 0xffffffffL); + if (!visited.add(key)) continue; + var chunk = level.getChunk(network.startChunkX(), network.startChunkZ(), ChunkStatus.STRUCTURE_STARTS, true); + StructureStart start = level.structureManager().getStartForStructure(holder.value(), chunk); + if (start == null || !start.isValid() || skipReferenced && !start.canBeReferenced()) continue; + var entrance = start.getPieces().stream() + .filter(p -> p instanceof LostCityUndergroundPiece20 piece && piece.module().kind() == LostCityUnderground16.Kind.ACCESS) + .map(p -> locator(((LostCityUndergroundPiece20) p).module())) + .min(Comparator.comparingDouble(origin::distSqr)); + if (entrance.isEmpty()) continue; + if (skipReferenced) level.structureManager().addReference(start); + return Pair.of(entrance.get(), holder); + } + return null; + } + + public static BlockPos locator(LostCityUnderground16.Network network) { + return locator(network.modules().stream().filter(m -> m.kind() == LostCityUnderground16.Kind.ACCESS) + .findFirst().orElseThrow(() -> new IllegalArgumentException("Underground hall has no surface access"))); + } + + private static BlockPos locator(LostCityUnderground16.Module access) { + int exit = Integer.numberOfTrailingZeros(access.openings() == 0 ? 1 : access.openings()); + int wall = (exit + 2) & 3; + int x = wall == 1 ? access.width() - 2 : wall == 3 ? 1 : access.width() / 2; + int z = wall == 2 ? access.depth() - 2 : wall == 0 ? 1 : access.depth() / 2; + return new BlockPos(access.minX() + x, access.maxY() - 1, access.minZ() + z); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritageCommands19.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritageCommands19.java index 1dc5938..4b646bc 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritageCommands19.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritageCommands19.java @@ -19,7 +19,7 @@ public final class HeritageCommands19 { var level = source.getLevel(); if (!Level.OVERWORLD.equals(level.dimension()) || !(level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator generator) - || (generator.rootGeneration() != 19 && generator.rootGeneration() != 20 && generator.rootGeneration() != 21) || !generator.experimentalStructures()) { + || (generator.rootGeneration() != 19 && generator.rootGeneration() != 20 && generator.rootGeneration() != 21 && generator.rootGeneration() != 22) || generator.rootGeneration() != 22 && !generator.experimentalStructures()) { source.sendFailure(Component.translatable("sanctuary.heritage.unavailable")); return 0; } @@ -28,12 +28,18 @@ public final class HeritageCommands19 { for (var site : plan.sites()) source.sendSuccess(() -> Component.translatable("sanctuary.heritage.entry", Component.translatable("sanctuary.heritage.kind." + site.kind().name().toLowerCase(Locale.ROOT)), site.entry().x(), site.entry().y(), site.entry().z()), false); - if (generator.rootGeneration() == 21) for (var module : generator.transitPlan21().modules()) { + if (generator.rootGeneration() == 21 || generator.rootGeneration() == 22) for (var module : generator.transitPlan21().modules()) { if (module.kind() != fr.koka.sanctuary.worldgen.transit.TransitPlan21.Kind.ACCESS) continue; var at = fr.koka.sanctuary.worldgen.transit.TransitPiece21.locator(module); source.sendSuccess(() -> Component.translatable("sanctuary.transit.entry", at.getX(), at.getY(), at.getZ()), false); } - return plan.sites().size(); + if (generator.rootGeneration() == 22) for (var network : generator.undergroundHall22().networks()) { + var at = fr.koka.sanctuary.worldgen.city.LostCityUndergroundStructures22.locator(network); + source.sendSuccess(() -> Component.translatable("sanctuary.underground_hall.entry", at.getX(), at.getY(), at.getZ()), false); + } + return plan.sites().size() + (generator.rootGeneration() == 22 + ? (int) generator.transitPlan21().modules().stream().filter(m -> m.kind() == fr.koka.sanctuary.worldgen.transit.TransitPlan21.Kind.ACCESS).count() + + generator.undergroundHall22().networks().size() : 0); })))); } } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritagePlanner22.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritagePlanner22.java new file mode 100644 index 0000000..aed2e5c --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritagePlanner22.java @@ -0,0 +1,612 @@ +package fr.koka.sanctuary.worldgen.heritage; + +import fr.koka.sanctuary.worldgen.heritage.HeritagePlan19.*; +import fr.koka.sanctuary.worldgen.heritage.HeritagePlanner21.Terrain; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** Generation22 only. A finite catalogue is admitted before complete, repairable routes are published. + * Everything samples native columns; no chunk, player object or future continent is requested. */ +public final class HeritagePlanner22 { + public static final int MAX_COLUMNS = HeritagePlan19.MAX_COLUMNS, MAX_SAMPLES = HeritagePlan19.MAX_SAMPLES; + // Prefer an existing platform: at most two blocks of cut/fill and four of total relief. + public static final int MAX_SITE_CUT = 2, MAX_SITE_FILL = 2, MAX_SITE_RELIEF = 4; + public static final int MAX_ROUTE_CUT = 1, MAX_ROUTE_FILL = 1; + private static final int SITE_COLUMNS = 11_000, SITE_SAMPLES = 540_000; + private static final int INVALID = Integer.MIN_VALUE; + private static final int[] DX = {0, 1, 0, -1}, DZ = {-1, 0, 1, 0}; + private HeritagePlanner22() {} + + public static HeritagePlan19 plan(long seed, int cx, int cz, int diameter, Terrain terrain) { + if (!List.of(512, 724, 1024).contains(diameter)) throw new IllegalArgumentException("Heritage22 requires a starter size"); + var probe = new Probe(terrain, cx, cz, diameter); + List landmarks = List.copyOf(terrain.landmarks()).stream().limit(16).toList(); + var sites = new ArrayList(); var cells = new LinkedHashMap(); var diagnostics = new EnumMap(Kind.class); + for (var kind : Kind.values()) { + int remaining = Kind.values().length - kind.ordinal() - 1; + probe.columnLimit = SITE_COLUMNS - remaining * (SITE_COLUMNS / 14); + probe.sampleLimit = SITE_SAMPLES - remaining * (SITE_SAMPLES / 14); + SiteDraft admitted = null; int attempts = 0; + var reasons = new java.util.TreeMap(); probe.reasons = reasons; + try { + for (var candidate : candidates(seed, kind, cx, cz, diameter, sites, landmarks, probe)) { + attempts++; + admitted = site(seed, kind, candidate, sites, landmarks, cells, probe); + if (admitted != null) break; + } + } catch (BudgetExceeded exhausted) { reasons.merge(exhausted.columns ? "column-budget" : "probe-budget", 1, Integer::sum); } + if (admitted != null) { + sites.add(admitted.site()); + Request landing = new Request(admitted.site().id() + "_landing", admitted.site().id(), admitted.site().id(), RouteKind.PATH); + for (var cell : admitted.landings()) cells.put(key(cell.x(), cell.z()), new OwnedCell(landing, cell)); + } + diagnostics.put(kind, (admitted == null ? "not admitted" : "admitted") + "; candidates=" + attempts + "; " + reasons); + } + probe.columnLimit = MAX_COLUMNS; probe.sampleLimit = MAX_SAMPLES; + var links = new ArrayList(); + var routeResults = new LinkedHashMap(); + var routeReasons = new java.util.TreeMap(); probe.reasons = routeReasons; + Map endpoints = new LinkedHashMap<>(); + for (var site : sites) endpoints.put(site.id(), new Endpoint(site.id(), site.entry(), false)); + for (var landmark : landmarks) if (!endpoints.containsKey(landmark.id())) + endpoints.put(landmark.id(), new Endpoint(landmark.id(), landmark.entry(), true)); + var requests = new ArrayList<>(requests(sites, landmarks).stream() + .filter(r -> endpoints.containsKey(r.from()) && endpoints.containsKey(r.to())).toList()); + var components = new Components(endpoints.keySet()); + // Multiple exterior entries into the certified connected city represent one already walkable destination. + String firstCity = null; + for (var landmark : landmarks) if (landmark.kind() == LandmarkKind.CITY) { + if (firstCity == null) firstCity = landmark.id(); else components.join(firstCity, landmark.id()); + } + Request longFreight = requests.stream().filter(r -> r.kind() == RouteKind.FREIGHT + && span(endpoints.get(r.from()).entry(), endpoints.get(r.to()).entry()) >= diameter / 4) + .max(Comparator.comparingInt(r -> span(endpoints.get(r.from()).entry(), endpoints.get(r.to()).entry()))).orElse(null); + var siteIds = new HashSet(); sites.forEach(site -> siteIds.add(site.id())); + var addressed = new HashSet(); + var destinationAttempts = new HashMap(); + int attempted = 0; + while (!requests.isEmpty() && attempted < 36 && links.size() < 24) { + // Give each isolated site a first useful route before spending its columns on a cycle. + // Repeated city gates already belong to one walkable component; no redundant freight bypass is needed. + requests.removeIf(r -> components.same(r.from(), r.to())); + if (requests.isEmpty()) break; + requests.sort(Comparator.comparingInt((Request r) -> isolatedSites(r, siteIds, addressed) > 0 + && isolatedAttempts(r, siteIds, addressed, destinationAttempts) == 0 ? 0 : 1) + .thenComparingInt(r -> span(endpoints.get(r.from()).entry(), endpoints.get(r.to()).entry())) + .thenComparingInt(r -> r.kind() == RouteKind.FREIGHT ? 0 : 1).thenComparing(Request::id)); + var request = attempted == 0 && longFreight != null ? longFreight : requests.getFirst(); requests.remove(request); + attempted++; + int distance = Math.abs(endpoints.get(request.from()).entry().x() - endpoints.get(request.to()).entry().x()) + + Math.abs(endpoints.get(request.from()).entry().z() - endpoints.get(request.to()).entry().z()); + boolean longAxis = span(endpoints.get(request.from()).entry(), endpoints.get(request.to()).entry()) >= diameter / 4; + int isolated = isolatedSites(request, siteIds, addressed); + var waiting = new HashSet(); + for (var next : requests) for (var id : List.of(next.from(), next.to())) + if (siteIds.contains(id) && !addressed.contains(id) && !id.equals(request.from()) && !id.equals(request.to())) waiting.add(id); + int reserve = Math.min(Math.min(waiting.size(), 8), 36 - attempted) * 100; + int allowance = longAxis && attempted == 1 ? 3_800 : isolated > 0 ? Math.clamp(distance * 5 + 350, 900, 2_400) : 900; + probe.columnLimit = Math.min(MAX_COLUMNS - reserve, probe.columns.size() + allowance); + probe.sampleLimit = Math.min(MAX_SAMPLES - reserve * 20, probe.samples + (longAxis ? 150_000 : 80_000)); + for (var id : List.of(request.from(), request.to())) if (siteIds.contains(id) && !addressed.contains(id)) + destinationAttempts.merge(id, 1, Integer::sum); + Endpoint from = endpoints.get(request.from()), to = endpoints.get(request.to()); + int beforeRouteColumns = probe.columns.size(); + var beforeReasons = new HashMap<>(routeReasons); + try { + RouteDraft route = route(seed, request, from, to, sites, landmarks, cells, probe); + if (route == null) { probe.reject("no-complete-route"); routeResults.put(request.id(), "refused; columns=" + (probe.columns.size() - beforeRouteColumns) + rejectionDelta(beforeReasons, routeReasons, probe.routeBlock)); continue; } + var groups = new HashSet(); + cells.values().forEach(c -> groups.add(new SegmentKey(c.request(), c.cell().x() >> 4, c.cell().z() >> 4))); + route.cells().values().stream().filter(c -> !cells.containsKey(key(c.x(), c.z()))) + .forEach(c -> groups.add(new SegmentKey(request, c.x() >> 4, c.z() >> 4))); + if (groups.size() > 512) { routeResults.put(request.id(), "refused; native-piece-budget"); continue; } + links.add(new Link(request.id(), request.from(), request.to(), request.kind(), route.path())); + components.join(request.from(), request.to()); + addressed.add(request.from()); addressed.add(request.to()); + routeResults.put(request.id(), "admitted; span=" + span(from.entry(), to.entry()) + "; length=" + route.path().size() + "; columns=" + (probe.columns.size() - beforeRouteColumns)); + for (var cell : route.cells().values()) { + long key = key(cell.x(), cell.z()); + OwnedCell old = cells.get(key); + if (old == null) cells.put(key, new OwnedCell(request, cell)); + else if (cell.rail() != Rail.NONE && old.cell().rail() == Rail.NONE) + cells.put(key, new OwnedCell(old.request(), cell)); + else if (cell.rail() != Rail.NONE && old.cell().rail() != Rail.NONE && cell.rail() != old.cell().rail()) { + // A missing switch is intentional; never emit a rail with contradictory connections. + var c = old.cell(); cells.put(key, new OwnedCell(old.request(), + new Cell(c.x(), c.floorY(), c.z(), c.foundationDepth(), c.rail(), false))); + } + } + } catch (BudgetExceeded exhausted) { probe.reject(exhausted.columns ? "route-column-budget" : "route-probe-budget"); routeResults.put(request.id(), "budget; columns=" + (probe.columns.size() - beforeRouteColumns) + rejectionDelta(beforeReasons, routeReasons, probe.routeBlock)); } + } + for (var site : sites) { + var incident = links.stream().filter(l -> l.fromId().equals(site.id()) || l.toId().equals(site.id())).map(Link::id).toList(); + var attempts = new LinkedHashMap(); + routeResults.forEach((id, value) -> { if (id.contains(site.id())) attempts.put(id, value); }); + diagnostics.put(site.kind(), diagnostics.get(site.kind()) + "; connected=" + !incident.isEmpty() + + "; accepted-links=" + incident + "; route-attempts=" + attempts); + } + var segments = segments(seed, cells); + if (Boolean.getBoolean("sanctuary.test.heritage22") || Boolean.getBoolean("sanctuary.heritage.plannerDiagnostics")) + System.getLogger(HeritagePlanner22.class.getName()).log(System.Logger.Level.INFO, + "Heritage22 seed=" + seed + " diameter=" + diameter + " sites=" + sites.stream().map(Site::kind).toList() + + " links=" + links.size() + " segments=" + segments.size() + " columns=" + probe.columns.size() + + " samples=" + probe.samples + " catalogue=" + diagnostics + " routes=" + routeReasons + " routeResults=" + routeResults); + return new HeritagePlan19(sites, landmarks, links, segments, probe.samples, probe.columns.size(), diagnostics); + } + + private static List candidates(long seed, Kind kind, int cx, int cz, int diameter, + List sites, List landmarks, Probe probe) { + double angle = unit(mix(seed ^ 0x19ACEL)) * Math.PI * 2 + kind.ordinal() * 2.399963229728653; + double radius = diameter * (kind == Kind.VOID_DOCK ? .40 : kind == Kind.OBSERVATORY || kind == Kind.RADIO_RELAY ? .31 : .22); + int x = cx + (int) Math.round(Math.cos(angle) * radius), z = cz + (int) Math.round(Math.sin(angle) * radius); + Kind parent = switch (kind) { + case POST_OFFICE, COMMUNAL_DEPOT, RAIL_WORKSHOP -> Kind.FREIGHT_STATION; + case EXPEDITION_OFFICE, PHOTO_CLUB -> Kind.POST_OFFICE; + case SUBSTATION, CONSTRUCTION_REFUGE -> Kind.RAIL_WORKSHOP; + case GREENHOUSE, IMPOSSIBLE_SERVICE -> Kind.PUMP_STATION; + default -> null; + }; + Site neighbor = parent == null ? null : find(sites, parent); + if (neighbor != null) { + double local = unit(mix(seed ^ kind.ordinal() * 179L)) * Math.PI * 2; + int distance = kind == Kind.RAIL_WORKSHOP ? 65 : 40; + x = neighbor.centerX() + (int) Math.round(Math.cos(local) * distance); + z = neighbor.centerZ() + (int) Math.round(Math.sin(local) * distance); + } + var candidates = new ArrayList(); + for (int i = 0; i < 24; i++) { + double turn = angle + i * Math.PI / 3; + int offset = i == 0 ? 0 : i < 5 ? 24 : i < 8 ? 48 : i < 12 ? 72 : i < 16 ? 96 : 128; + int px = x + (int) Math.round(Math.cos(turn) * offset), pz = z + (int) Math.round(Math.sin(turn) * offset); + if (Math.hypot((double) px - cx, (double) pz - cz) > diameter / 2.0 - 18) continue; + if (overlapsSites(px - 15, pz - 15, px + 15, pz + 15, sites)) continue; + int low = 384, high = -1; boolean dry = true; + for (int dz : new int[] {-8, 0, 8}) for (int dx : new int[] {-8, 0, 8}) { + int top = probe.surface(px + dx, pz + dz); + if (top < 48 || top > 330 || !probe.solid(px + dx, top, pz + dz) + || probe.protectedAt(px + dx, top, pz + dz)) { dry = false; break; } + low = Math.min(low, top); high = Math.max(high, top); + } + if (!dry || high - low > MAX_SITE_RELIEF) { probe.reject("coarse-terrain"); continue; } + int facing = direction(px, pz, neighbor == null ? cx : neighbor.centerX(), neighbor == null ? cz : neighbor.centerZ()); + if (kind == Kind.VOID_DOCK) facing = (direction(px, pz, cx, cz) + 2) & 3; + int score = (high - low) * 1_024 + i * 8 + (kind == Kind.OBSERVATORY || kind == Kind.RADIO_RELAY ? (300 - high) / 4 : 0); + candidates.add(new Candidate(px - 8, pz - 8, facing, score, mix(seed ^ kind.ordinal() * 463L ^ i))); + } + candidates.sort(Comparator.comparingInt(Candidate::score).thenComparingLong(Candidate::seed)); + return candidates; + } + + private static SiteDraft site(long seed, Kind kind, Candidate candidate, List previous, + List landmarks, Map existing, Probe probe) { + int x = candidate.x(), z = candidate.z(), low = 384, high = -1; + for (var landmark : landmarks) if (landmark.entry().x() >= x - 2 && landmark.entry().x() <= x + 18 + && landmark.entry().z() >= z - 2 && landmark.entry().z() <= z + 18) { probe.reject("existing-entry"); return null; } + var heights = new ArrayList(); + for (int dz = 0; dz < 17; dz++) for (int dx = 0; dx < 17; dx++) { + int top = probe.surface(x + dx, z + dz); + if (top < 48 || top > 330 || !probe.solid(x + dx, top, z + dz)) { probe.reject("dry-footprint"); return null; } + heights.add(top); low = Math.min(low, top); high = Math.max(high, top); + } + int minimum = Math.max(48, high - MAX_SITE_CUT), maximum = Math.min(330, low + MAX_SITE_FILL); + if (minimum > maximum || high - low > MAX_SITE_RELIEF) { probe.reject("site-slope"); return null; } + heights.sort(Integer::compareTo); + int floor = Math.clamp(heights.get(heights.size() / 2), minimum, maximum); + int foundation = Math.max(3, floor - low + 1), height = height(kind); + int base = floor - foundation; + for (int dz = 0; dz < 17; dz++) for (int dx = 0; dx < 17; dx++) + for (int y = base; y <= floor + height; y++) if (probe.protectedAt(x + dx, y, z + dz)) { + probe.reject("site-protected-volume"); return null; + } + var supports = new ArrayList(); + // Every foundation column rests directly on intact rock. Do not catch a cliff + // or a cavity with long piers just to retain this candidate. + for (int dz = 0; dz < 17; dz++) for (int dx = 0; dx < 17; dx++) { + if (!probe.solid(x + dx, base - 1, z + dz) || !probe.solid(x + dx, base - 2, z + dz)) { + probe.reject("site-no-direct-bearing"); return null; + } + for (int y = base - 2; y < base; y++) if (probe.protectedAt(x + dx, y, z + dz)) { + probe.reject("support-protected"); return null; + } + } + // The square envelope and bearing grid have already been certified. A difficult front slope + // should try another real facade before rejecting the entire building; no terrain is weakened. + for (int turn : new int[]{0, 1, 3, 2}) { + int facing = (candidate.facing() + turn) & 3; + int ex = facing == 1 ? x + 17 : facing == 3 ? x - 1 : x + 8; + int ez = facing == 2 ? z + 17 : facing == 0 ? z - 1 : z + 8; + Entry entry = new Entry(ex, floor + 1, ez, facing); + Site result = new Site(id(kind), kind, x, floor, z, 17, 17, height, facing, + mix(seed ^ candidate.seed()), foundation, supports, entry); + var landings = landing(result.entry(), previous, probe); + if (landings == null) { probe.reject("entry-landing"); continue; } + boolean collision = false; + for (var cell : landings) { + OwnedCell old = existing.get(key(cell.x(), cell.z())); + if (old != null && old.cell().floorY() != cell.floorY()) { collision = true; break; } + } + if (collision) { probe.reject("landing-collision"); continue; } + if (turn != 0) probe.reject("admitted-other-facade"); + return new SiteDraft(result, landings); + } + return null; + } + + private static int height(Kind kind) { + return switch (kind) { + case FREIGHT_STATION, POST_OFFICE, RAIL_WORKSHOP, SUBSTATION, GREENHOUSE, PHOTO_CLUB -> 10; + case EXPEDITION_OFFICE, IMPOSSIBLE_SERVICE -> 12; + case COMMUNAL_DEPOT, VOID_DOCK, CONSTRUCTION_REFUGE -> 14; + case PUMP_STATION -> 18; case OBSERVATORY -> 20; case RADIO_RELAY -> 22; + }; + } + private static List requests(List sites, List landmarks) { + var result = new LinkedHashMap(); + var endpoints = new ArrayList(); + sites.forEach(s -> endpoints.add(new Endpoint(s.id(), s.entry(), false))); + landmarks.forEach(l -> endpoints.add(new Endpoint(l.id(), l.entry(), true))); + var freight = new HashSet(); + for (var kind : new Kind[] {Kind.FREIGHT_STATION, Kind.COMMUNAL_DEPOT, Kind.RAIL_WORKSHOP, Kind.PUMP_STATION}) { + var site = find(sites, kind); if (site != null) freight.add(site.id()); + } + landmarks.stream().filter(l -> l.kind() == LandmarkKind.MINE).forEach(l -> freight.add(l.id())); + for (var a : endpoints) { + // Four alternatives per entrance tolerate one protected cut without a quadratic terrain search. + var nearest = endpoints.stream().filter(b -> !a.id().equals(b.id())) + .sorted(Comparator.comparingInt((Endpoint b) -> span(a.entry(), b.entry())).thenComparing(Endpoint::id)).limit(4).toList(); + for (var b : nearest) addRequest(result, a, b, freight.contains(a.id()) && freight.contains(b.id()) ? RouteKind.FREIGHT : RouteKind.PATH); + } + for (var pair : List.of(new Kind[]{Kind.FREIGHT_STATION, Kind.COMMUNAL_DEPOT}, + new Kind[]{Kind.FREIGHT_STATION, Kind.RAIL_WORKSHOP}, new Kind[]{Kind.COMMUNAL_DEPOT, Kind.PUMP_STATION})) { + var a = find(sites, pair[0]); var b = find(sites, pair[1]); + if (a != null && b != null) addRequest(result, new Endpoint(a.id(), a.entry(), false), new Endpoint(b.id(), b.entry(), false), RouteKind.FREIGHT); + } + return List.copyOf(result.values()); + } + private static void addRequest(Map result, Endpoint a, Endpoint b, RouteKind kind) { + if (a.id().compareTo(b.id()) > 0) { var swap = a; a = b; b = swap; } + String key = a.id() + "__" + b.id(); var old = result.get(key); + if (old == null || kind == RouteKind.FREIGHT) result.put(key, new Request("graph_" + key, a.id(), b.id(), kind)); + } + private static String rejectionDelta(Map before, Map after, XZ obstacle) { + var changes = new java.util.TreeMap(); + after.forEach((key, count) -> { int difference = count - before.getOrDefault(key, 0); if (difference > 0) changes.put(key, difference); }); + return "; reasons=" + changes + (obstacle == null ? "" : "; last-column=" + obstacle.x() + "," + obstacle.z()); + } + private static int isolatedSites(Request request, Set siteIds, Set addressed) { + return (siteIds.contains(request.from()) && !addressed.contains(request.from()) ? 1 : 0) + + (siteIds.contains(request.to()) && !addressed.contains(request.to()) ? 1 : 0); + } + private static int isolatedAttempts(Request request, Set siteIds, Set addressed, Map attempts) { + int result = Integer.MAX_VALUE; + for (var id : List.of(request.from(), request.to())) if (siteIds.contains(id) && !addressed.contains(id)) + result = Math.min(result, attempts.getOrDefault(id, 0)); + return result == Integer.MAX_VALUE ? 0 : result; + } + private static final class Components { + final Map parents = new HashMap<>(); + Components(Set ids) { for (var id : ids) parents.put(id, id); } + String root(String id) { String parent = parents.get(id); while (!parent.equals(id)) { id = parent; parent = parents.get(id); } return parent; } + boolean same(String a, String b) { return root(a).equals(root(b)); } + void join(String a, String b) { parents.put(root(b), root(a)); } + } + + /** Always persisted, including for an isolated destination: five steps return to natural terrain. */ + private static List landing(Entry entry, List sites, Probe probe) { + int facing = entry.facing(), floor = entry.y() - 1; + int endX = entry.x() + DX[facing] * 4, endZ = entry.z() + DZ[facing] * 4; + int target = probe.surface(endX, endZ); + if (target < 14 || target > 350 || Math.abs(target - floor) > 3) return null; + var result = new ArrayList(); + for (int step = 0; step < 5; step++) { + // Keep the first two rows flat for the facade and the first rail turn. + int y = floor + (int) Math.round((target - floor) * Math.max(0, step - 1) / 3.0); + for (int cross = -1; cross <= 1; cross++) { + int x = entry.x() + DX[facing] * step + DZ[facing] * cross; + int z = entry.z() + DZ[facing] * step - DX[facing] * cross; + if (overlapsSites(x, z, x, z, sites)) return null; + Cell cell = routeCell(x, y, z, Rail.NONE, false, probe); + if (cell == null) return null; + result.add(cell); + if (step == 4) { + int beyond = probe.surface(x + DX[facing], z + DZ[facing]); + if (Math.abs(beyond - y) > 1 || !probe.solid(x + DX[facing], beyond, z + DZ[facing])) return null; + } + } + } + return List.copyOf(result); + } + + private static RouteDraft route(long seed, Request request, Endpoint from, Endpoint to, List sites, + List landmarks, Map existing, Probe probe) { + Entry a = from.entry(), b = to.entry(); + XZ start = new XZ(a.x(), a.z()), end = new XZ(b.x(), b.z()); + XZ outA = start.move(a.facing(), 6), outB = end.move(b.facing(), 6); + int middleX = (outA.x() + outB.x()) / 2, middleZ = (outA.z() + outB.z()) / 2; + var alternatives = List.of( + List.of(outA, new XZ(outB.x(), outA.z()), outB), + List.of(outA, new XZ(outA.x(), outB.z()), outB), + List.of(outA, new XZ(middleX, outA.z()), new XZ(middleX, outB.z()), outB), + List.of(outA, new XZ(outA.x(), middleZ), new XZ(outB.x(), middleZ), outB), + List.of(outA, new XZ(outA.x(), middleZ - 16), new XZ(outB.x(), middleZ - 16), outB), + List.of(outA, new XZ(middleX + 16, outA.z()), new XZ(middleX + 16, outB.z()), outB)); + for (var waypoints : alternatives) { + var points = new ArrayList(); points.add(start); + boolean okay = true; + for (var waypoint : waypoints) if (!line(points, waypoint)) { okay = false; break; } + if (!okay || !line(points, end) || points.size() > 1024 || new HashSet<>(points).size() != points.size()) continue; + RouteDraft result = profile(seed, request, from, to, points, sites, landmarks, existing, probe); + if (result != null) return result; + } + return adaptive(seed, request, from, to, start, end, outA, outB, sites, landmarks, existing, probe); + } + + /** Adaptive fallback. The coarse graph only proposes an alignment; it grants no writing permission. */ + private static RouteDraft adaptive(long seed, Request request, Endpoint from, Endpoint to, XZ start, XZ end, + XZ outA, XZ outB, List sites, List landmarks, Map existing, Probe probe) { + int stepSize = span(from.entry(), to.entry()) >= 160 ? 8 : 4; + int certificateReserve = Math.min(2_200, distance(outA, outB) * 3 + 80); + var excluded = new HashSet(); + for (int attempt = 0; attempt < 3; attempt++) { + var open = new java.util.PriorityQueue(Comparator.comparingDouble(SearchNode::priority) + .thenComparingDouble(SearchNode::cost).thenComparingInt(n -> n.position().x()).thenComparingInt(n -> n.position().z())); + var best = new HashMap(); var parents = new HashMap(); var closed = new HashSet(); + var terrainHeights = new HashMap(); + int begin = coarseRoute(outA, sites, terrainHeights, probe); + if (begin == INVALID) return null; + open.add(new SearchNode(outA, 0, distance(outA, outB))); best.put(outA, 0.0); + List found = null; + int searchColumns = Math.max(probe.columns.size() + 150, probe.columnLimit - certificateReserve); + while (!open.isEmpty() && closed.size() < 500 && probe.columns.size() < searchColumns) { + var current = open.remove(); XZ p = current.position(); + if (!closed.add(p) || current.cost() > best.getOrDefault(p, Double.POSITIVE_INFINITY)) continue; + if (distance(p, outB) <= stepSize * 2) { + var reversed = new ArrayList(); XZ ancestor = p; + while (ancestor != null) { reversed.add(ancestor); ancestor = parents.get(ancestor); } + java.util.Collections.reverse(reversed); + for (int order = 0; order < 2 && found == null; order++) { + var candidate = new ArrayList(); candidate.add(start); boolean valid = true; + for (XZ step : reversed) if (!line(candidate, step)) { valid = false; break; } + XZ turn = order == 0 ? new XZ(outB.x(), p.z()) : new XZ(p.x(), outB.z()); + if (valid && line(candidate, turn) && line(candidate, outB) && line(candidate, end) + && new HashSet<>(candidate).size() == candidate.size()) found = candidate; + } + if (found != null) break; + } + int top = terrainHeights.get(p); XZ parent = parents.get(p); + for (int d = 0; d < 4; d++) { + if (parent == null && d == ((from.entry().facing() + 2) & 3)) continue; + XZ next = p.move(d, stepSize); + if (closed.contains(next) || excluded.contains(next) + || Math.min(distance(next, outA), distance(next, outB)) > distance(outA, outB) + 64) continue; + int height = coarseRoute(next, sites, terrainHeights, probe); + if (height == INVALID || Math.abs(height - top) > stepSize + 3) continue; + double turnCost = parent == null || parent.x() == next.x() || parent.z() == next.z() ? 0 : 1.5; + double cost = current.cost() + stepSize + Math.abs(height - top) * 1.1 + turnCost; + if (cost >= best.getOrDefault(next, Double.POSITIVE_INFINITY)) continue; + best.put(next, cost); parents.put(next, p); + open.add(new SearchNode(next, cost, cost + distance(next, outB) * 1.6)); + } + } + if (found == null) { probe.reject("adaptive-no-alignment"); return null; } + RouteDraft certified = profile(seed, request, from, to, found, sites, landmarks, existing, probe); + if (certified != null) { probe.reject("adaptive-admitted"); return certified; } + if (probe.routeBlock == null) return null; + // A failed exact corridor removes only nearby coarse nodes on the next proposal. + XZ bad = probe.routeBlock; + int gx = outA.x() + (int) Math.round((bad.x() - outA.x()) / (double) stepSize) * stepSize; + int gz = outA.z() + (int) Math.round((bad.z() - outA.z()) / (double) stepSize) * stepSize; + excluded.add(new XZ(gx, gz)); + for (int d = 0; d < 4; d++) excluded.add(new XZ(gx, gz).move(d, stepSize)); + } + return null; + } + private record SearchNode(XZ position, double cost, double priority) {} + private static int distance(XZ a, XZ b) { return Math.abs(a.x() - b.x()) + Math.abs(a.z() - b.z()); } + private static int coarseRoute(XZ p, List sites, Map cache, Probe probe) { + Integer old = cache.get(p); if (old != null) return old; + if (overlapsSites(p.x() - 2, p.z() - 2, p.x() + 2, p.z() + 2, sites)) { cache.put(p, INVALID); return INVALID; } + int top = probe.surface(p.x(), p.z()); + if (top < 14 || top > 350 || !probe.solid(p.x(), top, p.z()) || probe.protectedAt(p.x(), top, p.z())) { + cache.put(p, INVALID); return INVALID; + } + for (int d = 0; d < 4; d++) { + XZ side = p.move(d, 1); int sideTop = probe.surface(side.x(), side.z()); + if (sideTop < 14 || sideTop > 350 || Math.abs(sideTop - top) > MAX_ROUTE_CUT + 1 || probe.protectedAt(side.x(), sideTop, side.z())) { + cache.put(p, INVALID); return INVALID; + } + } + cache.put(p, top); return top; + } + + /** Finite orthogonal alignments, each certified at full block resolution before publication. */ + private static boolean line(List points, XZ destination) { + XZ p = points.getLast(); + if (p.x() != destination.x() && p.z() != destination.z()) return false; + int dx = Integer.signum(destination.x() - p.x()), dz = Integer.signum(destination.z() - p.z()); + while (!p.equals(destination)) { + p = new XZ(p.x() + dx, p.z() + dz); points.add(p); + if (points.size() > 1024) return false; + } + return true; + } + + private static RouteDraft profile(long seed, Request request, Endpoint from, Endpoint to, List points, + List sites, List landmarks, Map existing, Probe probe) { + int size = points.size(); probe.routeBlock = null; + int[] lo = new int[size], hi = new int[size], desired = new int[size], group = new int[size]; + boolean[] flat = new boolean[size - 1]; + for (int i = 1; i < size - 1; i++) if (turn(points, i)) { flat[i - 1] = true; flat[i] = true; } + for (int i = 0; i < size; i++) { + XZ point = points.get(i); probe.routeBlock = point; lo[i] = 14; hi[i] = 350; + desired[i] = probe.surface(point.x(), point.z()); + boolean readOnly = i == 0 && from.readOnly() || i == size - 1 && to.readOnly(); + for (XZ column : strip(points, i)) { + if (overlapsSites(column.x(), column.z(), column.x(), column.z(), sites)) { probe.reject("route-building"); return null; } + int top = probe.surface(column.x(), column.z()); + if (top < 14 || top > 350 || !probe.solid(column.x(), top, column.z())) { probe.reject("route-dry-ground"); return null; } + lo[i] = Math.max(lo[i], top - MAX_ROUTE_CUT); hi[i] = Math.min(hi[i], top + MAX_ROUTE_FILL); + OwnedCell old = existing.get(key(column.x(), column.z())); + if (old != null) { lo[i] = Math.max(lo[i], old.cell().floorY()); hi[i] = Math.min(hi[i], old.cell().floorY()); } + if (readOnly && top != (i == 0 ? from.entry().y() : to.entry().y()) - 1) return null; + } + if (i == 0 || i == size - 1) { + int exact = (i == 0 ? from.entry().y() : to.entry().y()) - 1; + lo[i] = Math.max(lo[i], exact); hi[i] = Math.min(hi[i], exact); + } + if (lo[i] > hi[i]) { probe.reject("route-grade-interval"); return null; } + } + // Curves and both adjacent rails share one elevation; a Minecraft curved rail cannot slope. + int groups = 1; + for (int i = 1; i < size; i++) { if (!flat[i - 1]) groups++; group[i] = groups - 1; } + int[] gl = new int[groups], gh = new int[groups], gd = new int[groups], y = new int[groups]; + java.util.Arrays.fill(gl, 14); java.util.Arrays.fill(gh, 350); + for (int i = 0; i < size; i++) { int g = group[i]; gl[g] = Math.max(gl[g], lo[i]); gh[g] = Math.min(gh[g], hi[i]); gd[g] = desired[i]; } + for (int g = 1; g < groups; g++) { gl[g] = Math.max(gl[g], gl[g - 1] - 1); gh[g] = Math.min(gh[g], gh[g - 1] + 1); } + for (int g = groups - 2; g >= 0; g--) { gl[g] = Math.max(gl[g], gl[g + 1] - 1); gh[g] = Math.min(gh[g], gh[g + 1] + 1); } + for (int g = 0; g < groups; g++) { + if (gl[g] > gh[g]) { probe.reject("route-grade"); return null; } + int lower = g == 0 ? gl[g] : Math.max(gl[g], y[g - 1] - 1), upper = g == 0 ? gh[g] : Math.min(gh[g], y[g - 1] + 1); + y[g] = Math.clamp(gd[g], lower, upper); + } + var path = new ArrayList(); var floors = new LinkedHashMap(); + for (int i = 0; i < size; i++) { + XZ point = points.get(i); int floor = y[group[i]]; path.add(new Point(point.x(), floor, point.z())); + if (i == 0 && from.readOnly() || i == size - 1 && to.readOnly()) continue; + for (XZ column : strip(points, i)) { + long key = key(column.x(), column.z()); Integer old = floors.putIfAbsent(key, floor); + if (old != null && old != floor) { probe.reject("route-self-grade"); return null; } + // Historical endpoints and their lateral landing are read-only, even at another nearby turn. + for (var landmark : landmarks) if (landingColumn(landmark.entry(), column)) { + boolean actualEnd = from.readOnly() && landmark.id().equals(from.id()) + || to.readOnly() && landmark.id().equals(to.id()); + if (!actualEnd || floor != landmark.entry().y() - 1) { probe.reject("route-historical-entry"); return null; } + floors.remove(key); break; + } + } + } + var cells = new LinkedHashMap(); + for (var entry : floors.entrySet()) { + int x = (int) (entry.getKey() >> 32), z = (int) (long) entry.getKey(); + probe.routeBlock = new XZ(x, z); + Cell cell = routeCell(x, entry.getValue(), z, Rail.NONE, false, probe); + if (cell == null) { probe.reject("route-column-certificate"); return null; } + cells.put(entry.getKey(), cell); + } + if (request.kind() == RouteKind.FREIGHT) for (int i = 0; i < path.size(); i++) { + Point point = path.get(i); long key = key(point.x(), point.z()); Cell cell = cells.get(key); + if (cell == null) continue; + Rail rail = rail(path, i); + // A one-block valley cannot carry two ascending slopes on the same rail. Keep a repairable rail gap. + boolean intact = rail != Rail.NONE && Math.floorMod(mix(seed ^ key ^ request.id().hashCode()), 19) != 0; + if (rail == Rail.NONE) rail = points.get(Math.max(0, i - 1)).x() == points.get(Math.min(size - 1, i + 1)).x() ? Rail.NORTH_SOUTH : Rail.EAST_WEST; + cells.put(key, new Cell(cell.x(), cell.floorY(), cell.z(), cell.foundationDepth(), rail, intact)); + } + return new RouteDraft(List.copyOf(path), cells); + } + + private static boolean landingColumn(Entry entry, XZ column) { + int dx = column.x() - entry.x(), dz = column.z() - entry.z(); + return DX[entry.facing()] * dx + DZ[entry.facing()] * dz == 0 && Math.abs(dx) + Math.abs(dz) <= 1; + } + private static List strip(List points, int i) { + XZ p = points.get(i); var result = new ArrayList(); + if (turn(points, i)) { + for (int dx = -1; dx <= 1; dx++) for (int dz = -1; dz <= 1; dz++) result.add(new XZ(p.x() + dx, p.z() + dz)); + } else { + XZ other = points.get(i == points.size() - 1 ? i - 1 : i + 1); + int dx = Integer.signum(other.x() - p.x()), dz = Integer.signum(other.z() - p.z()); + for (int cross = -1; cross <= 1; cross++) result.add(new XZ(p.x() + dz * cross, p.z() - dx * cross)); + } + return result; + } + private static boolean turn(List points, int i) { + if (i == 0 || i == points.size() - 1) return false; + XZ a = points.get(i - 1), b = points.get(i + 1); + return a.x() != b.x() && a.z() != b.z(); + } + private static Rail rail(List path, int i) { + Point p = path.get(i), a = path.get(Math.max(0, i - 1)), b = path.get(Math.min(path.size() - 1, i + 1)); + if (a.floorY() > p.floorY() && b.floorY() > p.floorY()) return Rail.NONE; + Point high = a.floorY() > p.floorY() ? a : b.floorY() > p.floorY() ? b : null; + if (high != null) return switch (direction(p.x(), p.z(), high.x(), high.z())) { + case 0 -> Rail.ASCENDING_NORTH; case 1 -> Rail.ASCENDING_EAST; case 2 -> Rail.ASCENDING_SOUTH; default -> Rail.ASCENDING_WEST; + }; + if (a.x() == b.x()) return Rail.NORTH_SOUTH; + if (a.z() == b.z()) return Rail.EAST_WEST; + boolean n = a.z() < p.z() || b.z() < p.z(), s = a.z() > p.z() || b.z() > p.z(); + boolean e = a.x() > p.x() || b.x() > p.x(); + return n ? e ? Rail.NORTH_EAST : Rail.NORTH_WEST : s && e ? Rail.SOUTH_EAST : Rail.SOUTH_WEST; + } + private static Cell routeCell(int x, int floor, int z, Rail rail, boolean present, Probe probe) { + int top = probe.surface(x, z); + if (floor < 14 || floor > 350 || top < floor - MAX_ROUTE_FILL || top > floor + MAX_ROUTE_CUT) return null; + int foundation = Math.max(1, floor - top + 1), base = floor - foundation; + if (!probe.solid(x, base - 1, z) || !probe.solid(x, base, z)) return null; + for (int y = base - 1; y <= floor + 3; y++) if (probe.protectedAt(x, y, z)) return null; + return new Cell(x, floor, z, foundation, rail, present); + } + private record XZ(int x, int z) { XZ move(int facing, int distance) { return new XZ(x + DX[facing] * distance, z + DZ[facing] * distance); } } + private record SegmentKey(Request request, int cx, int cz) {} + private static List segments(long seed, Map cells) { + var groups = new LinkedHashMap>(); + for (var owned : cells.values()) { + Cell c = owned.cell(); var key = new SegmentKey(owned.request(), c.x() >> 4, c.z() >> 4); + groups.computeIfAbsent(key, ignored -> new ArrayList<>()).add(c); + } + var result = new ArrayList(); + for (var entry : groups.entrySet()) { + var k = entry.getKey(); var r = k.request(); + // IDs are stable, bounded and independent of native generation order. + String id = "piece_" + r.id() + "_" + k.cx() + "_" + k.cz(); + result.add(new Segment(id, r.from(), r.to(), r.kind(), k.cx(), k.cz(), entry.getValue(), mix(seed ^ key(k.cx(), k.cz()) ^ r.id().hashCode()))); + } + return List.copyOf(result); + } + + + private static int span(Entry a, Entry b) { return Math.max(Math.abs(a.x() - b.x()), Math.abs(a.z() - b.z())); } + private static Site find(List sites, Kind kind) { return sites.stream().filter(site -> site.kind() == kind).findFirst().orElse(null); } + private static String id(Kind kind) { return kind.name().toLowerCase(Locale.ROOT); } + private static int direction(int x, int z, int tx, int tz) { return Math.abs(tx - x) >= Math.abs(tz - z) ? tx > x ? 1 : 3 : tz > z ? 2 : 0; } + private static boolean overlapsSites(int x0, int z0, int x1, int z1, List sites) { + for (var site : sites) if (x0 <= site.maxX() && x1 >= site.minX() && z0 <= site.maxZ() && z1 >= site.minZ()) return true; + return false; + } + private record SiteDraft(Site site, List landings) {} + private record Candidate(int x, int z, int facing, int score, long seed) {} + private record Endpoint(String id, Entry entry, boolean readOnly) {} + private record Request(String id, String from, String to, RouteKind kind) {} + private record OwnedCell(Request request, Cell cell) {} + private record RouteDraft(List path, Map cells) {} + private static final class BudgetExceeded extends RuntimeException { + final boolean columns; BudgetExceeded(boolean columns) { this.columns = columns; } + } + private static final class Probe { + final Terrain terrain; final int cx, cz; final double radius; + final Map columns = new HashMap<>(); + XZ routeBlock; Map reasons = new HashMap<>(); int samples, columnLimit, sampleLimit; + Probe(Terrain terrain, int cx, int cz, int diameter) { this.terrain = java.util.Objects.requireNonNull(terrain); this.cx = cx; this.cz = cz; radius = diameter / 2.0 - 2; } + int surface(int x, int z) { + if (Math.hypot((double) x - cx, (double) z - cz) > radius || !terrain.owns(x, z)) return INVALID; + long key = key(x, z); var cached = columns.get(key); if (cached != null) return cached; + if (columns.size() >= columnLimit) throw new BudgetExceeded(true); + count(); int top = terrain.surfaceY(x, z); columns.put(key, top); return top; + } + boolean solid(int x, int y, int z) { if (y < 1 || y > 383 || surface(x, z) == INVALID) return false; count(); return terrain.solid(x, y, z); } + boolean protectedAt(int x, int y, int z) { if (y < 1 || y > 383 || surface(x, z) == INVALID) return true; count(); return terrain.protectedAt(x, y, z); } + void count() { if (samples >= sampleLimit) throw new BudgetExceeded(false); samples++; } + void reject(String reason) { reasons.merge(reason, 1, Integer::sum); } + } + 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 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/heritage/HeritageStructure19.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritageStructure19.java index 64e9137..5b00476 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritageStructure19.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritageStructure19.java @@ -14,7 +14,7 @@ public final class HeritageStructure19 extends Structure { @Override protected Optional findGenerationPoint(GenerationContext context) { if (!(context.chunkGenerator() instanceof SanctuaryChunkGenerator generator) - || (generator.rootGeneration() != 19 && generator.rootGeneration() != 20 && generator.rootGeneration() != 21) || !generator.experimentalStructures()) return Optional.empty(); + || (generator.rootGeneration() != 19 && generator.rootGeneration() != 20 && generator.rootGeneration() != 21 && generator.rootGeneration() != 22) || !generator.experimentalStructures()) return Optional.empty(); var island = generator.islandForChunk(context.chunkPos().x(), context.chunkPos().z()); if (island == null || !island.origin() || (island.generation() != generator.rootGeneration())) return Optional.empty(); var plan = generator.heritagePlan19(); @@ -23,9 +23,9 @@ public final class HeritageStructure19 extends Structure { // Local route segments render before buildings. No piece needs a distant link endpoint loaded. for (var segment : plan.segments()) if (segment.startChunkX() == x && segment.startChunkZ() == z) - pieces.add(generator.rootGeneration() == 21 ? new HeritagePiece21(segment) : generator.rootGeneration() == 20 ? new HeritagePiece20(segment) : new HeritagePiece19(segment)); + pieces.add((generator.rootGeneration() == 21 || generator.rootGeneration() == 22) ? new HeritagePiece21(segment) : generator.rootGeneration() == 20 ? new HeritagePiece20(segment) : new HeritagePiece19(segment)); for (var site : plan.sites()) if (site.startChunkX() == x && site.startChunkZ() == z) - pieces.add(generator.rootGeneration() == 21 ? new HeritagePiece21(site) : generator.rootGeneration() == 20 ? new HeritagePiece20(site) : new HeritagePiece19(site)); + pieces.add((generator.rootGeneration() == 21 || generator.rootGeneration() == 22) ? new HeritagePiece21(site) : generator.rootGeneration() == 20 ? new HeritagePiece20(site) : new HeritagePiece19(site)); if (pieces.isEmpty()) return Optional.empty(); for (var piece : pieces) { var box = piece.getBoundingBox(); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritageStructures19.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritageStructures19.java index 3cc3ad5..97db05c 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritageStructures19.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritageStructures19.java @@ -57,7 +57,7 @@ public final class HeritageStructures19 { ChunkGeneratorStructureState state, StructureManager manager, ChunkAccess chunk, StructureTemplateManager templates, ResourceKey level) { if (!Level.OVERWORLD.equals(level) || !manager.shouldGenerateStructures() - || !(generator instanceof SanctuaryChunkGenerator sanctuary) || (generator.rootGeneration() != 19 && generator.rootGeneration() != 20 && generator.rootGeneration() != 21) + || !(generator instanceof SanctuaryChunkGenerator sanctuary) || (generator.rootGeneration() != 19 && generator.rootGeneration() != 20 && generator.rootGeneration() != 21 && generator.rootGeneration() != 22) || !sanctuary.experimentalStructures()) return; var island = generator.islandForChunk(chunk.getPos().x(), chunk.getPos().z()); if (island == null || !island.origin() || island.generation() != generator.rootGeneration()) return; @@ -79,7 +79,7 @@ public final class HeritageStructures19 { BlockPos origin, int radius, boolean skipReferenced) { if (!Level.OVERWORLD.equals(level.dimension()) || !(level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator generator) - || (generator.rootGeneration() != 19 && generator.rootGeneration() != 20 && generator.rootGeneration() != 21) || !generator.experimentalStructures() + || (generator.rootGeneration() != 19 && generator.rootGeneration() != 20 && generator.rootGeneration() != 21 && generator.rootGeneration() != 22) || !generator.experimentalStructures() || !level.structureManager().shouldGenerateStructures()) return null; Holder holder = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(KEY); if (!structures.contains(holder)) return null; diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsStructure17.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsStructure17.java index 4719ab7..8531294 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsStructure17.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsStructure17.java @@ -13,7 +13,7 @@ public final class RuinsStructure17 extends Structure { @Override protected Optional findGenerationPoint(GenerationContext context) { if (!(context.chunkGenerator() instanceof SanctuaryChunkGenerator generator) - || (generator.rootGeneration() != 17 && generator.rootGeneration() != 19 && generator.rootGeneration() != 20 && generator.rootGeneration() != 21) || !generator.experimentalStructures()) return Optional.empty(); + || (generator.rootGeneration() != 17 && generator.rootGeneration() != 19 && generator.rootGeneration() != 20 && generator.rootGeneration() != 21 && generator.rootGeneration() != 22) || !generator.experimentalStructures()) return Optional.empty(); var island = generator.islandForChunk(context.chunkPos().x(), context.chunkPos().z()); if (island == null || !island.origin() || island.generation() != generator.rootGeneration()) return Optional.empty(); var networks = generator.ruinsPlan17().networks().stream() @@ -30,9 +30,9 @@ public final class RuinsStructure17 extends Structure { return Optional.of(new GenerationStub(RuinsStructures17.locator(networks.getFirst()), pieces -> { // Shafts come last so their surface hatches cannot be filled by a connected room. networks.forEach(n -> n.rooms().stream().filter(r -> r.kind() != RuinsPlan17.Kind.ACCESS) - .forEach(r -> pieces.addPiece((generator.rootGeneration() == 20 || generator.rootGeneration() == 21) ? new RuinsPiece20(r) : new RuinsPiece17(r)))); + .forEach(r -> pieces.addPiece((generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22) ? new RuinsPiece20(r) : new RuinsPiece17(r)))); networks.forEach(n -> n.rooms().stream().filter(r -> r.kind() == RuinsPlan17.Kind.ACCESS) - .forEach(r -> pieces.addPiece((generator.rootGeneration() == 20 || generator.rootGeneration() == 21) ? new RuinsPiece20(r) : new RuinsPiece17(r)))); + .forEach(r -> pieces.addPiece((generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22) ? new RuinsPiece20(r) : new RuinsPiece17(r)))); })); } @Override public StructureType type() { return RuinsStructures17.TYPE; } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsStructures17.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsStructures17.java index 8d359f1..9e2cef6 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsStructures17.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsStructures17.java @@ -55,7 +55,7 @@ public final class RuinsStructures17 { ChunkGeneratorStructureState state, StructureManager manager, ChunkAccess chunk, StructureTemplateManager templates, ResourceKey level) { if (!Level.OVERWORLD.equals(level) || !manager.shouldGenerateStructures() - || !(generator instanceof SanctuaryChunkGenerator sanctuary) || (generator.rootGeneration() != 17 && generator.rootGeneration() != 19 && generator.rootGeneration() != 20 && generator.rootGeneration() != 21) + || !(generator instanceof SanctuaryChunkGenerator sanctuary) || (generator.rootGeneration() != 17 && generator.rootGeneration() != 19 && generator.rootGeneration() != 20 && generator.rootGeneration() != 21 && generator.rootGeneration() != 22) || !sanctuary.experimentalStructures()) return; var island = generator.islandForChunk(chunk.getPos().x(), chunk.getPos().z()); if (island == null || !island.origin() || island.generation() != generator.rootGeneration()) return; @@ -78,7 +78,7 @@ public final class RuinsStructures17 { BlockPos origin, int radius, boolean skipReferenced) { if (!Level.OVERWORLD.equals(level.dimension()) || !(level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator generator) - || (generator.rootGeneration() != 17 && generator.rootGeneration() != 19 && generator.rootGeneration() != 20 && generator.rootGeneration() != 21) || !generator.experimentalStructures() + || (generator.rootGeneration() != 17 && generator.rootGeneration() != 19 && generator.rootGeneration() != 20 && generator.rootGeneration() != 21 && generator.rootGeneration() != 22) || !generator.experimentalStructures() || !level.structureManager().shouldGenerateStructures()) return null; Holder holder = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(KEY); if (!structures.contains(holder)) return null; diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitPiece22.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitPiece22.java new file mode 100644 index 0000000..444eb43 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitPiece22.java @@ -0,0 +1,52 @@ +package fr.koka.sanctuary.worldgen.transit; + +import fr.koka.sanctuary.worldgen.city.LostCityRenderer; +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; + +/** Distinct native piece ID selects the corrected22 corner without migrating any saved21 piece. */ +public final class TransitPiece22 extends StructurePiece { + private final TransitPlan21.Module room; + public TransitPiece22(TransitPlan21.Module room) { + super(TransitStructures21.PIECE22, 0, TransitPiece21.bounds(room)); + this.room = room; setOrientation(null); + } + public TransitPiece22(StructurePieceSerializationContext context, CompoundTag tag) { + super(TransitStructures21.PIECE22, tag); + room = TransitPiece21.decodeModule(tag.getCompound("SanctuaryModule") + .orElseThrow(() -> new IllegalArgumentException("Missing saved transit22 room"))); + if (!getBoundingBox().equals(TransitPiece21.bounds(room))) + throw new IllegalArgumentException("Transit22 room disagrees with its saved native bounding box"); + setOrientation(null); + } + public TransitPlan21.Module module() { return room; } + @Override public BlockPos getLocatorPosition() { return TransitPiece21.locator(room); } + @Override protected void addAdditionalSaveData(StructurePieceSerializationContext context, CompoundTag tag) { + tag.put("SanctuaryModule", TransitPiece21.encodeModule(room)); + } + @Override public void postProcess(WorldGenLevel level, StructureManager manager, ChunkGenerator generator, + RandomSource random, BoundingBox clip, ChunkPos chunk, BlockPos pivot) { + TransitStructures21.placing(() -> TransitRenderer22.render(room, new LostCityRenderer.BlockWriter() { + private BlockPos world(int x, int y, int z) { return new BlockPos(room.minX() + x, room.minY() + y, room.minZ() + z); } + @Override public void set(int x, int y, int z, BlockState block) { + var pos = world(x, y, z); + if (getBoundingBox().isInside(pos) && clip.isInside(pos)) placeBlock(level, block, pos.getX(), pos.getY(), pos.getZ(), clip); + } + @Override public void chest(int x, int y, int z, ResourceKey loot, long seed) { + var pos = world(x, y, z); + if (getBoundingBox().isInside(pos) && clip.isInside(pos)) createChest(level, clip, RandomSource.create(seed), pos, loot, null); + } + })); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitPlanner22.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitPlanner22.java new file mode 100644 index 0000000..de7944e --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitPlanner22.java @@ -0,0 +1,13 @@ +package fr.koka.sanctuary.worldgen.transit; + +/** The main traversal is core infrastructure in22. Reserve it before optional networks and sites. + * Its established civil engineering may bridge a short cavity with certified bearings; the stricter + * surface-building policy must not remove this underground destination. No terrain is generated here. */ +public final class TransitPlanner22 { + public static final int MAX_COLUMNS = TransitPlanner21.MAX_COLUMNS; + public static final int MAX_PIER = 16, MAX_WEAK_SPAN = 34; + private TransitPlanner22() {} + public static TransitPlan21 plan(long seed, int centerX, int centerZ, int diameter, TransitPlanner21.Terrain terrain) { + return TransitPlanner21.plan(seed, centerX, centerZ, diameter, terrain); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitRenderer22.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitRenderer22.java new file mode 100644 index 0000000..d717d4e --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitRenderer22.java @@ -0,0 +1,24 @@ +package fr.koka.sanctuary.worldgen.transit; + +import fr.koka.sanctuary.worldgen.city.LostCityRenderer; +import fr.koka.sanctuary.worldgen.detail.ConnectedBlocks20; +import net.minecraft.world.level.block.Blocks; + +/** New22 corners retain complete walls on sides that have no saved opening. + * Existing21 pieces keep their historical paint sequence and geometry. */ +public final class TransitRenderer22 { + private TransitRenderer22() {} + public static void render(TransitPlan21.Module module, LostCityRenderer.BlockWriter output) { + if (module.kind() != TransitPlan21.Kind.JUNCTION) { TransitRenderer21.render(module, output); return; } + var buffer = ConnectedBlocks20.buffer(output); + TransitRenderer21.render(module, buffer); + // The roadway crossing and its two slab curbs must stop at an unopened wall. + for (int direction = 0; direction < 4; direction++) if ((module.openings() & 1 << direction) == 0) + for (int across = -3; across <= 3; across++) { + int x = direction == 1 ? module.width() - 1 : direction == 3 ? 0 : module.width() / 2 + across; + int z = direction == 0 ? 0 : direction == 2 ? module.depth() - 1 : module.depth() / 2 + across; + buffer.set(x, 2, z, Blocks.TUFF_BRICKS.defaultBlockState()); + } + buffer.flush(); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitRoute22.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitRoute22.java new file mode 100644 index 0000000..1b3d1d9 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitRoute22.java @@ -0,0 +1,236 @@ +package fr.koka.sanctuary.worldgen.transit; + +import fr.koka.sanctuary.worldgen.transit.TransitPlan21.Kind; +import fr.koka.sanctuary.worldgen.transit.TransitPlan21.Module; +import fr.koka.sanctuary.worldgen.transit.TransitPlan21.Support; +import fr.koka.sanctuary.worldgen.transit.TransitPlanner21.Terrain; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** Generation22 fallback alignments around protected volumes. It is called with a fresh terrain + * sampler after the historical straight-route search fails. No partial route is ever published. */ +public final class TransitRoute22 { + public static final int MAX_COLUMNS = 12_000; + private TransitRoute22() {} + public static TransitPlan21 plan(long seed, int cx, int cz, int diameter, Terrain terrain) { + if (!List.of(512, 724, 1024).contains(diameter)) throw new IllegalArgumentException("Unsupported transit detour diameter"); + var probe = new Probe(terrain); + var choices = new ArrayList(); String last = "no supported protected-volume detour"; + int farRadius = (int) Math.ceil(diameter * .21 / 17) * 17; + int minimumRadius = (int) Math.ceil(diameter / 8.0 / 17) * 17; + var radii = new java.util.LinkedHashSet(List.of(Math.max(minimumRadius, farRadius - 34), minimumRadius, farRadius)); + // Search actual entrance pairs before repeatedly testing their identical bad porticos at seven depths. + // Fine translations also remove the previous assumption that every entrance must lie on x/z=17n. + int[][] offsets = {{0, 0}, {-17, 0}, {17, 0}, {0, -17}, {0, 17}, {-8, -8}, {8, 8}, {-8, 8}, {8, -8}, + {-34, 0}, {34, 0}, {0, -34}, {0, 34}, {-34, -34}, {34, 34}, {-34, 34}, {34, -34}}; + probe.columnLimit = 5_500; probe.sampleLimit = 1_200_000; + int alignments = 0; + try { + search: for (var offset : offsets) for (int radius : radii) for (int shape = 0; shape < 8; shape++) + for (boolean alongX : new boolean[]{true, false}) { + var line = alignment(cx + offset[0], cz + offset[1], radius, 0, shape, alongX); + alignments++; + int firstTop = approachTop(line.getFirst(), probe), lastTop = approachTop(line.getLast(), probe); + String geometry = "shape=" + shape + " axis=" + (alongX ? "east-west" : "north-south") + + " radius=" + radius + " translation=" + offset[0] + "," + offset[1]; + if (firstTop < 0 || lastTop < 0) { last = "no suitable pair of natural porticos"; diagnostic("portico reject " + geometry); continue; } + int selectedAtGeometry = 0; + for (int floor : floors(firstTop, lastTop)) { + var route = candidate(seed, line, floor, probe); + String label = geometry + " floor=" + floor; + if (route == null) { last = probe.reason; diagnostic("reject " + label + " " + last + " columns=" + probe.columns.size()); continue; } + int cost = route.stream().mapToInt(m -> m.supports().stream().mapToInt(p -> p.maxY() - p.minY() + 1).sum() + + (m.kind() == Kind.ACCESS ? m.surfaceY() - m.floorY() : 0)).sum(); + choices.add(new Choice(route, cost + line.size() * 4, label)); + diagnostic("preselected " + label + " modules=" + route.size()); + if (choices.size() >= 8) break search; + // Keep alternative footprints available if exact hydrology rejects one alignment. + if (++selectedAtGeometry >= 2) break; + } + } + } catch (BudgetExceeded exhausted) { diagnostic("prescreen budget; retain " + choices.size()); } + probe.columnLimit = MAX_COLUMNS; probe.sampleLimit = TransitPlan21.MAX_SAMPLES; + choices.sort(java.util.Comparator.comparingInt(Choice::cost)); + try { + for (var choice : choices) { + var modules = new ArrayList(); + for (var rough : choice.modules()) { + var complete = certify(rough, probe, true); + if (complete == null) { last = probe.reason; break; } + modules.add(complete); + } + if (modules.size() != choice.modules().size()) { diagnostic("exact reject " + choice.label() + " " + last); continue; } + int span = Math.max(Math.abs(modules.getFirst().centerX() - modules.getLast().centerX()), + Math.abs(modules.getFirst().centerZ() - modules.getLast().centerZ())); + return new TransitPlan21(modules, probe.samples, "accepted detour " + choice.label() + " span=" + span + + " columns=" + probe.columns.size() + " bends=" + modules.stream().filter(m -> m.kind() == Kind.JUNCTION).count()); + } + } catch (BudgetExceeded exhausted) { last = "bounded detour search exhausted: " + probe.reason; } + return new TransitPlan21(List.of(), probe.samples, last + "; candidates=" + choices.size() + "; alignments=" + alignments + "; columns=" + probe.columns.size()); + } + private static List floors(int a, int b) { + int lowerTop = Math.min(a, b), upperTop = Math.max(a, b); + var result = new java.util.LinkedHashSet(); + for (int y : new int[]{lowerTop - 22, lowerTop - 38, lowerTop - 54, lowerTop - 70, 216, 200, 184, 160}) + if (y >= 32 && y >= upperTop - 145 && y <= lowerTop - 18) result.add(y); + return List.copyOf(result); + } + /** Height-independent portion of the existing21 portico certificate. Failure is cached per entrance. */ + private static int approachTop(Point point, Probe probe) { + Integer old = probe.approaches.get(point); if (old != null) return old; + int top = -1, low = 384; + for (int x = point.x() - 2; x <= point.x() + 2; x++) for (int z = point.z() - 11; z <= point.z() - 9; z++) { + int y = probe.surface(x, z); low = Math.min(low, y); top = Math.max(top, y); + } + boolean valid = low >= 0 && top <= 378 && top - low <= 1; + if (valid) for (int x = point.x() - 2; x <= point.x() + 2; x++) for (int z = point.z() - 11; z <= point.z() - 9; z++) { + int y = probe.surface(x, z); + if (probe.protectedAt(x, y, z) || probe.protectedAt(x, top + 1, z) || probe.protectedAt(x, top + 2, z)) valid = false; + } + if (valid) for (int x : new int[]{point.x() - 8, point.x(), point.x() + 8}) + for (int z : new int[]{point.z() - 8, point.z(), point.z() + 8}) { + int y = probe.surface(x, z); if (y < top - 24 || y > top + 16) valid = false; + } + probe.approaches.put(point, valid ? top : -1); + return valid ? top : -1; + } + private record Choice(List modules, int cost, String label) {} + private record Point(int x, int z) {} + private static List alignment(int cx, int cz, int radius, int shift, int shape, boolean alongX) { + var points = new ArrayList(); + if (shape < 6) { + int offset = (shape / 2 + 1) * 34 * ((shape & 1) == 0 ? -1 : 1); + points.add(new Point(-radius, 0)); line(points, -radius, offset); line(points, radius, offset); line(points, radius, 0); + } else { + int offset = 68; + points.add(new Point(-radius, -offset)); + if (shape == 6) line(points, radius, -offset); else line(points, -radius, offset); + line(points, radius, offset); + } + return points.stream().map(p -> new Point(cx + (alongX ? p.x() : p.z() + shift), + cz + (alongX ? p.z() + shift : p.x()))).toList(); + } + private static void line(List points, int x, int z) { + var p = points.getLast(); int dx = Integer.signum(x - p.x()) * 17, dz = Integer.signum(z - p.z()) * 17; + while (p.x() != x || p.z() != z) { p = new Point(p.x() + dx, p.z() + dz); points.add(p); } + } + private static int direction(Point from, Point to) { + return from.x() == to.x() ? to.z() > from.z() ? 2 : 0 : to.x() > from.x() ? 1 : 3; + } + private static List candidate(long seed, List points, int floor, Probe probe) { + // A sparse route-body test rejects a wrong alignment before full shafts and slabs are sampled. + for (var p : points) for (int dx : new int[]{-4, 0, 4}) for (int dz : new int[]{-4, 0, 4}) + for (int y : new int[]{floor - 2, floor, floor + 4, floor + 8}) if (probe.protectedAt(p.x() + dx, y, p.z() + dz)) { + probe.reason = "protected alignment [" + p.x() + "," + y + "," + p.z() + "]"; return null; + } + var first = points.getFirst(); var last = points.getLast(); + var entrance = access(first.x(), first.z(), floor, 1 << direction(first, points.get(1)), seed ^ 0x2201L, probe, false); + if (entrance == null) return null; + var exit = access(last.x(), last.z(), floor, 1 << direction(last, points.get(points.size() - 2)), seed ^ 0x2202L, probe, false); + if (exit == null) return null; + var result = new ArrayList(); result.add(entrance); int unanchored = 0; + for (int i = 1; i < points.size() - 1; i++) { + var p = points.get(i); int a = direction(p, points.get(i - 1)), b = direction(p, points.get(i + 1)); + boolean bend = ((a + 2) & 3) != b, alongX = a == 1 || a == 3; + int width = bend || alongX ? 17 : 9, depth = bend || !alongX ? 17 : 9; + var module = certify(new Module(bend ? Kind.JUNCTION : Kind.GALLERY, p.x() - width / 2, floor - 1, + p.z() - depth / 2, width, 10, depth, alongX ? 1 : 0, (1 << a) | (1 << b), + seed ^ (i * 0x9E3779B97F4A7C15L), List.of()), probe, false); + if (module == null) return null; + unanchored = probe.bearings.get(module) == 15 ? 0 : unanchored + 1; + if (unanchored > 2) { probe.reason = "unsupported detour span exceeds 34 blocks"; return null; } + result.add(module); + } + result.add(exit); + return List.copyOf(result); + } + private static Module access(int cx, int cz, int floor, int opening, long seed, Probe probe, boolean full) { + int top = -1, low = 384; + for (int x = cx - 2; x <= cx + 2; x++) for (int z = cz - 11; z <= cz - 9; z++) { + int surface = probe.surface(x, z); top = Math.max(top, surface); low = Math.min(low, surface); + } + if (low < 0 || top - low > 1) { probe.reason = "access approach relief [" + cx + "," + cz + "] " + low + ".." + top; return null; } + if (top < floor + 18 || top > floor + 145) { probe.reason = "surface access height"; return null; } + // Its visible north portico meets real ground; its three-wide approach is never an unsupported threshold. + for (int x = cx - 2; x <= cx + 2; x++) for (int z = cz - 11; z <= cz - 9; z++) { + int y = probe.surface(x, z); + if (y < top - 1 || y > top || probe.protectedAt(x, y, z) + || probe.protectedAt(x, top + 1, z) || probe.protectedAt(x, top + 2, z)) { + probe.reason = "access approach [" + x + "," + top + "," + z + "] terrain=" + y; return null; + } + } + for (int x : new int[]{cx - 8, cx, cx + 8}) for (int z : new int[]{cz - 8, cz, cz + 8}) { + int y = probe.surface(x, z); + // The portico alone needs level terrain. A buried shaft can follow a hillside; + // its local walls and checked footings carry the deck without flattening the hill. + if (y < Math.max(floor + 8, top - 24) || y > top + 16) { probe.reason = "access hillside [" + x + "," + top + "," + z + "] terrain=" + y; return null; } + } + return certify(new Module(Kind.ACCESS, cx - 8, floor - 1, cz - 8, 17, + top - (floor - 1) + 5, 17, 0, opening, seed, List.of()), probe, full); + } + private static Module certify(Module module, Probe probe, boolean full) { + var supports = new ArrayList(); + int bearingQuadrants = 0; + for (int z = module.minZ();; z = Math.min(z + 4, module.maxZ())) { + for (int x = module.minX();; x = Math.min(x + 4, module.maxX())) { + if (!probe.owns(x, z)) { probe.reason = "outside island"; return null; } + if (!full) for (int y = module.minY(); y <= module.maxY(); y += 4) + if (probe.protectedAt(x, y, z)) { + probe.reason = "coarse " + module.kind() + " [" + x + "," + y + "," + z + "] " + probe.terrain.obstruction(x, y, z); return null; + } + int anchor = module.minY() - 1; + while (anchor >= module.minY() - 17 && !(probe.solid(x, anchor, z) && probe.solid(x, anchor - 1, z))) anchor--; + if (anchor >= module.minY() - 17) { + for (int y = anchor - 1; y < module.minY(); y++) if (probe.protectedAt(x, y, z)) { + probe.reason = "protected footing"; return null; + } + if (anchor < module.minY() - 1) supports.add(new Support(x, anchor + 1, z, module.minY() - 1)); + bearingQuadrants |= 1 << ((x < module.centerX() ? 0 : 1) + (z < module.centerZ() ? 0 : 2)); + } + if (x == module.maxX()) break; + } + if (z == module.maxZ()) break; + } + // The continuous two-block deck can span gaps between its bearings. Require + // untouched rock in each quarter, not a pier at every point above a cavity. + if (bearingQuadrants != 15 && module.kind() != Kind.GALLERY) { probe.reason = "unsupported quarters " + module.kind() + + " [" + module.minX() + "," + module.minY() + "," + module.minZ() + "] mask=" + bearingQuadrants; return null; } + if (full) { + // Hydrology and all previous structures are checked at every edited voxel after the cheap footing test. + for (int z = module.minZ(); z <= module.maxZ(); z++) for (int x = module.minX(); x <= module.maxX(); x++) { + if (!probe.owns(x, z)) { probe.reason = "outside island"; return null; } + for (int y = module.minY() - 2; y <= module.maxY(); y++) if (probe.protectedAt(x, y, z)) { + probe.reason = "volume " + module.kind() + " [" + x + "," + y + "," + z + "] " + probe.terrain.obstruction(x, y, z); return null; + } + } + } + var certified = new Module(module.kind(), module.minX(), module.minY(), module.minZ(), module.width(), module.height(), + module.depth(), module.facing(), module.openings(), module.seed(), supports); + probe.bearings.put(certified, bearingQuadrants); + return certified; + } + private static void diagnostic(String message) { + if (Boolean.getBoolean("sanctuary.test.transit22") || Boolean.getBoolean("sanctuary.transit.plannerDiagnostics")) + System.getLogger(TransitRoute22.class.getName()).log(System.Logger.Level.INFO, "TransitRoute22 " + message); + } + private static final class Probe { + final Terrain terrain; final Set columns = new HashSet<>(); int samples; int sampleLimit = TransitPlan21.MAX_SAMPLES, columnLimit = MAX_COLUMNS; String reason = "budget"; + final java.util.Map bearings = new java.util.HashMap<>(); + final java.util.Map approaches = new java.util.HashMap<>(); + Probe(Terrain terrain) { this.terrain = terrain; } + void visit(int x, int z) { + if (samples >= sampleLimit) throw new BudgetExceeded(); + samples++; + long key = (long) x << 32 ^ z & 0xffffffffL; + if (!columns.contains(key) && columns.size() >= columnLimit) throw new BudgetExceeded(); + columns.add(key); + } + boolean owns(int x, int z) { visit(x, z); return terrain.owns(x, z); } + boolean solid(int x, int y, int z) { visit(x, z); return terrain.solid(x, y, z); } + boolean protectedAt(int x, int y, int z) { visit(x, z); return terrain.protectedAt(x, y, z); } + int surface(int x, int z) { visit(x, z); return terrain.surfaceY(x, z); } + } + private static final class BudgetExceeded extends RuntimeException { private static final long serialVersionUID = 1L; } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitStructure21.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitStructure21.java index 8cff9a7..7ff302f 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitStructure21.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitStructure21.java @@ -12,9 +12,9 @@ public final class TransitStructure21 extends Structure { public TransitStructure21(StructureSettings settings) { super(settings); } @Override protected Optional findGenerationPoint(GenerationContext context) { if (!(context.chunkGenerator() instanceof SanctuaryChunkGenerator generator) - || generator.rootGeneration() != 21 || !generator.experimentalStructures()) return Optional.empty(); + || (generator.rootGeneration() != 21 && generator.rootGeneration() != 22) || generator.rootGeneration() != 22 && !generator.experimentalStructures()) return Optional.empty(); var island = generator.islandForChunk(context.chunkPos().x(), context.chunkPos().z()); - if (island == null || !island.origin() || island.generation() != 21) return Optional.empty(); + if (island == null || !island.origin() || (island.generation() != 21 && island.generation() != 22)) return Optional.empty(); var modules = generator.transitPlan21().modulesForChunk(context.chunkPos().x(), context.chunkPos().z()); if (modules.isEmpty()) return Optional.empty(); for (var module : modules) { @@ -25,7 +25,9 @@ public final class TransitStructure21 extends Structure { throw new IllegalStateException("Transit module exceeds local native reference range"); } return Optional.of(new GenerationStub(TransitPiece21.locator(modules.getFirst()), pieces -> - modules.forEach(module -> pieces.addPiece(new TransitPiece21(module))))); + modules.forEach(module -> pieces.addPiece(generator.rootGeneration() == 22 + && module.kind() == TransitPlan21.Kind.JUNCTION && Integer.bitCount(module.openings()) == 2 + ? new TransitPiece22(module) : new TransitPiece21(module))))); } @Override public StructureType type() { return TransitStructures21.TYPE; } } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitStructures21.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitStructures21.java index 9240018..a0c0391 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitStructures21.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitStructures21.java @@ -32,6 +32,8 @@ public final class TransitStructures21 { SanctuaryMod.id("industrial_transit_v21"), () -> TransitStructure21.CODEC); public static final StructurePieceType PIECE = Registry.register(BuiltInRegistries.STRUCTURE_PIECE, SanctuaryMod.id("industrial_transit_module_v21"), TransitPiece21::new); + public static final StructurePieceType PIECE22 = Registry.register(BuiltInRegistries.STRUCTURE_PIECE, + SanctuaryMod.id("industrial_transit_module_v22"), TransitPiece22::new); private static final ThreadLocal PLACEMENT_DEPTH = ThreadLocal.withInitial(() -> 0); private TransitStructures21() {} public static void register() {} @@ -44,10 +46,10 @@ public final class TransitStructures21 { ChunkGeneratorStructureState state, StructureManager manager, ChunkAccess chunk, StructureTemplateManager templates, ResourceKey level) { if (!Level.OVERWORLD.equals(level) || !manager.shouldGenerateStructures() - || !(generator instanceof SanctuaryChunkGenerator sanctuary) || generator.rootGeneration() != 21 - || !sanctuary.experimentalStructures()) return; + || !(generator instanceof SanctuaryChunkGenerator sanctuary) || (generator.rootGeneration() != 21 && generator.rootGeneration() != 22) + || generator.rootGeneration() != 22 && !sanctuary.experimentalStructures()) return; var island = generator.islandForChunk(chunk.getPos().x(), chunk.getPos().z()); - if (island == null || !island.origin() || island.generation() != 21) return; + if (island == null || !island.origin() || (island.generation() != 21 && island.generation() != 22)) return; if (sanctuary.transitPlan21().modulesForChunk(chunk.getPos().x(), chunk.getPos().z()).isEmpty()) return; Holder holder = registries.lookupOrThrow(Registries.STRUCTURE).getOrThrow(KEY); StructureStart previous = manager.getStartForStructure(holder.value(), chunk); @@ -64,7 +66,7 @@ public final class TransitStructures21 { BlockPos origin, int radius, boolean skipReferenced) { if (!Level.OVERWORLD.equals(level.dimension()) || !(level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator generator) - || generator.rootGeneration() != 21 || !generator.experimentalStructures() + || (generator.rootGeneration() != 21 && generator.rootGeneration() != 22) || generator.rootGeneration() != 22 && !generator.experimentalStructures() || !level.structureManager().shouldGenerateStructures()) return null; Holder holder = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(KEY); if (!structures.contains(holder)) return null; 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 db14aaf..471d908 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 @@ -128,6 +128,8 @@ "sanctuary.world_options.experimental": "Experimental structures", "sanctuary.world_options.experimental_hint": "Mines, strange rooms and abandoned facilities. This choice is saved with the new world.", "structure.sanctuary.forgotten_networks": "Forgotten networks", + "sanctuary.underground_hall.entry": "Underground hall entrance: %s, %s, %s", + "sanctuary.world_options.heritage22_hint": "Adds experimental buildings and networks. The main traversal and underground hall belong to the island and remain without this option.", "sanctuary.world_options.heritage_hint": "Adds forgotten networks, buildings and abandoned routes to the new island. This choice is saved with the world.", "structure.sanctuary.heritage_networks": "Abandoned infrastructure", "sanctuary.heritage.kind.freight_station": "Freight station", 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 d41a76a..5afd471 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 @@ -128,6 +128,8 @@ "sanctuary.world_options.experimental": "Structures expérimentales", "sanctuary.world_options.experimental_hint": "Mines, salles étranges et installations abandonnées. Ce choix reste attaché au nouveau monde.", "structure.sanctuary.forgotten_networks": "Réseaux oubliés", + "sanctuary.underground_hall.entry": "Accès à la salle souterraine : %s, %s, %s", + "sanctuary.world_options.heritage22_hint": "Ajoute les bâtiments et réseaux expérimentaux. La grande traversée et la salle souterraine font partie de l’île et restent présentes sans cette option.", "sanctuary.world_options.heritage_hint": "Ajoute les anciens réseaux, bâtiments et voies abandonnées sur la nouvelle île. Ce choix est enregistré avec le monde.", "structure.sanctuary.heritage_networks": "Anciennes infrastructures", "sanctuary.heritage.kind.freight_station": "Gare de marchandises", diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/structure/underground_hall22.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/structure/underground_hall22.json new file mode 100644 index 0000000..65eb958 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/structure/underground_hall22.json @@ -0,0 +1,7 @@ +{ + "type": "sanctuary:underground_hall_v22", + "biomes": "#sanctuary:has_structure/forgotten_networks", + "step": "underground_structures", + "terrain_adaptation": "none", + "spawn_overrides": {} +} 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 14e6edb..958526b 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_v21", + "type": "sanctuary:island_v22", "biome_source": { "biomes": [ "minecraft:the_void", diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary_v21.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary_v21.json new file mode 100644 index 0000000..14e6edb --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary_v21.json @@ -0,0 +1,81 @@ +{ + "dimensions": { + "minecraft:overworld": { + "type": "sanctuary:sanctuary_384", + "generator": { + "type": "sanctuary:island_v21", + "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 a1be271..4bd2bfd 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 @@ -72,7 +72,7 @@ public final class ExpansionJournalSmoke { require(Arrays.equals(unifiedMediumBytes, Files.readAllBytes(unifiedMediumDirectory.resolve(ExpansionJournal.FILE_NAME))), "Rejected cross-generation read changed its journal"); - for (int generation : List.of(20, 21)) { + for (int generation : List.of(20, 21, 22)) { var saved = scratch.resolve("architecture-cross-version-" + generation); var root21 = ExpansionIsland.origin(724, SEED, generation); var journal21 = new ExpansionJournal(saved, SEED, 724, generation); @@ -80,7 +80,7 @@ public final class ExpansionJournalSmoke { journal21.commit(List.of(root21, child)); byte[] bytes = Files.readAllBytes(saved.resolve(ExpansionJournal.FILE_NAME)); require(new ExpansionJournal(saved, SEED, 724, generation).islands().equals(List.of(root21, child)), "Architecture journal round trip"); - reject(() -> new ExpansionJournal(saved, SEED, 724, generation == 20 ? 21 : 20), "Cross-generation architecture journal"); + reject(() -> new ExpansionJournal(saved, SEED, 724, generation == 22 ? 21 : 22), "Cross-generation architecture journal"); require(Arrays.equals(bytes, Files.readAllBytes(saved.resolve(ExpansionJournal.FILE_NAME))), "Rejected upgrade altered saved journal"); } diff --git a/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/city/LostCityPlanner22Smoke.java b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/city/LostCityPlanner22Smoke.java new file mode 100644 index 0000000..a0b2f87 --- /dev/null +++ b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/city/LostCityPlanner22Smoke.java @@ -0,0 +1,109 @@ +package fr.koka.sanctuary.worldgen.city; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.IntBinaryOperator; + +/** Strict admission is exercised against the final footprint, including misleading coarse bearings. */ +public final class LostCityPlanner22Smoke { + public static void main(String[] args) { + int satellites = 0; + for (int size : new int[]{512, 724, 1024}) for (long seed : new long[]{0, 42}) { + Terrain good = new Terrain((x, z) -> 220); + var plan = LostCityPlanner22.plan(seed, 0, 0, size, true, good); + require(!plan.surface().isEmpty(), "Strict admission rejected flat, fully supported terrain"); + verify(plan, good); satellites += plan.connections().size(); + var repeated = LostCityPlanner22.plan(seed, 0, 0, size, true, new Terrain((x, z) -> 220)); + require(signature(plan).equals(signature(repeated)), "Natural placement depends on invocation order"); + } + require(satellites > 0, "No strict witness retains a complete supported satellite approach"); + for (int size : new int[]{512, 724, 1024}) { + Terrain empty = new Terrain((x, z) -> 0); + var result = LostCityPlanner22.plan(0, 0, 0, size, true, empty); + verify(result, empty); + require(result.surface().isEmpty(), "Void received the former forced town or a large foundation"); + } + Terrain cliff = new Terrain((x, z) -> Math.floorMod(x, 10) < 5 ? 198 : 250); + var broken = LostCityPlanner22.plan(0, 0, 0, 724, true, cliff); + verify(broken, cliff); + require(broken.surface().isEmpty(), "Sharp repeated cliffs were replaced by an urban embankment"); + Terrain wet = new Terrain((x, z) -> 220); wet.wet = true; + var water = LostCityPlanner22.plan(0, 0, 0, 512, true, wet); + verify(water, wet); + require(water.surface().isEmpty(), "A protected wet island received a forced city"); + Terrain hollow = new Terrain((x, z) -> 220); hollow.hollow = true; + var unsupported = LostCityPlanner22.plan(42, 0, 0, 512, true, hollow); + verify(unsupported, hollow); + require(unsupported.surface().isEmpty(), "Coarse depth claims hid missing rock beneath the city foundations"); + + // Refusing a satellite must preserve the valid core and never leave an orphaned approach. + var baseline = LostCityPlanner22.plan(0, 0, 0, 724, true, new Terrain((x, z) -> 220)); + var core = baseline.core().surface().districts().getFirst(); + Terrain limited = new Terrain((x, z) -> 220); + limited.protectedVolume = (x, y, z) -> x < core.minX() - 2 || x > core.maxX() + 2 + || z < core.minZ() - 2 || z > core.maxZ() + 2; + var onlyCore = LostCityPlanner22.plan(0, 0, 0, 724, true, limited); + verify(onlyCore, limited); + require(!onlyCore.surface().isEmpty() && onlyCore.connections().isEmpty(), + "Rejecting protected outskirts removed the sound core or retained a fictional satellite"); + var continent = LostCityPlanner22.plan(0, 0, 0, 256, false, new Terrain((x, z) -> 220)); + require(!continent.surface().isEmpty() && continent.connections().isEmpty(), + "A flat continent lost its conditional city or inherited the root satellite policy"); + verify(continent, new Terrain((x, z) -> 220)); + System.out.println("LostCityPlanner22Smoke: naturally supported towns, bounded search, strict cut/fill, complete satellite routes and refusal of void/water/cliffs/false bearings passed."); + } + + private static void verify(LostCityPlanner21.Result plan, Terrain terrain) { + require(plan.reservation() == null && plan.core().reservation() == null, + "Generation22 exposed a terrain-clearing fallback reservation"); + require(plan.supports().isEmpty(), "Generation22 used a long catch-up pier"); + require(plan.columns() <= LostCityPlanner22.MAX_COLUMNS && plan.volumeChecks() <= LostCityPlanner22.MAX_VOLUME_CHECKS, + "Strict search exceeded its global budget across retries"); + require(plan.diagnostics().getOrDefault("proposal-attempt", 0) <= LostCityPlanner22.MAX_ATTEMPTS, "Unbounded proposal search"); + Map road = new HashMap<>(); + for (var district : plan.surface().districts()) for (var parcel : district.parcels()) { + require(parcel.kind() != LostCityPlan.Kind.BRIDGE, "A suspended crossing survived strict admission"); + for (int z = parcel.minZ(); z <= parcel.maxZ(); z++) for (int x = parcel.minX(); x <= parcel.maxX(); x++) { + int floor = parcel.surfaceY(x - parcel.minX(), z - parcel.minZ()), top = terrain.height.applyAsInt(x, z); + require(top - floor <= LostCityPlanner22.MAX_CUT && floor - top <= LostCityPlanner22.MAX_FILL, + "Accepted geometry exceeds the two-block cut/fill limit"); + for (int y = floor - parcel.foundationDepth() - 2; y <= Math.min(floor - 1, top); y++) + require(terrain.solid(x, y, z), "A final foundation borrows another column's bearing"); + for (int y = floor - parcel.foundationDepth() - 2; y <= parcel.maxY(); y++) + require(!terrain.protectedAt(x, y, z), "Accepted construction edits a protected volume"); + if (parcel.kind() == LostCityPlan.Kind.STREET) road.put(key(x, z), floor); + } + } + for (var link : plan.connections()) { + require(link.districtIndex() > 0 && link.districtIndex() < plan.surface().districts().size(), + "A connection references a rejected satellite"); + for (var point : link.path()) require(road.get(key(point.x(), point.z())) != null + && road.get(key(point.x(), point.z())) == point.floorY(), + "A retained satellite depends on a road that was discarded"); + } + } + private static String signature(LostCityPlanner21.Result result) { + var text = new StringBuilder().append(result.columns()).append(result.volumeChecks()).append(result.connections()) + .append(new java.util.TreeMap<>(result.diagnostics())); + for (var d : result.surface().districts()) for (var p : d.parcels()) text.append(p.kind()).append(p.minX()).append(p.minZ()) + .append(p.width()).append(p.depth()).append(p.floorY()).append(p.seed()).append(java.util.Arrays.toString(p.surfaceHeights())); + return text.toString(); + } + private interface Protection { boolean test(int x, int y, int z); } + private static final class Terrain implements LostCityPlanner16.Terrain { + final IntBinaryOperator height; boolean wet, hollow; + Protection protectedVolume = (x, y, z) -> false; + Terrain(IntBinaryOperator height) { this.height = height; } + @Override public boolean owns(int x, int z) { return Math.abs(x) < 600 && Math.abs(z) < 600; } + @Override public LostCityPlanner.Ground ground(int x, int z) { + int top = height.applyAsInt(x, z); + return new LostCityPlanner.Ground(top, top > 0 ? 24 : 0, !wet && top > 0, wet || protectedVolume.test(x, top, z)); + } + @Override public boolean solid(int x, int y, int z) { + int top = height.applyAsInt(x, z); return y > 0 && y <= top && (!hollow || y >= top - 1); + } + @Override public boolean protectedAt(int x, int y, int z) { return wet || protectedVolume.test(x, y, z); } + } + private static long key(int x, int z) { return (long) x << 32 ^ z & 0xffffffffL; } + private static void require(boolean ok, String message) { if (!ok) throw new AssertionError(message); } +} diff --git a/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/city/LostCityUnderground22Smoke.java b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/city/LostCityUnderground22Smoke.java new file mode 100644 index 0000000..234a7a1 --- /dev/null +++ b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/city/LostCityUnderground22Smoke.java @@ -0,0 +1,94 @@ +package fr.koka.sanctuary.worldgen.city; + +import fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Kind; +import fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Module; +import fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan; +import fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Type; +import java.util.ArrayDeque; +import java.util.HashSet; + +/** No city fixture is supplied: the hall, its native start and its surface access stand alone. */ +public final class LostCityUnderground22Smoke { + public static void main(String[] args) { + boolean pool = false, sewer = false; + for (int size : new int[]{512, 724, 1024}) for (long seed : new long[]{0, 42}) { + var terrain = new Plateau(); var plan = LostCityUnderground22.plan(seed, 0, 0, size, terrain); + require(!plan.isEmpty(), "Natural rock must admit an independent hall"); + require(plan.equals(LostCityUnderground22.plan(seed, 0, 0, size, new Plateau())), "Stable complete plan"); + verify(plan, terrain); + pool |= plan.networks().getFirst().modules().stream().anyMatch(m -> m.kind() == Kind.POOLROOM); + sewer |= plan.networks().getFirst().modules().stream().anyMatch(m -> m.kind() == Kind.SEWER); + } + require(pool && sewer, "Optional utility wings must be reachable on suitable rock"); + var protectedCenter = new Plateau() { + @Override public boolean protectedAt(int x, int y, int z) { return Math.abs(x) <= 15 && Math.abs(z) <= 15; } + }; + var shifted = LostCityUnderground22.plan(0, 0, 0, 724, protectedCenter); + require(!shifted.isEmpty(), "Search another real entrance when the central site is occupied"); verify(shifted, protectedCenter); + for (var bad : new Plateau[]{ + new Plateau() { @Override public boolean owns(int x, int z) { return false; } }, + new Plateau() { @Override public boolean solid(int x, int y, int z) { return false; } }, + new Plateau() { @Override public boolean solid(int x, int y, int z) { return y >= 250 && y <= 252 || y <= 80; } }, + new Plateau() { @Override public boolean protectedAt(int x, int y, int z) { return true; } }}) { + var refused = LostCityUnderground22.plan(0, 0, 0, 724, bad); + require(refused.isEmpty(), "No hanging hall or forced protected excavation"); + require(refused.samples() <= LostCityUnderground22.MAX_SAMPLES, "Failed search has a hard budget"); + } + System.out.println("Independent undercroft22: six city-free hall/access pairs, connected utility wings, occupied-site search and safe refusals."); + } + private static void verify(Plan plan, Plateau terrain) { + require(plan.networks().size() == 1 && plan.samples() <= LostCityUnderground22.MAX_SAMPLES, "One bounded native network"); + var network = plan.networks().getFirst(); var modules = network.modules(); + require(modules.stream().filter(m -> m.kind() == Kind.EXPANSION_HALL).count() == 1, "One independent expansion hall"); + require(modules.stream().filter(m -> m.kind() == Kind.ACCESS).count() == 1, "A real public entrance"); + var columns = new HashSet(); + for (var m : modules) { + require(Math.abs((m.minX() >> 4) - network.startChunkX()) <= 7 && Math.abs((m.maxX() >> 4) - network.startChunkX()) <= 7 + && Math.abs((m.minZ() >> 4) - network.startChunkZ()) <= 7 && Math.abs((m.maxZ() >> 4) - network.startChunkZ()) <= 7, "Native reference range"); + for (int x = m.minX(); x <= m.maxX(); x++) for (int z = m.minZ(); z <= m.maxZ(); z++) { + require(terrain.owns(x, z), "Owned footprint"); columns.add(((long) x << 32) ^ (z & 0xffffffffL)); + if (m.kind() == Kind.ACCESS) require(Math.abs(m.maxY() - 2 - terrain.surfaceY(x, z)) <= 2, "Entrance deck follows natural surface"); + for (int y = m.minY(); y <= m.maxY(); y++) require(!terrain.protectedAt(x, y, z), "Every written voxel respects protection"); + } + for (var s : m.supports()) { + require(s.maxY() - s.minY() + 1 <= 8, "Short support only"); + int anchor = s.type() == Type.PILLAR ? s.minY() : s.maxY(), neighbor = anchor + (s.type() == Type.PILLAR ? -1 : 1); + require(terrain.solid(s.x(), anchor, s.z()) && terrain.solid(s.x(), neighbor, s.z()), "Native paired anchor"); + for (int y = Math.min(neighbor, s.minY()); y <= Math.max(neighbor, s.maxY()); y++) + require(!terrain.protectedAt(s.x(), y, s.z()), "Written support and untouched anchor protected"); + } + } + require(columns.size() <= LostCityUnderground22.MAX_COLUMNS, "Published footprint fits the column budget"); + // Native pieces are disjoint; adjacent opened walls meet at the same walking floor. + for (int a = 0; a < modules.size(); a++) for (int b = a + 1; b < modules.size(); b++) { + var x = modules.get(a); var y = modules.get(b); + require(x.maxX() < y.minX() || y.maxX() < x.minX() || x.maxZ() < y.minZ() || y.maxZ() < x.minZ(), "No conflicting piece writes"); + } + var reached = new HashSet(); var todo = new ArrayDeque(); reached.add(0); todo.add(0); + while (!todo.isEmpty()) { + var a = modules.get(todo.remove()); + for (int i = 0; i < modules.size(); i++) if (!reached.contains(i) && connected(a, modules.get(i))) { reached.add(i); todo.add(i); } + } + require(reached.size() == modules.size(), "All rooms have an open route from the surface access"); + } + private static boolean connected(Module a, Module b) { + if (a.floorY() != b.floorY()) return false; + if (a.centerZ() == b.centerZ()) { + if (a.maxX() + 1 == b.minX()) return (a.openings() & 2) != 0 && (b.openings() & 8) != 0; + if (b.maxX() + 1 == a.minX()) return (a.openings() & 8) != 0 && (b.openings() & 2) != 0; + } + if (a.centerX() == b.centerX()) { + if (a.maxZ() + 1 == b.minZ()) return (a.openings() & 4) != 0 && (b.openings() & 1) != 0; + if (b.maxZ() + 1 == a.minZ()) return (a.openings() & 1) != 0 && (b.openings() & 4) != 0; + } + return false; + } + private static class Plateau implements LostCityUnderground22.Terrain { + public boolean owns(int x, int z) { return Math.abs(x) < 600 && Math.abs(z) < 600; } + public int surfaceY(int x, int z) { return 252; } + public boolean solid(int x, int y, int z) { return y >= 64 && y <= 252; } + public boolean protectedAt(int x, int y, int z) { return false; } + public boolean exteriorAir(int x, int y, int z, int dx, int dz) { return false; } + } + private static void require(boolean value, String message) { if (!value) throw new AssertionError(message); } +} diff --git a/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/heritage/HeritagePlanner22Smoke.java b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/heritage/HeritagePlanner22Smoke.java new file mode 100644 index 0000000..0199ca0 --- /dev/null +++ b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/heritage/HeritagePlanner22Smoke.java @@ -0,0 +1,47 @@ +package fr.koka.sanctuary.worldgen.heritage; + +/** Admission fixtures keep a good plateau usable while rejecting artificial terrain repairs. */ +public final class HeritagePlanner22Smoke { + public static void main(String[] args) { + for (int size : new int[]{512, 724, 1024}) { + var terrain = new Plateau(); + var plan = HeritagePlanner22.plan(0, 0, 0, size, terrain); + require(!plan.isEmpty() && !plan.links().isEmpty(), "Natural flat land must retain useful structures and routes"); + var repeat = HeritagePlanner22.plan(0, 0, 0, size, new Plateau()); + require(plan.sites().equals(repeat.sites()) && plan.links().equals(repeat.links()) && plan.segments().equals(repeat.segments()) + && plan.samples() == repeat.samples() && plan.diagnostics().equals(repeat.diagnostics()), "Placement must be deterministic"); + require(plan.samples() <= HeritagePlanner22.MAX_SAMPLES && plan.columns() <= HeritagePlanner22.MAX_COLUMNS, "Bounded search"); + for (var site : plan.sites()) { + require(site.supports().isEmpty(), "No rescue piers"); + for (int x = site.minX(); x <= site.maxX(); x++) for (int z = site.minZ(); z <= site.maxZ(); z++) { + require(Math.abs(site.floorY() - terrain.surfaceY(x, z)) <= 2, "Minimal cut/fill"); + require(terrain.solid(x, site.minY() - 1, z) && terrain.solid(x, site.minY() - 2, z), "Full footprint carries foundation"); + } + } + for (var segment : plan.segments()) for (var cell : segment.cells()) + require(Math.abs(cell.floorY() - terrain.surfaceY(cell.x(), cell.z())) <= 1, "Paths follow terrain"); + } + var stepped = new Plateau() { + @Override public int surfaceY(int x, int z) { return 220 + (Math.floorMod(x, 8) < 4 ? 0 : 8); } + }; + require(!HeritagePlanner21.plan(0, 0, 0, 724, stepped).isEmpty(), "Stepped witness exercised21's permissive placement"); + require(HeritagePlanner22.plan(0, 0, 0, 724, stepped).isEmpty(), "Repeated cliffs are rejected, not filled"); + require(HeritagePlanner22.plan(0, 0, 0, 724, new Plateau() { + @Override public boolean solid(int x, int y, int z) { return y == 220 || y <= 209; } + }).isEmpty(), "Hollow shelf cannot receive long piers"); + require(HeritagePlanner22.plan(0, 0, 0, 724, new Plateau() { + @Override public boolean protectedAt(int x, int y, int z) { return true; } + }).isEmpty(), "Protected hydrology cannot be waived"); + require(HeritagePlanner22.plan(0, 0, 0, 724, new Plateau() { + @Override public boolean owns(int x, int z) { return false; } + }).isEmpty(), "Void has no structures"); + System.out.println("Heritage22: three sizes admitted on natural land; relief, hollow shelves and protected land refused."); + } + private static class Plateau implements HeritagePlanner21.Terrain { + public boolean owns(int x, int z) { return Math.abs(x) < 600 && Math.abs(z) < 600; } + public int surfaceY(int x, int z) { return 220; } + public boolean solid(int x, int y, int z) { return y >= 80 && y <= surfaceY(x, z); } + public boolean protectedAt(int x, int y, int z) { return false; } + } + 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/transit/TransitPlanner22Smoke.java b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/transit/TransitPlanner22Smoke.java new file mode 100644 index 0000000..8ffb124 --- /dev/null +++ b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/transit/TransitPlanner22Smoke.java @@ -0,0 +1,34 @@ +package fr.koka.sanctuary.worldgen.transit; + +/** Core22 retains the supported21 traversal independently of optional surface placement. */ +public final class TransitPlanner22Smoke { + public static void main(String[] args) { + for (int size : new int[]{512, 724, 1024}) { + var plan = TransitPlanner22.plan(0, 0, 0, size, new Plateau()); + require(!plan.isEmpty(), "Solid plateau retains a complete buried route: " + plan.diagnostic()); + require(plan.modules().stream().filter(m -> m.kind() == TransitPlan21.Kind.ACCESS).count() == 2, "Two real exits"); + require(plan.equals(TransitPlanner22.plan(0, 0, 0, size, new Plateau())), "Deterministic bounded search"); + require(plan.samples() <= TransitPlan21.MAX_SAMPLES, "Probe budget"); + for (var m : plan.modules()) { + for (var support : m.supports()) require(support.maxY() - support.minY() + 1 <= TransitPlanner22.MAX_PIER, "Bounded engineered pier"); + + } + } + var hollow = new Plateau() { @Override public boolean solid(int x, int y, int z) { return y == 252 || y < 140; } }; + require(TransitPlanner22.plan(0, 0, 0, 724, hollow).isEmpty(), "No hanging route in huge cavern"); + require(TransitPlanner22.plan(0, 0, 0, 724, new Plateau() { + @Override public boolean protectedAt(int x, int y, int z) { return true; } + }).isEmpty(), "No protected volume overwritten"); + require(TransitPlanner22.plan(0, 0, 0, 724, new Plateau() { + @Override public boolean owns(int x, int z) { return false; } + }).isEmpty(), "No route beyond island ownership"); + System.out.println("Transit22: three sizes admit solid plateaus; void and protected volumes still refuse unsafe placement."); + } + private static class Plateau implements TransitPlanner21.Terrain { + public boolean owns(int x, int z) { return Math.abs(x) < 600 && Math.abs(z) < 600; } + public int surfaceY(int x, int z) { return 252; } + public boolean solid(int x, int y, int z) { return y >= 80 && y <= 252; } + public boolean protectedAt(int x, int y, int z) { return false; } + } + 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/transit/TransitRenderer22Smoke.java b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/transit/TransitRenderer22Smoke.java new file mode 100644 index 0000000..b8536b6 --- /dev/null +++ b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/transit/TransitRenderer22Smoke.java @@ -0,0 +1,76 @@ +package fr.koka.sanctuary.worldgen.transit; + +import fr.koka.sanctuary.worldgen.city.LostCityRenderer; +import fr.koka.sanctuary.worldgen.transit.TransitPlan21.Kind; +import fr.koka.sanctuary.worldgen.transit.TransitPlan21.Module; +import java.util.ArrayDeque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; +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.state.BlockState; +import net.minecraft.world.level.storage.loot.LootTable; + +/** All four L bends keep closed walls while retaining their full five-wide connection. */ +public final class TransitRenderer22Smoke { + public static void main(String[] args) { + SharedConstants.tryDetectVersion(); Bootstrap.bootStrap(); + for (int openings : new int[]{3, 6, 12, 9}) for (int facing : new int[]{0, 1}) { + var m = new Module(Kind.JUNCTION, 0, 199, 0, 17, 10, 17, facing, openings, 42, List.of()); + var old = new Capture(); TransitRenderer21.render(m, old); + var actual = new Capture(); TransitRenderer22.render(m, actual); + require(TransitPiece21.decodeModule(TransitPiece21.encodeModule(m)).equals(m), "Saved geometry schema remains identical"); + int changes = 0; + for (var entry : actual.blocks.entrySet()) { + var at = entry.getKey(); require(at.getX() >= 0 && at.getX() < 17 && at.getZ() >= 0 && at.getZ() < 17 + && at.getY() >= 0 && at.getY() < 10, "No expanded piece bounds"); + if (!entry.getValue().equals(old.block(at))) { + changes++; + require(at.getY() == 2 && entry.getValue().is(Blocks.TUFF_BRICKS) + && (old.block(at).isAir() || old.block(at).is(Blocks.SMOOTH_STONE_SLAB)), "Only unintended low wall gaps are restored"); + } + } + require(changes == 12 && actual.blocks.keySet().equals(old.blocks.keySet()), "Exactly ten holes and two curb gaps change"); + var targets = new HashSet(); + for (int d = 0; d < 4; d++) { + boolean open = (openings & 1 << d) != 0; + for (int across = -3; across <= 3; across++) for (int y = 2; y <= 8; y++) { + var at = edge(d, across, y); + require(open ? actual.block(at).isAir() : actual.block(at).isCollisionShapeFullBlock(EmptyBlockGetter.INSTANCE, at), + "Openings and complete closed walls must match saved direction " + d + " at " + at); + } + if (open) for (int across = -2; across <= 2; across++) targets.add(edge(d, across, 2)); + } + var reached = new HashSet(); var queue = new ArrayDeque(); var center = new BlockPos(8, 2, 8); + reached.add(center); queue.add(center); + while (!queue.isEmpty()) { + var at = queue.remove(); + for (int[] delta : new int[][]{{1, 0}, {-1, 0}, {0, 1}, {0, -1}}) { + var next = at.offset(delta[0], 0, delta[1]); + if (next.getX() < 0 || next.getX() > 16 || next.getZ() < 0 || next.getZ() > 16) continue; + if (actual.block(next).isAir() && actual.block(next.above()).isAir() + && actual.block(next.below()).isCollisionShapeFullBlock(EmptyBlockGetter.INSTANCE, next.below()) && reached.add(next)) queue.add(next); + } + } + require(reached.containsAll(targets), "Both five-wide road mouths remain connected through the corner"); + } + System.out.println("Transit22 corners: eight orientations retain sealed unopened walls, five-wide circulation and the unchanged module schema."); + } + private static BlockPos edge(int direction, int across, int y) { + return new BlockPos(direction == 1 ? 16 : direction == 3 ? 0 : 8 + across, y, + direction == 0 ? 0 : direction == 2 ? 16 : 8 + across); + } + private static final class Capture implements LostCityRenderer.BlockWriter { + final Map blocks = new HashMap<>(); + @Override public void set(int x, int y, int z, BlockState state) { 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()); } + BlockState block(BlockPos pos) { return blocks.getOrDefault(pos, Blocks.AIR.defaultBlockState()); } + } + private static void require(boolean value, String message) { if (!value) throw new AssertionError(message); } +} diff --git a/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/transit/TransitRoute22Smoke.java b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/transit/TransitRoute22Smoke.java new file mode 100644 index 0000000..e5886fd --- /dev/null +++ b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/transit/TransitRoute22Smoke.java @@ -0,0 +1,112 @@ +package fr.koka.sanctuary.worldgen.transit; + +import fr.koka.sanctuary.worldgen.transit.TransitPlan21.Kind; +import fr.koka.sanctuary.worldgen.transit.TransitPlan21.Module; +import java.util.ArrayDeque; +import java.util.HashSet; + +/** A dry plateau with a protected central obstacle must retain a genuine connected detour. */ +public final class TransitRoute22Smoke { + public static void main(String[] args) { + for (int size : new int[]{512, 724, 1024}) { + var terrain = new Plateau(); var plan = TransitRoute22.plan(42, 0, 0, size, terrain); + require(!plan.isEmpty(), "Detour around protected center must exist: " + plan.diagnostic()); + require(plan.equals(TransitRoute22.plan(42, 0, 0, size, new Plateau())), "Deterministic detour"); + verify(plan, size, terrain); + } + var rigidPorticos = new Plateau() { + @Override public int surfaceY(int x, int z) { + if (Math.abs(x) >= 105 && Math.abs(x) <= 130) return 252 + Math.floorMod(z, 5); + if (Math.abs(z) >= 105 && Math.abs(z) <= 140) return 252 + Math.floorMod(x, 5); + return 252; + } + @Override public boolean solid(int x, int y, int z) { return y >= 64 && y <= surfaceY(x, z); } + }; + var moved = TransitRoute22.plan(42, 0, 0, 512, rigidPorticos); + require(!moved.isEmpty(), "Reject the old119-radius porticos and search another complete pair"); + verify(moved, 512, rigidPorticos); + require(Math.max(Math.abs(moved.modules().getFirst().centerX() - moved.modules().getLast().centerX()), + Math.abs(moved.modules().getFirst().centerZ() - moved.modules().getLast().centerZ())) < 238, + "A closer well-supported pair replaces the failed fixed endpoints"); + for (var terrain : new Plateau[]{ + new Plateau() { @Override public boolean protectedAt(int x, int y, int z) { return true; } }, + new Plateau() { @Override public boolean owns(int x, int z) { return false; } }, + new Plateau() { @Override public int surfaceY(int x, int z) { return 380; } }, + new Plateau() { @Override public boolean solid(int x, int y, int z) { return y == 252; } }}) { + var plan = TransitRoute22.plan(42, 0, 0, 512, terrain); + require(plan.isEmpty(), "No incomplete, protected or unanchored fallback route"); + require(plan.samples() <= TransitPlan21.MAX_SAMPLES, "Failed detour is bounded"); + } + System.out.println("Transit22 detours: three sizes, protected obstacle bypass, connected broad corners and bounded safe refusals."); + } + private static void verify(TransitPlan21 plan, int diameter, Plateau terrain) { + require(plan.samples() <= TransitPlan21.MAX_SAMPLES, "Sampling budget"); + var modules = plan.modules(); + require(modules.getFirst().kind() == Kind.ACCESS && modules.getLast().kind() == Kind.ACCESS + && modules.stream().filter(m -> m.kind() == Kind.ACCESS).count() == 2, "Two indispensable surface exits"); + require(Math.max(Math.abs(modules.getFirst().centerX() - modules.getLast().centerX()), + Math.abs(modules.getFirst().centerZ() - modules.getLast().centerZ())) >= diameter / 4, "Island-scale destinations"); + require(modules.stream().anyMatch(m -> m.kind() == Kind.JUNCTION), "A real turn bypasses the direct alignment"); + var columns = new HashSet(); int floating = 0; + for (var m : modules) { + require(m.floorY() == modules.getFirst().floorY(), "Constant roadway height"); + require(m.kind() != Kind.JUNCTION || m.width() == 17 && m.depth() == 17, "Broad corner with the full five-wide crossing"); + if (m.kind() == Kind.ACCESS) { + int low = 384, high = -1; + for (int x = m.centerX() - 2; x <= m.centerX() + 2; x++) for (int z = m.minZ() - 3; z < m.minZ(); z++) { + int y = terrain.surfaceY(x, z); low = Math.min(low, y); high = Math.max(high, y); + } + require(high - low <= 1 && high == m.surfaceY(), "Changing endpoints never weakens the natural portico certificate"); + } + for (int x = m.minX(); x <= m.maxX(); x++) for (int z = m.minZ(); z <= m.maxZ(); z++) { + require(terrain.owns(x, z), "Owned footprint"); columns.add(((long) x << 32) ^ (z & 0xffffffffL)); + for (int y = m.minY() - 2; y <= m.maxY(); y++) require(!terrain.protectedAt(x, y, z), "Every edited voxel avoids the obstacle"); + } + int quadrants = 0; + for (int z = m.minZ();; z = Math.min(z + 4, m.maxZ())) { + for (int x = m.minX();; x = Math.min(x + 4, m.maxX())) { + boolean carried = false; + for (int y = m.minY() - 1; y >= m.minY() - 17; y--) + if (terrain.solid(x, y, z) && terrain.solid(x, y - 1, z)) { carried = true; break; } + if (carried) quadrants |= 1 << ((x < m.centerX() ? 0 : 1) + (z < m.centerZ() ? 0 : 2)); + if (x == m.maxX()) break; + } + if (z == m.maxZ()) break; + } + if (m.kind() != Kind.GALLERY) require(quadrants == 15, "Every access and corner is carried in four quarters"); + floating = quadrants == 15 ? 0 : floating + 1; require(floating <= 2, "At most34 blocks between complete bearings"); + for (var support : m.supports()) { + require(support.maxY() - support.minY() + 1 <= 16, "Bounded civil-engineering support"); + require(terrain.solid(support.x(), support.minY() - 1, support.z()) + && terrain.solid(support.x(), support.minY() - 2, support.z()), "Two native anchor layers"); + } + } + require(columns.size() <= TransitRoute22.MAX_COLUMNS, "Published footprint fits column budget"); + for (int i = 1; i < modules.size(); i++) require(connected(modules.get(i - 1), modules.get(i)), "Saved adjacent mouths meet at a turn"); + var seen = new HashSet(); var todo = new ArrayDeque(); seen.add(0); todo.add(0); + while (!todo.isEmpty()) { + var a = modules.get(todo.remove()); + for (int i = 0; i < modules.size(); i++) if (!seen.contains(i) && connected(a, modules.get(i))) { seen.add(i); todo.add(i); } + } + require(seen.size() == modules.size(), "Every emitted module belongs to the route between both accesses"); + } + private static boolean connected(Module a, Module b) { + if (a.floorY() != b.floorY()) return false; + if (a.centerZ() == b.centerZ()) { + if (a.maxX() + 1 == b.minX()) return (a.openings() & 2) != 0 && (b.openings() & 8) != 0; + if (b.maxX() + 1 == a.minX()) return (a.openings() & 8) != 0 && (b.openings() & 2) != 0; + } + if (a.centerX() == b.centerX()) { + if (a.maxZ() + 1 == b.minZ()) return (a.openings() & 4) != 0 && (b.openings() & 1) != 0; + if (b.maxZ() + 1 == a.minZ()) return (a.openings() & 1) != 0 && (b.openings() & 4) != 0; + } + return false; + } + private static class Plateau implements TransitPlanner21.Terrain { + public boolean owns(int x, int z) { return Math.abs(x) < 600 && Math.abs(z) < 600; } + public int surfaceY(int x, int z) { return 252; } + public boolean solid(int x, int y, int z) { return y >= 64 && y <= 252; } + public boolean protectedAt(int x, int y, int z) { return Math.abs(x) <= 10 && Math.abs(z) <= 45; } + } + private static void require(boolean value, String message) { if (!value) throw new AssertionError(message); } +} diff --git a/packwiz/pack.toml b/packwiz/pack.toml index 5cb823c..d02f41e 100644 --- a/packwiz/pack.toml +++ b/packwiz/pack.toml @@ -1,6 +1,6 @@ name = "Sanctuary" author = "KOKA99CAB" -version = "0.1.0-alpha.21" +version = "0.1.0-alpha.22" pack-format = "packwiz:1.1.0" [index]