diff --git a/README.md b/README.md index 570aa84..dcc73f0 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,18 @@ le relief et les souterrains de la 24, retire la montgolfière et travaille les marais souterrains et la restauration de vrais modèles d’épaves vanilla. Voir le [chantier et ses limites](docs/generation-alpha27.md). +## Alpha.30.6 — village souterrain et voies ramifiées + +La déformation du terrain 30.5 est légèrement renforcée. Un village vanilla +abandonné dispose d’une cavité locale adaptée ; les mines sont réservées à +l’avance et les donjons natifs disposent de davantage de tentatives. Les voies +abandonnées s’allongent et se ramifient entre les plateaux. +Deux nouveaux morceaux de terrain naturel sur Petit/Moyen, trois sur Grand, +occupent le ciel vers Y300–400. Le lieu envisagé à Y600 reste une idée future. + +Voir [la génération](docs/generation-alpha30.6.md), [les essais](docs/testing-alpha30.6.md) +et [l’atlas scientifique](docs/terrain-atlas.md). **Nouveau monde requis.** + ## Alpha.30.5 — plateaux et atlas du terrain L’étirement vertical de la 30.4 est retiré. Les plateaux issus de la 24/27 diff --git a/docs/generation-alpha30.6.md b/docs/generation-alpha30.6.md new file mode 100644 index 0000000..d35fd71 --- /dev/null +++ b/docs/generation-alpha30.6.md @@ -0,0 +1,97 @@ +# Alpha.30.6 — village souterrain et voies ramifiées + +Cette passe prolonge le terrain 30.5 validé en jeu. Elle ne reprend ni +l’étirement vertical 30.4 ni les grandes cavités artificielles de la 25. +Créer un **nouveau monde** : le profil de prototype `sanctuary:island_v24` +évolue avec l’accord du créateur pour ses mondes de test. Plafond Y640, +Minecraft 26.3-pre-2, Petit 512 / Moyen 724 par défaut / Grand 1 024 inchangés. + +## Relief + +L’amplitude de la déformation 3D supérieure passe de 0,80 à 0,95 dans les +mêmes grandes provinces. Leur répartition, les plateaux, les grottes basses, +les rivières courbes et le filtrage des petits fragments restent en place. +Il n’y a ni sommet imposé ni ajout de bruit de détail. L’atlas compare les +champs 24/27, 30.1, 30.5 et 30.6 aux mêmes coordonnées. + +Deux morceaux de terrain naturel sur Petit/Moyen, trois sur Grand, sont +recherchés entre Y300 et Y400 au-dessus de l’île. Ce sont des volumes rocheux +pleins érodés par un bruit 3D large, indépendants des deux îlots minéraux à +coffre. Ils ne reçoivent aucun butin ni construction. Leurs positions sont +séparées et recherchées en 24 essais au plus par masse ; le champ de terrain +existant est sondé pour conserver un espace d’air dessous. Le filtrage des +petits fragments s’applique également à ces hauteurs. La surface projetée +reste minoritaire ; aucun semis de blocs flottants n’est ajouté. + +L’idée d’un lieu exceptionnel vers **Y600** est conservée pour plus tard. +Aucune structure, plateforme ou arène n’y est générée dans cette version. + +## Village abandonné + +Un village vanilla abandonné est recherché dans la roche de l’île initiale. +**La cavité est aménagée spécialement pour lui**, à la demande du créateur : +sol commun praticable, chambres qui se recouvrent autour du graphe complet, +voûte irrégulière et accès vers les grottes voisines. Ce creusement reste +local ; aucun nouveau champ de cavités ne s’étend à l’île entière. + +- Identifiant : `sanctuary:underground_abandoned_village`. +- Assemblage Jigsaw natif des plaines abandonnées, taille 6 et distance 80 ; + ces paramètres ne fixent pas un nombre de maisons. Aucun pool Minecraft + global n’est remplacé, aucun graphe n’est réduit pour le faire tenir. +- Au moins six maisons pour accepter un graphe. Les templates natifs gardent + leurs lits, coffres, toiles et zombie-villageois persistants et guérissables. +- Les rues utilisent le même sol pendant la planification **et** la pose, + sans être projetées sur le toit de l’île. +- Au plus 60 positions × six altitudes possibles, au plus 32 000 colonnes mises en + cache ; aucun chargement de chunks pour rechercher l’emplacement. +- Le sous-sol doit offrir une majorité d’appuis et de couverture. Le plan + local évite les fluides détectés, les secrets et les infrastructures ; les + raccords sont contrôlés avec leur emprise complète. Les refus sont journalisés. +- Les pièces enregistrent la cavité avec les maisons ; le locator et le + registre observent le véritable départ. Le village ne se reconstitue pas + dans un chunk déjà généré. + +Le creusement laisse des fenêtres naturelles possibles. Ce n’est pas une +nouvelle ville de surface, un laboratoire ou une restauration des Lost Cities. +Les options expérimentales et générales de structures doivent être actives. + +## Mines et donjons + +Avant les premiers chunks, le générateur réserve des mines Minecraft complètes : +objectif **une sur Petit/Moyen, deux sur Grand**. Il cherche dans au plus 192 +candidats natifs, avec les contrôles d’appuis et d’altitude existants. Le plan +accepté est celui effectivement placé et localisé. Un échec reste explicite +au journal ; les graines testées ne prouvent pas l’ensemble des graines possibles. +Les mines restent indépendantes du commutateur expérimental ; l’option générale +« Générer les structures » fait autorité. + +Les salles à spawner restent la feature vanilla `monster_room`, avec ses +contrôles, butins et mobs. Les tentatives explorent neuf colonnes par chunk +au lieu de quatre, dans chaque chunk éligible, avec au plus 32 tentatives. +Les pièces protégées et les fluides restent exclus. Les plantes dont le support +est retiré par la pose tardive d’un donjon sont nettoyées dans son volume, sans +modifier les plantes valides ni produire de drops. Cela s’applique aussi +aux expansions du générateur courant ; aucune expansion n’est ouverte par +la recherche de structures. + +## Voies de surface + +Les voies se développent en longues portions suivant les pentes admissibles, +avec des bifurcations au milieu des parcours et des prolongements. Le ballast +reste continu sur les portions retenues ; certains rails et aiguillages sont +manquants et réparables. Aucune gare n’est nécessaire pour conserver une voie. +L’arrivée naturelle reste protégée sur 48 blocs autour de l’origine. + +Les **6–8 % évoqués sont un repère visuel, pas un quota imposé**. L’atlas des +structures mesure les colonnes aménagées et les superpose au relief. Les voies +s’arrêtent lorsqu’un terrain ou un raccord ne convient pas ; elles ne terrassent +pas une autoroute à travers les montagnes ou le vide pour atteindre un quota. + +## Registre et vérification + +Une erreur d’arrêt révélée par les essais est corrigée : Minecraft peut exécuter +une tâche immédiatement sur un worker lorsque le serveur s’arrête. Le registre +ne tente plus de modifier son état depuis ce worker. Les chunks trop tardifs +restent inconnus jusqu’à leur prochain chargement, sans inventer une absence. + +Voir [les essais et limites](testing-alpha30.6.md) et [la méthode d’atlas](terrain-atlas.md). diff --git a/docs/terrain-atlas.md b/docs/terrain-atlas.md index 4aec0b6..e4e6bb2 100644 --- a/docs/terrain-atlas.md +++ b/docs/terrain-atlas.md @@ -20,8 +20,8 @@ expansion n’est créé pour cet export. -PsanctuaryAtlasRevision=REVISION_DU_CODE python3.14 -m venv build/atlas-venv build/atlas-venv/bin/pip install -r scripts/terrain-atlas-requirements.txt -build/atlas-venv/bin/python scripts/terrain_atlas.py --output build/atlas305 \ - mods/sanctuary/build/run/gameTest/diagnostics/atlas305/terrain-*.json +build/atlas-venv/bin/python scripts/terrain_atlas.py --output build/atlas306 \ + mods/sanctuary/build/run/gameTest/diagnostics/atlas306/terrain-*.json ``` Le tracé livré utilise Python 3.14 ; les versions de Matplotlib et NumPy @@ -40,7 +40,7 @@ avant d’en faire une archive. Aucune sauvegarde personnelle n’est utilisée. représente pas les blocs ou les biomes ; du blanc n’implique pas de neige. - Altitude : dernier solide rencontré, recherche verticale par pas de 2 blocs. Une cellule grise signifie qu’aucun solide n’a été échantillonné dans la colonne. -- Épaisseur cumulée : nombre d’échantillons solides multiplié par 2 sur Y2..382. +- Épaisseur cumulée : nombre d’échantillons solides multiplié par 2 sur Y2..418 dans la 30.6 (Y2..382 dans la 30.5). Ce n’est ni une densité physique, ni un volume exact de matériaux minables. - Roche continue : épaisseur depuis le sommet jusqu’au premier vide échantillonné. Un petit résultat peut signaler une voûte mince, un surplomb ou un fragment. @@ -78,3 +78,25 @@ Le libellé « 24 / 27 » désigne le champ de la 27 restauré depuis la 24, pas une simulation de tous les matériaux et structures historiques de la 24. Les samplers 30.1 et 30.4 sont conservés pour comparer des transformations exactes, et non des approximations dessinées pour illustrer un résultat attendu. + + +## Comparaison 30.6 et infrastructures + +L’export courant remplace la colonne 30.4 par la comparaison directe 30.5/30.6. +`compileHistorical305` conserve exactement le coefficient 0,80 ; le champ livré +utilise 0,95. Le renderer accepte également les anciens exports 30.5. +Des coupes supplémentaires Y260..418 ciblent les morceaux de terrain supérieurs. +Les JSON courants sont dans `diagnostics/atlas306/`. + +`scripts/structure_atlas.py` superpose les plans ferroviaires au relief et trace +les coupes des blocs du village effectivement générés par `Village306GameTests`. +Le pourcentage est une estimation de surface projetée à partir du pas de +12 blocs ; le numérateur inclut les colonnes de ballast et les chemins. Ce n’est +ni une longueur de rails en pourcentage ni une part du volume de l’île. + +```sh +build/atlas-venv/bin/python scripts/structure_atlas.py \ + mods/sanctuary/build/run/gameTest/diagnostics/village306/724-0.json \ + mods/sanctuary/build/run/gameTest/diagnostics/atlas306/terrain-724-0.json \ + --output build/atlas306/structures +``` diff --git a/docs/testing-alpha30.6.md b/docs/testing-alpha30.6.md new file mode 100644 index 0000000..6e47d4b --- /dev/null +++ b/docs/testing-alpha30.6.md @@ -0,0 +1,108 @@ +# Vérifications alpha.30.6 + +Essais natifs sur **Mac**, Minecraft 26.3-pre-2, Java 25. Aucun essai Windows +natif n’est revendiqué. Un **nouveau monde** est nécessaire. + +## Atlas final + +Neuf couples : Petit 512 / Moyen 724 / Grand 1024 × graines 0, 42 et +-7228211907433324401. Le serveur d’export plat passe ses deux tests ; +l’acquisition finale dure 3 min 37 s, démarrage compris. Elle compile le champ +natif avant eau, matériaux, végétation et structures, sans générer de chunks +Sanctuary. Le rendu utilise Matplotlib/NumPy épinglés et aucune interpolation. + +Les trois références (24/27, 30.1, 30.5) sont comparées aux neuf exports +archivés de la 30.5 : colonnes et valeurs de densité des coupes **identiques**. +L’augmentation du domaine d’affichage à Y420 ajoute seulement de l’air aux +références ; le plafond constructible reste Y640. + +| Diamètre | Graine | Sommet échantillonné, formes aériennes incluses | Hauts plateaux conservés à ±2 blocs | +|---|---|---|---| +| 1024 | `-7228211907433324401` | Y382 | 71.6 % | +| 1024 | `0` | Y374 | 75.9 % | +| 1024 | `42` | Y386 | 68.8 % | +| 512 | `-7228211907433324401` | Y378 | 78.7 % | +| 512 | `0` | Y376 | 78.8 % | +| 512 | `42` | Y384 | 69.9 % | +| 724 | `-7228211907433324401` | Y384 | 76.5 % | +| 724 | `0` | Y376 | 77.6 % | +| 724 | `42` | Y382 | 68.0 % | + +La dernière colonne compare les colonnes de la base 24/27 atteignant Y200, +sur un pas horizontal de 12 blocs et vertical de 2. Elle ne mesure pas toute +la surface au bloc près. Les coupes supplémentaires ciblent les masses +supérieures ; une coupe 2D ne démontre pas à elle seule une connexion en 3D. +Le filtrage des composantes de 32 blocs ou moins reste actif dans le volume. + +## Essais de structures + +La passe complète du Grand sur la graine utilisateur a passé les 25 GameTests +et `check build` (9 min 58 s). Le village contient 12 maisons natives, +20 blocs de lits, 3 coffres, 852 blocs de rue et 9 zombie-villageois persistants, +observés dans 84 chunks FULL. Deux départs de mines vanilla sont réservés. +La recherche native par identifiants trouve une vraie mine au chunk [13,-2] +sans ouvrir d’expansion ; ses corridors et leurs éléments sont inspectés. +93 salles vanilla `monster_room` ont été observées dans l’ensemble de cette +passe : ce n’est pas le nombre total de donjons de l’île ni une garantie par graine. + +Ce témoin complet précède l’ultime sculpture des morceaux de terrain aérien. +La génération sous Y300 n’a pas changé dans ce dernier ajustement. Les plans +finaux du Grand et le témoin complet du Moyen sont vérifiés séparément ci-dessous. + +La cavité et toutes les pièces font un aller-retour NBT : position, rotation, +modèle, projection et jonctions conservés. Sur le Grand, Minecraft réduit la +marge verticale de collision de 18 pièces Jigsaw à la boîte du template lors +du chargement ; le test vérifie précisément cette normalisation native. Ce +contrôle ne remplace pas un essai de pillage suivi d’un redémarrage complet. + +Les premières passes de développement avaient aussi observé 17 maisons, +3 coffres et 6 zombie-villageois sur Petit/42, et 15 maisons, 2 coffres et +10 zombie-villageois sur Moyen/0. Elles ne sont pas présentées comme des +essais complets du dernier binaire. + +## Témoin final Moyen / 0 + +Le dernier `./gradlew check build`, après correction des plantes privées de +support par les donjons, réussit en **7 min 20 s** : **25/25 GameTests**, puis +tous les contrôles de build. Ce temps inclut le démarrage et les tests ; ce +n’est pas une mesure du temps d’ouverture d’un monde chez un joueur. + +Le village réellement généré comporte **15 maisons natives, 24 blocs de lits, +2 coffres, 1 340 blocs de rue et 10 zombie-villageois persistants** dans +104 chunks FULL. Entrée témoin : `-16, 112, 19`. Les 99 pièces natives et la +cavité conservent leurs données à la sérialisation ; 29 pièces subissent la +normalisation verticale native décrite plus haut. Une mine est réservée. +Les tests contrôlent également le marais, les rivières, la traversée, +les satellites et la localisation native. + +Le plan de surface contient 11 parcours et 52 segments : **659 colonnes de +voie**, 1 982 colonnes aménagées en incluant les chemins. Cela représente +**0,90 %** de l’emprise terrestre projetée estimée (0,96 % au-dessus de Y180), +et non les 6–8 % évoqués comme repère visuel. Le réseau est plus ramifié mais +ne couvre pas toute l’île. Les coupes du village montrent les blocs observés +après décoration ; les tracés ferroviaires représentent le plan préparé. + +## Plans finaux Grand / graine utilisateur + +Une nouvelle préparation native, après la dernière sculpture aérienne, passe +**2/2 tests en 3 min 1 s**, démarrage compris. Le village (12 maisons, entrée +`-59, 112, 336`) et les deux emprises de mines sont identiques au témoin +complet précédent. Le plan de surface final possède 11 parcours, 66 segments, +844 colonnes de voie et 2 538 colonnes aménagées, soit **0,48 %** de la surface +terrestre projetée estimée. Ce dernier essai vérifie les plans et leur +sérialisation, sans prétendre rejouer toute la génération FULL du Grand. + +## Livraison et limites + +L’assemblage utilise `./gradlew assemblePack -x :sanctuary:check` après le +`check build` complet réussi, pour ne pas relancer une seconde fois tous les +mêmes tests par la dépendance `assemblePack → build → check`. Le premier +assemblage sans exclusion a été interrompu avant cette répétition native. +L’assemblage réussit ; JAR, index packwiz, MRpack, dépendance Fabric API et +amorçage Prism sont vérifiés localement avant publication. +Le reçu de publication et de synchronisation figure dans `docs/packwiz.md`. +L’archive fige les mesures, les images et l’empreinte du JAR de cette livraison. +Les pourcentages de voies sont des mesures du plan ; aucun trajet en minecart +sur l’intégralité du réseau n’est revendiqué. Les recherches bornées peuvent +refuser une autre graine ; les témoins testés ne prouvent pas toutes les graines. +Aucune arène ni plateforme à Y600 n’est implémentée. diff --git a/gradle.properties b/gradle.properties index fd247a7..53c177d 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.30.5 -pack_version=0.1.0-alpha.30.5 +mod_version=0.1.0-alpha.30.6 +pack_version=0.1.0-alpha.30.6 maven_group=fr.koka.sanctuary diff --git a/mods/sanctuary/build.gradle b/mods/sanctuary/build.gradle index 88317b9..95e1cef 100644 --- a/mods/sanctuary/build.gradle +++ b/mods/sanctuary/build.gradle @@ -36,6 +36,7 @@ tasks.register('cleanGameTestWorld', Delete) { } tasks.named('runGameTest') { dependsOn('cleanGameTestWorld') + systemProperty('sanctuary.test.village306.planOnly', providers.gradleProperty('sanctuaryVillagePlanOnly').getOrElse('false')) systemProperty('sanctuary.test.atlasOnly', providers.gradleProperty('sanctuaryAtlasOnly').getOrElse('false')) systemProperty('sanctuary.test.atlasSeeds', providers.gradleProperty('sanctuaryAtlasSeeds').getOrElse('0,42,-7228211907433324401')) systemProperty('sanctuary.test.atlasRevision', providers.gradleProperty('sanctuaryAtlasRevision').getOrElse('working-tree')) @@ -87,6 +88,8 @@ tasks.named('runGameTest') { } tasks.named('processGametestResources') { + def villageOnly = providers.gradleProperty('sanctuaryVillageOnly').map { it.toBoolean() }.getOrElse(false) + inputs.property('villageOnly', villageOnly) def atlasOnly = providers.gradleProperty('sanctuaryAtlasOnly').map { it.toBoolean() }.getOrElse(false) inputs.property('atlasOnly', atlasOnly) def benchmark = providers.gradleProperty('sanctuaryBenchmark').map { it.toBoolean() }.getOrElse(false) @@ -132,11 +135,11 @@ tasks.named('processGametestResources') { def voidReuse = providers.gradleProperty('sanctuaryVoidReuseTests').map { it.toBoolean() }.getOrElse(false) inputs.property('voidReuse', voidReuse) filesMatching('fabric.mod.json') { - expand(gametest_entrypoint: atlasOnly ? 'fr.koka.sanctuary.gametest.TerrainAtlas305GameTests' : aerial24Flow ? 'fr.koka.sanctuary.gametest.Aerial24FlowAdmissionGameTests' : aerial24 ? 'fr.koka.sanctuary.gametest.Aerial24WorldGameTests' : admission23 ? 'fr.koka.sanctuary.gametest.Expansion23AdmissionGameTests' : ocean23 ? 'fr.koka.sanctuary.gametest.Ocean23WorldGameTests' : secrets23 ? 'fr.koka.sanctuary.gametest.Secrets23WorldGameTests' : native23 ? 'fr.koka.sanctuary.gametest.Native23WorldGameTests' : 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 + expand(gametest_entrypoint: villageOnly ? 'fr.koka.sanctuary.gametest.Village306GameTests' : atlasOnly ? 'fr.koka.sanctuary.gametest.TerrainAtlas305GameTests' : aerial24Flow ? 'fr.koka.sanctuary.gametest.Aerial24FlowAdmissionGameTests' : aerial24 ? 'fr.koka.sanctuary.gametest.Aerial24WorldGameTests' : admission23 ? 'fr.koka.sanctuary.gametest.Expansion23AdmissionGameTests' : ocean23 ? 'fr.koka.sanctuary.gametest.Ocean23WorldGameTests' : secrets23 ? 'fr.koka.sanctuary.gametest.Secrets23WorldGameTests' : native23 ? 'fr.koka.sanctuary.gametest.Native23WorldGameTests' : 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') - if (atlasOnly) filter { line -> line.replaceAll(/("fabric-gametest": \["[^"]+")[^\]]*/, '$1') } + if (atlasOnly || villageOnly) filter { line -> line.replaceAll(/("fabric-gametest": \["[^"]+")[^\]]*/, '$1') } } } diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Alpha28NativeStructuresGameTests.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Alpha28NativeStructuresGameTests.java index a90ed31..2817cd5 100644 --- a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Alpha28NativeStructuresGameTests.java +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Alpha28NativeStructuresGameTests.java @@ -70,11 +70,14 @@ public final class Alpha28NativeStructuresGameTests { report.put("locate_mineshaft", locateProof); if (located != null) { String id = located.getSecond().unwrapKey().orElseThrow().identifier().toString(); - for (var candidate : ExpansionStructures23.candidates(root)) { - var start = level.getChunk(candidate.chunk().x(), candidate.chunk().z(), ChunkStatus.STRUCTURE_STARTS, true) + var searchChunks = new java.util.LinkedHashSet(); + ExpansionStructures23.candidates(root).forEach(c -> searchChunks.add(c.chunk())); + host.starterMines306().forEach(m -> searchChunks.add(m.start().getChunkPos())); + for (var candidate : searchChunks) { + var start = level.getChunk(candidate.x(), candidate.z(), ChunkStatus.STRUCTURE_STARTS, true) .getStartForStructure(located.getSecond().value()); if (start != null && start.isValid() && start.getPieces().getFirst().getLocatorPosition().equals(located.getFirst())) { - found.put(id, start); locateProof.put("start_chunk", List.of(candidate.chunk().x(), candidate.chunk().z())); break; + found.put(id, start); locateProof.put("start_chunk", List.of(candidate.x(), candidate.z())); break; } } helper.assertTrue(found.containsKey(id), "The native /locate result belongs to a real saved start"); diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Swamp27WorldGameTests.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Swamp27WorldGameTests.java index 8138763..88327b9 100644 --- a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Swamp27WorldGameTests.java +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Swamp27WorldGameTests.java @@ -155,7 +155,7 @@ public final class Swamp27WorldGameTests { if (state.getFluidState().is(FluidTags.WATER)) { water++; chunkWater++; } if (plant(state)) { // A feature must obey native placement; old vanilla vines elsewhere are irrelevant. - if (!state.is(Blocks.VINE)) helper.assertTrue(state.canSurvive(level, at), "Native swamp plant retains its support: " + at + " " + state); + if (!state.is(Blocks.VINE)) helper.assertTrue(state.canSurvive(level, at), "Native swamp plant retains its support: " + at + " " + state + " below=" + level.getBlockState(at.below())); plants++; chunkPlants++; counts.merge(id(state), 1, Integer::sum); } if ((!state.isAir() && !plant(state)) || !state.getFluidState().isEmpty()) continue; diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Terraces29WorldGameTests.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Terraces29WorldGameTests.java index a8fca11..357c091 100644 --- a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Terraces29WorldGameTests.java +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Terraces29WorldGameTests.java @@ -108,7 +108,7 @@ public final class Terraces29WorldGameTests { var columns = new ArrayList(); int retained=0,land=0,highest=0; for(int z=-r;z<=r;z+=12)for(int x=-r;x<=r;x+=12){ int a=0,b=0,c=0; - for(int y=380;y>=32;y-=2){ + for(int y=398;y>=32;y-=2){ if(a==0 && old.sampleValue(context,x,y,z)>0)a=y; if(b==0 && now.sampleValue(context,x,y,z)>0)b=y; if(c==0 && global.sampleValue(context,x,y,z)>0)c=y; @@ -117,7 +117,7 @@ public final class Terraces29WorldGameTests { if(a>=200){land++;if(Math.abs(a-b)<=2)retained++;} highest=Math.max(highest,b); columns.add(List.of(x,z,a,b,c)); } - helper.assertTrue(highest<=360,"Natural summits leave room below the build ceiling"); + helper.assertTrue(highest<400,"Natural terrain and upper bodies leave at least 240 blocks below the build ceiling"); helper.assertTrue(retained>land*.25,"Each size retains substantial original uplands, rather than changing every slope"); sizes.add(Map.of("diameter",r*2,"columns",columns,"unchanged_uplands",retained,"uplands",land,"highest_sample",highest)); } diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/TerrainAtlas305GameTests.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/TerrainAtlas305GameTests.java index 6df8be7..7ac4ad5 100644 --- a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/TerrainAtlas305GameTests.java +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/TerrainAtlas305GameTests.java @@ -39,17 +39,17 @@ public final class TerrainAtlas305GameTests { var functions = new LinkedHashMap(); functions.put("plateaus27", field.legacy27()); functions.put("global301", field.legacy301()); - functions.put("stretch304", new Historical304(field)); - functions.put("current305", field); + functions.put("current305", new Historical305(field)); + functions.put("current306", field); var variants = new LinkedHashMap(); for (var entry : functions.entrySet()) { var sampler = random.getSampler(entry.getValue()); var context = SamplerContext.builder().enableCaches().build(); - var columns = new ArrayList(); + var columns = new ArrayList>(); for (int z = -bound; z <= bound; z += 12) for (int x = -bound; x <= bound; x += 12) { int top = 0, rock = 0, skin = 0; boolean roof = false; - for (int y = 382; y >= 2; y -= 2) { + for (int y = 418; y >= 2; y -= 2) { boolean solid = sampler.sampleValue(context, x, y, z) > 0; if (solid) { rock += 2; @@ -62,7 +62,7 @@ public final class TerrainAtlas305GameTests { var sections = new ArrayList(); for (int z : new int[]{0, 96, -(radius / 4)}) { var rows = new ArrayList(); - for (int y = 382; y >= 0; y -= 2) { + for (int y = 418; y >= 0; y -= 2) { var row = new ArrayList(); for (int x = -bound; x <= bound; x += 2) { float density = sampler.sampleValue(context, x, y, z); @@ -75,22 +75,37 @@ public final class TerrainAtlas305GameTests { } rows.add(row); } - sections.add(Map.of("z", z, "min_x", -bound, "max_y", 382, "step", 2, "density", rows)); + sections.add(Map.of("z", z, "min_x", -bound, "max_y", 418, "step", 2, "density", rows)); } - variants.put(entry.getKey(), Map.of("columns", columns, "sections", sections)); + var upperCuts = new ArrayList(); + if(entry.getKey().equals("current306")) { + var peaks=new ArrayList>(); + for(var peak:columns.stream().filter(c->c.get(2)>=300).sorted(Comparator.comparingInt((List c)->c.get(2)).reversed()).toList()) { + if(peaks.stream().anyMatch(p->Math.hypot(p.get(0)-peak.get(0),p.get(1)-peak.get(1))<100))continue; + peaks.add(peak); if(peaks.size()==3)break; + } + helper.assertTrue(peaks.size()>=2,"At least two substantial upper bodies sampled"); + for(var peak:peaks) { + var rows=new ArrayList(); + for(int y=418;y>=260;y-=2) {var row=new ArrayList();for(int x=peak.get(0)-72;x<=peak.get(0)+72;x+=2) + row.add(sampler.sampleValue(context,x,y,peak.get(1))>0?1:0);rows.add(row);} + upperCuts.add(Map.of("z",peak.get(1),"min_x",peak.get(0)-72,"max_y",418,"step",2,"solid",rows)); + } + } + variants.put(entry.getKey(), Map.of("columns", columns, "sections", sections,"upper_sections",upperCuts)); } var report = new LinkedHashMap(); report.put("schema", 1); report.put("version", version); report.put("revision", System.getProperty("sanctuary.test.atlasRevision", "working-tree")); report.put("minecraft", "26.3-pre-2"); report.put("seed", Long.toString(seed)); report.put("diameter", radius * 2); report.put("process_id", ProcessHandle.current().pid()); - report.put("domain_y", List.of(0, 383)); report.put("build_ceiling_exclusive", 640); + report.put("domain_y", List.of(0, 419)); report.put("build_ceiling_exclusive", 640); report.put("map_step", 12); report.put("vertical_step", 2); report.put("density_scale", 10000); report.put("columns", List.of("x", "z", "top_y", "sampled_solid_thickness", "uninterrupted_roof_thickness")); report.put("scope", "Native density, before fluids, surface materials, vegetation and structures; no chunks generated for this export"); report.put("variants", variants); report.put("export_seconds", (System.nanoTime() - started) / 1e9); - Path path = Path.of("diagnostics/atlas305", "terrain-" + radius * 2 + "-" + seed + ".json"); + Path path = Path.of("diagnostics/atlas306", "terrain-" + radius * 2 + "-" + seed + ".json"); Files.createDirectories(path.getParent()); Files.writeString(path, new Gson().toJson(report)); SanctuaryMod.LOGGER.info("Terrain atlas exported {} in {} s", path, report.get("export_seconds")); } @@ -108,4 +123,14 @@ public final class TerrainAtlas305GameTests { // Never serialized: this wrapper only selects the exact diagnostic sampler. @Override public MapCodec codec() { return base.codec(); } } + private record Historical305(PopulationIslandDensity base) implements DensityFunction { + @Override public DensitySampler compileSampler(CompileContext context) { return base.compileHistorical305(context); } + @Override public DensityFunction rewriteChildren(DfRewriteRule rule) { + return new Historical305((PopulationIslandDensity)rule.rewrite(base)); + } + @Override public Interval range() { return base.range(); } + @Override public int domainAxes() { return ALL_AXES; } + // Never serialized: this wrapper only selects the exact diagnostic sampler. + @Override public MapCodec codec() { return base.codec(); } + } } diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Village306GameTests.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Village306GameTests.java new file mode 100644 index 0000000..cfe165c --- /dev/null +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Village306GameTests.java @@ -0,0 +1,95 @@ +package fr.koka.sanctuary.gametest; +import com.google.gson.GsonBuilder; +import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator; +import fr.koka.sanctuary.worldgen.underground306.Village306; +import java.nio.file.*; +import java.util.*; +import net.fabricmc.fabric.api.gametest.v1.GameTest; +import net.minecraft.core.BlockPos; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext; +public final class Village306GameTests { + @GameTest(maxTicks=12000) + public void villageInCustomCavern(GameTestHelper helper) throws Exception { + var level=helper.getLevel();var host=(SanctuaryChunkGenerator)level.getChunkSource().getGenerator();var p=host.village306(); + if(host.rootGeneration()!=24){helper.succeed();return;} + var report=new LinkedHashMap();report.put("seed",Long.toString(level.getSeed()));report.put("diameter",host.initialDiameter()); + report.put("process_id",ProcessHandle.current().pid());report.put("diagnostics",p.diagnostics()); + Path file=Path.of("diagnostics/village306",host.initialDiameter()+"-"+level.getSeed()+".json");Files.createDirectories(file.getParent()); + Files.writeString(file,new GsonBuilder().setPrettyPrinting().create().toJson(report)); + if(!host.experimentalStructures() || !level.structureManager().shouldGenerateStructures()) {helper.assertTrue(p.start()==null,"Options disable village");helper.succeed();return;} + helper.assertTrue(p.start()!=null,"Underground vanilla village admitted: "+p.diagnostics()); + helper.assertTrue(host.starterMines306().size()>=(host.initialDiameter()==1024?2:1),"One native mine, two for Large"); + report.put("mines",host.starterMines306().stream().map(m->m.start().getBoundingBox().toString()).toList()); + report.put("rail_links",host.heritagePlan19().links().stream().map(l->Map.of("id",l.id(),"kind",l.kind().toString(),"points",l.path())).toList()); + report.put("rail_cells",host.heritagePlan19().segments().stream().flatMap(seg->seg.cells().stream()).toList()); + report.put("box",p.start().getBoundingBox().toString());report.put("floor",p.cavern().floorY());report.put("entrance",p.cavern().getLocatorPosition().toShortString()); + var cp=p.start().getChunkPos();var actual=level.getChunk(cp.x(),cp.z(),ChunkStatus.STRUCTURE_STARTS,true).getStartForStructure(p.start().getStructure()); + helper.assertTrue(actual!=null&&actual.isValid(),"Actual registered start"); + var ctx=StructurePieceSerializationContext.fromLevel(level);var tag=actual.createTag(ctx,cp);var restored=StructureStart.loadStaticStart(ctx,tag,level.getSeed()); + helper.assertTrue(restored!=null&&restored.getPieces().size()==actual.getPieces().size(),"Full graph reloads without losing pieces"); + var restoredTag=restored.createTag(ctx,cp); + report.put("exact_nbt_roundtrip",tag.equals(restoredTag)); + int normalized=0; + for(int i=0;i{try{ + if(iterator.hasNext()){var at=iterator.next();level.getChunk(at.x(),at.z(),ChunkStatus.FULL,true);helper.runAtTickTime(helper.getTick()+1,()->next[0].run());return;} + if(!tickets[0]) {chunks.forEach(at->level.getChunkSource().addTicketWithRadius(net.minecraft.server.level.TicketType.DRAGON,at,2));tickets[0]=true;} + if((waited[0] < 20 || level.getEntitiesOfClass(net.minecraft.world.entity.monster.zombie.ZombieVillager.class, + net.minecraft.world.phys.AABB.of(p.cavern().getBoundingBox())).isEmpty()) && waited[0]++<200) { + helper.runAtTickTime(helper.getTick()+1,()->next[0].run());return; + } + int beds=0,chests=0,roads=0; var box=p.cavern().getBoundingBox(); + for(int z=box.minZ();z<=box.maxZ();z++)for(int x=box.minX();x<=box.maxX();x++)for(int y=p.cavern().floorY()-2;y<=p.cavern().floorY()+13;y++){ + var state=level.getBlockState(new BlockPos(x,y,z));if(state.getBlock() instanceof net.minecraft.world.level.block.BedBlock)beds++; + if(state.is(net.minecraft.world.level.block.Blocks.CHEST))chests++;if(state.is(net.minecraft.world.level.block.Blocks.DIRT_PATH))roads++; + } + var zombies=level.getEntitiesOfClass(net.minecraft.world.entity.monster.zombie.ZombieVillager.class,net.minecraft.world.phys.AABB.of(box)); + report.put("beds_blocks",beds);report.put("chests",chests);report.put("roads",roads);report.put("zombie_villagers",zombies.size());report.put("entity_wait",waited[0]); + report.put("monster_rooms",fr.koka.sanctuary.knowledge.structures.StructureCensusService.snapshot(level.getServer()).evidence().sites().values().stream() + .filter(site->site.key().id().equals("minecraft:monster_room")).map(site->site.key()).toList());report.put("difficulty",level.getDifficulty().toString()); + Files.writeString(file,new GsonBuilder().setPrettyPrinting().create().toJson(report)); + helper.assertTrue(zombies.stream().allMatch(z->z.isPersistenceRequired()),"Native zombie villagers remain persistent and curable"); + helper.assertTrue(beds>=6,"Vanilla houses have beds");helper.assertTrue(chests>0,"Native chest loot present");helper.assertTrue(roads>20,"Native roads placed underground");helper.assertTrue(!zombies.isEmpty(),"Abandoned village has zombie villagers"); + report.put("beds_blocks",beds);report.put("chests",chests);report.put("roads",roads);report.put("zombie_villagers",zombies.size());report.put("full_chunks",chunks.size());report.put("passed",true); + var cuts=new ArrayList(); + for(int z:new int[]{box.minZ()+box.getZSpan()/3,box.minZ()+box.getZSpan()*2/3}) { + var rows=new ArrayList>(); + for(int y=300;y>=0;y-=2){var row=new ArrayList();for(int x=box.minX();x<=box.maxX();x+=2){ + var state=level.getBlockState(new BlockPos(x,y,z));row.add(state.getFluidState().is(net.minecraft.tags.FluidTags.WATER)?2: + state.getFluidState().is(net.minecraft.tags.FluidTags.LAVA)?3:state.isAir()?0:1); + }rows.add(row);}cuts.add(Map.of("z",z,"min_x",box.minX(),"max_y",300,"step",2,"blocks",rows)); + } + report.put("native_cuts",cuts); + var floorRows=new ArrayList>(); + for(int z=box.minZ();z<=box.maxZ();z++){var row=new ArrayList();for(int x=box.minX();x<=box.maxX();x++) + row.add(net.minecraft.core.registries.BuiltInRegistries.BLOCK.getKey(level.getBlockState(new BlockPos(x,p.cavern().floorY()+2,z)).getBlock()).toString());floorRows.add(row);} + report.put("floor_cut",Map.of("min_x",box.minX(),"min_z",box.minZ(),"y",p.cavern().floorY()+2,"blocks",floorRows)); + + Files.writeString(file,new GsonBuilder().setPrettyPrinting().create().toJson(report)); + chunks.forEach(at->level.getChunkSource().removeTicketWithRadius(net.minecraft.server.level.TicketType.DRAGON,at,2));helper.succeed(); + }catch(Exception error){throw new IllegalStateException(error);}}; + helper.runAtTickTime(helper.getTick()+1,()->next[0].run()); + } +} diff --git a/mods/sanctuary/src/gametest/resources/fabric.mod.json b/mods/sanctuary/src/gametest/resources/fabric.mod.json index f9558f3..35dc604 100644 --- a/mods/sanctuary/src/gametest/resources/fabric.mod.json +++ b/mods/sanctuary/src/gametest/resources/fabric.mod.json @@ -6,7 +6,7 @@ "environment": "*", "license": "GPL-3.0-or-later", "entrypoints": { - "fabric-gametest": ["${gametest_entrypoint}", "fr.koka.sanctuary.gametest.Swamp27WorldGameTests", "fr.koka.sanctuary.gametest.Caves28WorldGameTests", "fr.koka.sanctuary.gametest.Terraces29WorldGameTests", "fr.koka.sanctuary.gametest.River30WorldGameTests", "fr.koka.sanctuary.gametest.Transit29WorldGameTests", "fr.koka.sanctuary.gametest.Alpha28NativeStructuresGameTests", "fr.koka.sanctuary.gametest.BlockKnowledgeGameTests", "fr.koka.sanctuary.gametest.CensusGameTestsTerrain", "fr.koka.sanctuary.gametest.CensusStockGameTests", "fr.koka.sanctuary.gametest.MaterialActivityGameTests"], + "fabric-gametest": ["${gametest_entrypoint}", "fr.koka.sanctuary.gametest.Swamp27WorldGameTests", "fr.koka.sanctuary.gametest.Caves28WorldGameTests", "fr.koka.sanctuary.gametest.Terraces29WorldGameTests", "fr.koka.sanctuary.gametest.River30WorldGameTests", "fr.koka.sanctuary.gametest.Transit29WorldGameTests", "fr.koka.sanctuary.gametest.Alpha28NativeStructuresGameTests", "fr.koka.sanctuary.gametest.Village306GameTests", "fr.koka.sanctuary.gametest.BlockKnowledgeGameTests", "fr.koka.sanctuary.gametest.CensusGameTestsTerrain", "fr.koka.sanctuary.gametest.CensusStockGameTests", "fr.koka.sanctuary.gametest.MaterialActivityGameTests"], "fabric-client-gametest": ["fr.koka.sanctuary.gametest.SanctuaryClientRenderTests"] }, "mixins": ["sanctuary-gametest.mixins.json"], 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 990fc7f..970541f 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/SanctuaryMod.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/SanctuaryMod.java @@ -79,6 +79,7 @@ public final class SanctuaryMod implements ModInitializer { Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v24"), SanctuaryChunkGenerator.AERIAL24_CODEC); Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v24"), SanctuaryBiomeSource.AERIAL24_CODEC); fr.koka.sanctuary.worldgen.aerial.AerialStructures24.register(); + fr.koka.sanctuary.worldgen.underground306.Village306.register(); fr.koka.sanctuary.worldgen.mineral.MineralSanctuaryStructures23.register(); fr.koka.sanctuary.worldgen.transit.TransitStructures21.register(); fr.koka.sanctuary.worldgen.city.LostCityUndergroundStructures22.register(); 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 71320ec..7e86737 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 @@ -130,6 +130,14 @@ public class ExpansionChunkGenerator extends ChunkGenerator { fr.koka.sanctuary.worldgen.city.LostCityUndergroundStructures22.generate(this, registries, state, structures, chunk, templates, dimension); fr.koka.sanctuary.worldgen.mineral.MineralSanctuaryStructures23.generate(this, registries, state, structures, chunk, templates, dimension); fr.koka.sanctuary.worldgen.aerial.AerialStructures24.generate(this, registries, state, structures, chunk, templates, dimension); + if (this instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator host && structures.shouldGenerateStructures() + && Level.OVERWORLD.equals(dimension) && host.village306().start() != null) { + var planned = host.village306().start(); + if (planned.getChunkPos().equals(chunk.getPos())) { + var existing = structures.getStartForStructure(planned.getStructure(), chunk); + if (existing == null || !existing.isValid()) structures.setStartForStructure(planned.getStructure(), planned, chunk); + } + } 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/ExpansionStructures23.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionStructures23.java index d472602..bfbce81 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionStructures23.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionStructures23.java @@ -92,6 +92,13 @@ public final class ExpansionStructures23 { if (!Level.OVERWORLD.equals(dimension) || !manager.shouldGenerateStructures()) return; ExpansionIsland island = generator.islandAt(chunk.getPos().getMiddleBlockX(), chunk.getPos().getMiddleBlockZ()); if (island == null || !nativeGeneration(island.generation())) return; + if (island.origin() && generator instanceof SanctuaryChunkGenerator host) { + for (var planned : host.starterMines306()) if (planned.start().getChunkPos().equals(chunk.getPos())) { + var existing = manager.getStartForStructure(planned.structure().value(),chunk); + if(existing == null || !existing.isValid()) manager.setStartForStructure(planned.structure().value(),planned.start(),chunk); + return; + } + } Candidate candidate = candidates(island).stream().filter(c -> c.chunk().equals(chunk.getPos())).findFirst().orElse(null); if (candidate == null) return; for (var old : chunk.getAllStarts().values()) { @@ -159,6 +166,37 @@ public final class ExpansionStructures23 { return null; } + /** Reserve complete native mines for the starter island, before mutable chunk visitation. */ + public static List starterMines306(ServerLevel level, SanctuaryChunkGenerator host, ExpansionIsland root) { + var view = new NativeView(host, root, host.source(), level.registryAccess()); + var factory = PalettedContainerFactory.create(level.registryAccess()); + var choices = new ArrayList<>(candidates(root)); + choices.sort(Comparator.comparingDouble(c -> Math.hypot(c.chunk().getMiddleBlockX(), c.chunk().getMiddleBlockZ()))); + // Additional initial-world candidates vary the complete native graph, rather than forcing + // one unsuitable graph through the island. They become real locator candidates when reserved. + var extra = new ArrayList(); + int extent = root.terrainRadius() * 3 / 4; + for(int z=-extent;z<=extent;z+=48)for(int x=-extent;x<=extent;x+=48) { + var at=new ChunkPos(x>>4,z>>4); + if(Math.hypot(x,z)>extent || choices.stream().anyMatch(c->c.chunk().equals(at)))continue; + extra.add(new Candidate(at,mix(root.seed()^at.pack()^0x306A11EL))); + } + extra.sort(Comparator.comparingLong(Candidate::roll));choices.addAll(extra); + var result = new ArrayList(); + for (var candidate : choices.stream().limit(192).toList()) { + var chunk = new ProtoChunk(candidate.chunk(), UpgradeData.EMPTY, level, factory, null); + try { + var accepted = planFiltered(host, view, root, candidate, level.registryAccess(), level.getStructureTemplateManager(), + level.dimension(), chunk, Long.MAX_VALUE, entry -> entry.id().equals("minecraft:mineshaft")); + if (accepted == null || result.stream().anyMatch(old -> old.start().getBoundingBox().intersects(accepted.start().getBoundingBox()))) continue; + result.add(accepted); + if (result.size() >= (host.initialDiameter() == 1024 ? 2 : 1)) break; + } catch (SampleBudgetExceeded refused) { /* Continue the finite search, retaining native support rules. */ } + } + fr.koka.sanctuary.SanctuaryMod.LOGGER.info("Starter mines306 seed {} size {}: {} native mines reserved", root.seed(),host.initialDiameter(),result.size()); + return List.copyOf(result); + } + private static Accepted planFiltered(ExpansionChunkGenerator host, ExpansionChunkGenerator view, ExpansionIsland island, Candidate candidate, RegistryAccess registries, StructureTemplateManager templates, ResourceKey dimension, ChunkAccess chunk, long deadlineNanos, Predicate included) { @@ -469,6 +507,11 @@ public final class ExpansionStructures23 { && Math.abs((long) candidate.chunk().z() - (origin.getZ() >> 4)) <= (long) radius * CELL_CHUNKS) candidates.add(candidate); } } + if (nativeRequested && generator instanceof SanctuaryChunkGenerator sanctuary) { + for(var mine:sanctuary.starterMines306()) { + var at=mine.start().getChunkPos();addPosition(candidates,at.x(),at.z()); + } + } if (sanctuaryRequested && generator instanceof SanctuaryChunkGenerator sanctuary) addSanctuaryCandidates(sanctuary, candidates); candidates.removeIf(c -> Math.abs((long) c.chunk().x() - (origin.getX() >> 4)) > (long) radius * CELL_CHUNKS || Math.abs((long) c.chunk().z() - (origin.getZ() >> 4)) > (long) radius * CELL_CHUNKS); @@ -508,6 +551,9 @@ public final class ExpansionStructures23 { generator.ruinsPlan17().networks().forEach(s -> addPosition(positions, s.startChunkX(), s.startChunkZ())); generator.transitPlan21().modules().forEach(s -> addPosition(positions, s.startChunkX(), s.startChunkZ())); generator.undergroundHall22().networks().forEach(s -> addPosition(positions, s.startChunkX(), s.startChunkZ())); + if (generator.village306().start() != null) { + var village = generator.village306().start().getChunkPos(); addPosition(positions, village.x(), village.z()); + } var mineral = generator.mineralSanctuary23(); if (!mineral.isEmpty()) addPosition(positions, mineral.chamber().startChunkX(), mineral.chamber().startChunkZ()); if (generator.rootGeneration() == 24) generator.aerialPlan24().sites() diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/knowledge/structures/StructureCensusService.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/knowledge/structures/StructureCensusService.java index a346d63..fddcfac 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/knowledge/structures/StructureCensusService.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/knowledge/structures/StructureCensusService.java @@ -214,11 +214,19 @@ public final class StructureCensusService { var session = current(server); if (session.failed) return; try { observation.accept(session); } catch (RuntimeException error) { fail(session, error); } }; - if (server.isSameThread()) task.run(); else server.execute(task); + if (server.isSameThread()) task.run(); else if (server.isRunning()) server.execute(() -> { + // Minecraft may execute inline on a generation worker during shutdown. Such late + // chunks stay unknown in the census and are observed normally on their next load. + if (server.isSameThread()) task.run(); + }); } private static void reportFailure(MinecraftServer server, Exception error) { Runnable task = () -> fail(current(server), error); - if (server.isSameThread()) task.run(); else server.execute(task); + if (server.isSameThread()) task.run(); else if (server.isRunning()) server.execute(() -> { + // Minecraft may execute inline on a generation worker during shutdown. Such late + // chunks stay unknown in the census and are observed normally on their next load. + if (server.isSameThread()) task.run(); + }); } public static void flush(MinecraftServer server) { assertServerThread(server); var session = SERVERS.get(server); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/UndergroundHeight306Mixin.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/UndergroundHeight306Mixin.java new file mode 100644 index 0000000..750ebdd --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/UndergroundHeight306Mixin.java @@ -0,0 +1,20 @@ +package fr.koka.sanctuary.mixin; + +import fr.koka.sanctuary.worldgen.underground306.Village306; +import net.minecraft.server.level.WorldGenRegion; +import net.minecraft.world.level.levelgen.Heightmap; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +/** Vanilla terrain_matching roads query this at placement time, independently of the planning generator. */ +@Mixin(WorldGenRegion.class) +public abstract class UndergroundHeight306Mixin { + @Inject(method = "getHeight", at = @At("HEAD"), cancellable = true) + private void sanctuary$undergroundRoad306(Heightmap.Types type, int x, int z, CallbackInfoReturnable cir) { + Integer floor = Village306.projectedHeight(x, z); + if (floor != null && (type == Heightmap.Types.WORLD_SURFACE_WG || type == Heightmap.Types.OCEAN_FLOOR_WG + || type == Heightmap.Types.WORLD_SURFACE || type == Heightmap.Types.OCEAN_FLOOR)) cir.setReturnValue(floor); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/UndergroundStart306Mixin.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/UndergroundStart306Mixin.java new file mode 100644 index 0000000..625d0a6 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/UndergroundStart306Mixin.java @@ -0,0 +1,23 @@ +package fr.koka.sanctuary.mixin; + +import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import fr.koka.sanctuary.worldgen.underground306.Village306; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.StructureManager; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import org.spongepowered.asm.mixin.Mixin; + +@Mixin(StructureStart.class) +public abstract class UndergroundStart306Mixin { + @WrapMethod(method = "placeInChunk") + private void sanctuary$projectVillage306(WorldGenLevel level, StructureManager manager, ChunkGenerator generator, + RandomSource random, BoundingBox clip, ChunkPos chunk, Operation original) { + Village306.placing( (StructureStart) (Object) this, + () -> original.call(level, manager, generator, random, clip, chunk)); + } +} 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 c3cde04..0ee5208 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 @@ -21,6 +21,10 @@ public abstract class UnifiedDecorationProtectionMixin { if (!(region.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator generator) || !fr.koka.sanctuary.expansion.ExpansionIsland.spatialGeneration(generator.rootGeneration())) return; // Aerial pieces write in genuine void chunks, within their own immutable site and current chunk only. + if (fr.koka.sanctuary.worldgen.underground306.Village306.isPlacing()) { + if (!fr.koka.sanctuary.worldgen.underground306.Village306.mayWrite(pos)) callback.setReturnValue(false); + return; + } if (generator.rootGeneration() == 24 && fr.koka.sanctuary.worldgen.aerial.AerialStructures24.isPlacing()) { if (!fr.koka.sanctuary.worldgen.aerial.AerialStructures24.mayWrite(pos)) callback.setReturnValue(false); return; // Vanilla still applies its write-zone and build-height checks. @@ -44,6 +48,10 @@ public abstract class UnifiedDecorationProtectionMixin { if (!(region.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator generator)) return; // Vanilla rejects writes outside its region before reading the destination chunk. if (!region.isWithinWriteZone(pos)) return; + if (fr.koka.sanctuary.worldgen.underground306.Village306.isPlacing()) { + if (!fr.koka.sanctuary.worldgen.underground306.Village306.mayWrite(pos)) callback.setReturnValue(false); + return; + } if (generator.rootGeneration() == 24 && fr.koka.sanctuary.worldgen.aerial.AerialStructures24.isPlacing()) { if (!fr.koka.sanctuary.worldgen.aerial.AerialStructures24.mayWrite(pos)) callback.setReturnValue(false); return; diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavityMonsterRoom28.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavityMonsterRoom28.java index 952839c..18da98e 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavityMonsterRoom28.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavityMonsterRoom28.java @@ -15,7 +15,7 @@ import net.minecraft.world.level.levelgen.feature.MonsterRoomFeature; /** A few attempts at real cave walls; the vanilla feature owns its shell, loot and spawner. */ public final class CavityMonsterRoom28 { private static final MonsterRoomFeature NATIVE = new MonsterRoomFeature(); - private static final int MAX_ATTEMPTS = 8; + private static final int MAX_ATTEMPTS = 32; private CavityMonsterRoom28() {} public static void decorate(WorldGenLevel level, ChunkGenerator generator, ChunkAccess chunk) { @@ -24,10 +24,9 @@ public final class CavityMonsterRoom28 { var pos = chunk.getPos(); var island = host.islandForChunk(pos.x(), pos.z()); if (island == null || island.generation() != 24) return; long seed = WoodlandGroveGeometry.seed(island.seed(), pos.x(), 28, pos.z(), 0xD086E028L); - if (Math.floorMod(seed, 3) != 0) return; var candidates = new ArrayList(); int bottom = Math.max(level.getMinY() + 2, 32), top = Math.min(level.getMaxY() - 6, 280); - for (int dx : new int[]{4, 11}) for (int dz : new int[]{4, 11}) { + for (int dx : new int[]{4, 8, 11}) for (int dz : new int[]{4, 8, 11}) { int x = pos.getMinBlockX() + dx, z = pos.getMinBlockZ() + dz; for (int y = bottom; y <= top; y++) { BlockPos at = new BlockPos(x, y, z); @@ -46,7 +45,17 @@ public final class CavityMonsterRoom28 { if (attempts++ >= MAX_ATTEMPTS) break; if (!safe(level, host, at, island.id())) continue; // No virtual solid floor/roof and no prebuilt room. Native rejection remains authoritative. - if (NATIVE.place(level, generator, RandomSource.create(WoodlandGroveGeometry.seed(seed, at.getX(), at.getY(), at.getZ(), 0xC0128)), at)) return; + if (NATIVE.place(level, generator, RandomSource.create(WoodlandGroveGeometry.seed(seed, at.getX(), at.getY(), at.getZ(), 0xC0128)), at)) { + // This late native room can carve a floor below vegetation placed earlier. + // Native generation uses quiet writes, so settle those plants locally, without drops. + for(int dy=-1;dy<=5;dy++)for(int dx=-4;dx<=4;dx++)for(int dz=-4;dz<=4;dz++) { + var plant=at.offset(dx,dy,dz);var state=level.getBlockState(plant); + if((state.getBlock() instanceof net.minecraft.world.level.block.VegetationBlock + || state.getBlock() instanceof net.minecraft.world.level.block.SugarCaneBlock) + && !state.canSurvive(level,plant)) level.setBlock(plant,Blocks.AIR.defaultBlockState(),2); + } + return; + } } } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/DetachedRock302.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/DetachedRock302.java index c77504a..4f60788 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/DetachedRock302.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/DetachedRock302.java @@ -14,15 +14,20 @@ public final class DetachedRock302 implements DensitySampler { private final double radius; private final long seed; private final boolean broad; + private final int ceiling; + public DensitySampler input() { return input; } public DetachedRock302(DensitySampler input, double radius, long seed) { this(input, radius, seed, false); } public DetachedRock302(DensitySampler input, double radius, long seed, boolean broad) { - this.input = input; this.radius = radius; this.seed = seed; this.broad = broad; + this(input,radius,seed,broad,376); + } + public DetachedRock302(DensitySampler input, double radius, long seed, boolean broad, int ceiling) { + this.input = input; this.radius = radius; this.seed = seed; this.broad = broad; this.ceiling=ceiling; } private boolean affected(int x, int y, int z) { - if (broad) return y > 170 && y < 376 && Math.hypot(x, z) > 48 && Math.hypot(x,z) < radius + 32; + if (broad) return y > 170 && y < ceiling && Math.hypot(x, z) > 48 && Math.hypot(x,z) < radius + 32; return SurfaceRelief302.weight(x,y,z,radius) * Relief30.influence(seed,x,z,radius) > 0; } @Override public float sampleValue(SamplerContext context,int x,int y,int z) { diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/FloatingTerrain306.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/FloatingTerrain306.java new file mode 100644 index 0000000..c414fdc --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/FloatingTerrain306.java @@ -0,0 +1,65 @@ +package fr.koka.sanctuary.worldgen; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import fr.koka.sanctuary.SanctuaryMod; +import net.minecraft.world.level.levelgen.densityfunction.*; + +/** A few solid, eroded rock bodies in the upper air, not a field of floating debris. */ +public final class FloatingTerrain306 implements DensitySampler { + public record Mass(int x, int y, int z, int rx, int rz, int depth, int rise) {} + private final DensitySampler ground, coarse; + private final double radius; + private final long seed; + private volatile List masses; + public FloatingTerrain306(DensitySampler ground, DensitySampler coarse, double radius, long seed) { + this.ground=ground; this.coarse=coarse; this.radius=radius; this.seed=seed; + } + public List masses(SamplerContext sample) { + if(masses!=null) return masses; + synchronized(this) { + if(masses!=null) return masses; + var random=new Random(seed ^ 0x306A1B0D1EL); var chosen=new ArrayList(); + int count=radius>=500?3:2; + double phase=random.nextDouble()*Math.PI*2; + for(int i=0;i0) {clear=false;break;} + if(clear) {chosen.add(m);break;} + } + masses=List.copyOf(chosen); + SanctuaryMod.LOGGER.info("Floating terrain306 radius {}: {} / {} coherent bodies",radius,masses.size(),count); + return masses; + } + } + @Override public float sampleValue(SamplerContext sample,int x,int y,int z) { + float result=ground.sampleValue(sample,x,y,z); + if(y<300||y>=400||Math.hypot(x,z)>radius*.85) return result; + for(var m:masses(sample)) { + double dx=(x-m.x)/(double)m.rx, dz=(z-m.z)/(double)m.rz; + if(Math.abs(dx)>1.8||Math.abs(dz)>1.8||ym.y+m.rise*1.5) continue; + // Low-frequency 3D erosion changes the silhouette; a solid centre cannot become a shell. + double noise=Math.clamp(2*coarse.sampleValue(sample,x+457,y-231,z-619),-1,1); + dx += .24*Math.clamp(2*coarse.sampleValue(sample,x-331,y+179,z+223),-1,1); + dz += .24*Math.clamp(2*coarse.sampleValue(sample,x+613,y-71,z-337),-1,1); + double edge=1-dx*dx-dz*dz+.72*noise; + double vertical=y 0 ? HighPlateaus304.sourceY(y, province) : y; double weight = geology28 ? CavesRelief28.deformationWeight(x, y, z, capacity.radius()) : 0; - if (plateaus != null) weight *= .8 * plateaus.province(x, z); + if (plateaus != null) weight *= plateauStrength * plateaus.province(x, z); else if (balanced != null) weight *= province; else if (relief30) weight *= Relief30.influence(seed, x, z, capacity.radius()); double surfaceWeight = surface302 ? SurfaceRelief302.weight(x, y, z, capacity.radius()) @@ -140,7 +141,9 @@ public record PopulationIslandDensity(DensityFunction terrain, DensityFunction d DensitySampler.sampleVolumeNaive(sample, buffer, volume, this); } }; - return surface302 || balanced303 ? new DetachedRock302(field,capacity.radius(),seed, balanced303) : field; + if (balanced303 && !historical304 && plateauStrength > .9) + field = new FloatingTerrain306(field, coarse, capacity.radius(), seed); + return surface302 || balanced303 ? new DetachedRock302(field,capacity.radius(),seed, balanced303, field instanceof FloatingTerrain306 ? 412 : 376) : field; } /** Native diagnostics can compile the exact accepted27 field without new adaptations. */ public PopulationIslandDensity legacy27() { 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 c9775ab..64bd729 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 @@ -137,6 +137,12 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { lostCityPlan(island); return cityPlacements21.get(cityIdentity(island)); } + private volatile fr.koka.sanctuary.worldgen.underground306.Village306.Plan village306 = fr.koka.sanctuary.worldgen.underground306.Village306.Plan.EMPTY; + private boolean villagePrepared306; + private boolean minesPrepared306; + private volatile java.util.List starterMines306 = java.util.List.of(); + public java.util.List starterMines306() { return starterMines306; } + public fr.koka.sanctuary.worldgen.underground306.Village306.Plan village306() { return village306; } private volatile Context originContext; private final int generation; private net.minecraft.world.level.LevelHeightAccessor heightAccessor; @@ -189,6 +195,9 @@ 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) { + if (village306.intersects(minX,minY,minZ,maxX,maxY,maxZ)) return true; + for (var mine : starterMines306) for(var piece : mine.start().getPieces()) + if(piece.getBoundingBox().intersects(new net.minecraft.world.level.levelgen.structure.BoundingBox(minX,minY,minZ,maxX,maxY,maxZ))) return true; if (generation == 24) for (var site : aerialPlan24.sites()) { if (minX <= site.maxX() + 1 && maxX >= site.minX() - 1 && minZ <= site.maxZ() + 1 && maxZ >= site.minZ() - 1 && minY <= site.maxY() + 1 && maxY >= site.minY() - 1) return true; @@ -246,6 +255,14 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { prepareRuins(islands); prepareHeritage(islands); prepareTransit(islands); + if (!villagePrepared306 && generation == 24 && cityStructuresEnabled && experimentalStructures) { + villagePrepared306 = true; + village306 = fr.koka.sanctuary.worldgen.underground306.VillagePlanner306.plan(level, this); + } + if (!minesPrepared306 && generation == 24 && cityStructuresEnabled) { + minesPrepared306 = true; + starterMines306 = fr.koka.sanctuary.expansion.ExpansionStructures23.starterMines306(level,this,islands.stream().filter(ExpansionIsland::origin).findFirst().orElseThrow()); + } } @Override public void setIslands(List islands) { // Complete and publish city geometry before workers can observe the new owner list. @@ -320,7 +337,9 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { || ruinsPlan17.protects(pos.getX(), pos.getY(), pos.getZ())); long start = System.nanoTime(); sampled.discoverLandmarks(lostCityPlan(root), ruinsPlan17); - var plan = (generation == 23 || generation == 24) + var plan = generation == 24 + ? fr.koka.sanctuary.worldgen.heritage.HeritagePlanner23.plan306(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled) + : generation == 23 ? fr.koka.sanctuary.worldgen.heritage.HeritagePlanner23.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled) : generation == 22 ? fr.koka.sanctuary.worldgen.heritage.HeritagePlanner22.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled) @@ -550,6 +569,7 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { }); } public boolean protectsCityDecoration(net.minecraft.core.BlockPos pos) { + if (village306.cavern() != null && village306.cavern().getBoundingBox().isInside(pos)) return true; if (generation == 24 && aerialPlan24.protects(pos)) return true; if ((generation == 23 || generation == 24) && mineralSanctuary23.protects(pos.getX(), pos.getY(), pos.getZ())) return true; if (generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22 || (generation == 23 || generation == 24)) return cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ()) diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritagePlanner23.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritagePlanner23.java index 39f0d92..c33497e 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritagePlanner23.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritagePlanner23.java @@ -26,8 +26,14 @@ public final class HeritagePlanner23 { private HeritagePlanner23() {} public static HeritagePlan19 plan(long seed, int cx, int cz, int diameter, Terrain terrain) { + return plan(seed, cx, cz, diameter, terrain, false); + } + public static HeritagePlan19 plan306(long seed, int cx, int cz, int diameter, Terrain terrain) { + return plan(seed, cx, cz, diameter, terrain, true); + } + private static HeritagePlan19 plan(long seed, int cx, int cz, int diameter, Terrain terrain, boolean branching) { if (!List.of(512, 724, 1024).contains(diameter)) throw new IllegalArgumentException("Heritage23 requires a starter size"); - var probe = new Probe(terrain, cx, cz, diameter); + var probe = new Probe(terrain, cx, cz, diameter); probe.branching = branching; probe.columnLimit = SITE_COLUMNS; probe.sampleLimit = SITE_SAMPLES; List landmarks = new ArrayList<>(List.copyOf(terrain.landmarks()).stream().limit(12).toList()); naturalRailEndpoints(seed, cx, cz, diameter, landmarks, probe); @@ -54,7 +60,8 @@ public final class HeritagePlanner23 { } probe.columnLimit = MAX_COLUMNS; probe.sampleLimit = MAX_SAMPLES; var links = new ArrayList(); - naturalRailFragments(seed, landmarks, sites, links, cells, probe); + if (branching) branchingRails306(seed, diameter, landmarks, sites, links, cells, probe); + else naturalRailFragments(seed, landmarks, sites, links, cells, probe); probe.columnLimit = MAX_COLUMNS; probe.sampleLimit = MAX_SAMPLES; var routeResults = new LinkedHashMap(); var routeReasons = new java.util.TreeMap(); probe.reasons = routeReasons; @@ -152,8 +159,8 @@ public final class HeritagePlanner23 { List landmarks, Probe p) { try { double axis = unit(mix(seed ^ 0x23A115L)) * Math.PI * 2; - for (int end = 0; end < 2; end++) for (int attempt = 0; attempt < 16; attempt++) { - double angle = axis + end * Math.PI + (attempt % 2 == 0 ? 1 : -1) * (attempt / 2) * .07; + for (int end = 0; end < (p.branching ? 4 : 2); end++) for (int attempt = 0; attempt < 16; attempt++) { + double angle = axis + end * (p.branching ? Math.PI / 2 : Math.PI) + (attempt % 2 == 0 ? 1 : -1) * (attempt / 2) * .07; double radius = diameter * (attempt < 8 ? .25 : .18); int x = cx + (int) Math.round(Math.cos(angle) * radius); int z = cz + (int) Math.round(Math.sin(angle) * radius); @@ -214,6 +221,69 @@ public final class HeritagePlanner23 { } catch (BudgetExceeded exhausted) { p.reject("fragment-budget"); } } + /** Grow along locally feasible grades, then fork from an actual saved endpoint. */ + private static void branchingRails306(long seed, int diameter, List landmarks, List sites, + List links, Map cells, Probe p) { + p.columnLimit = MAX_COLUMNS - 1_500; p.sampleLimit = MAX_SAMPLES - 80_000; + record Arm(Landmark from, int facing, int depth) {} + var queue = new java.util.ArrayDeque(); + for (var l : landmarks) if (l.id().startsWith("natural_rail_")) queue.add(new Arm(l,l.entry().facing(),0)); + int serial = 0; + try { + while (!queue.isEmpty() && landmarks.size() < 16 && links.size() < 20) { + var arm=queue.removeFirst(); var from=arm.from(); + var path=new ArrayList(); path.add(new XZ(from.entry().x(),from.entry().z())); + RouteDraft best=null; int face=arm.facing(); + String id="rail_branch_"+serial; + var request=new Request("branch_"+serial,from.id(),id,RouteKind.FREIGHT); + int target=diameter/4; + for(int step=0;step chosenPath=null; int chosenFace=face; + var last=path.getLast(); + for(int turn:new int[]{0,1,-1}) { + int direction=(face+turn+4)&3; + // Give long straight runs priority, with curves only where the grade needs them. + var end=last.move(direction,8);int top=p.surface(end.x(),end.z()); + if(top<14||top>350)continue; + if(path.stream().anyMatch(at->Math.abs(at.x()-end.x())+Math.abs(at.z()-end.z())<5))continue; + if(cells.containsKey(key(end.x(),end.z())))continue; + var proposed=new ArrayList<>(path);line(proposed,end); + var endEntry=new Entry(end.x(),top+1,end.z(),(direction+2)&3); + var historical=landmarks.stream().filter(l->!l.id().startsWith("natural_rail_")&&!l.id().startsWith("rail_branch_")).toList(); + var draft=profile(seed,request,new Endpoint(from.id(),from.entry(),false),new Endpoint(id,endEntry,false),proposed,sites,historical,cells,p); + if(draft!=null){chosen=draft;chosenPath=proposed;chosenFace=direction;break;} + } + if(chosen==null)break; + best=chosen;path=new ArrayList<>(chosenPath);face=chosenFace; + } + if(best==null||best.path().size()<17)continue; + var end=best.path().getLast(); + var terminal=new Landmark(id,LandmarkKind.MINE,new Entry(end.x(),end.floorY()+1,end.z(),face)); + landmarks.add(terminal); + if(best.path().size() >= 49 && landmarks.size() < 15 && links.size() < 19) { + int middle=best.path().size()/2;var at=best.path().get(middle);var after=best.path().get(middle+1); + int tangent=direction(at.x(),at.z(),after.x(),after.z()); + var fork=new Landmark(id+"_fork",LandmarkKind.MINE,new Entry(at.x(),at.floorY()+1,at.z(),tangent)); + landmarks.add(fork); + links.add(new Link(request.id()+"_a",request.from(),fork.id(),request.kind(),best.path().subList(0,middle+1))); + links.add(new Link(request.id()+"_b",fork.id(),request.to(),request.kind(),best.path().subList(middle,best.path().size()))); + queue.addFirst(new Arm(fork,(tangent+1)&3,arm.depth()+1)); + queue.addLast(new Arm(fork,(tangent+3)&3,arm.depth()+1)); + } else links.add(new Link(request.id(),request.from(),request.to(),request.kind(),best.path())); + for(var cell:best.cells().values()) { + long key=key(cell.x(),cell.z());var previous=cells.get(key); + if(previous==null)cells.put(key,new OwnedCell(request,cell)); + else if(cell.rail()!=Rail.NONE && previous.cell().rail()!=cell.rail()) { + var c=previous.cell();cells.put(key,new OwnedCell(previous.request(),new Cell(c.x(),c.floorY(),c.z(),c.foundationDepth(),cell.rail(),false))); + } + } + serial++; + // Forward continuation and a lateral branch share their ballast; the switch is repairable. + if(arm.depth()<2){queue.addFirst(new Arm(terminal,(face+(serial%2==0?1:3))&3,arm.depth()+1));queue.addLast(new Arm(terminal,face,arm.depth()+1));} + } + } catch(BudgetExceeded exhausted){p.reject("branched-rail-budget");} + } + 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; @@ -665,7 +735,7 @@ public final class HeritagePlanner23 { 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; + boolean branching; 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(x - cx, z - cz) < 48) return INVALID; diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/underground306/Village306.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/underground306/Village306.java new file mode 100644 index 0000000..c051ccc --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/underground306/Village306.java @@ -0,0 +1,42 @@ +package fr.koka.sanctuary.worldgen.underground306; + +import fr.koka.sanctuary.SanctuaryMod; +import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator; +import java.util.List; +import java.util.Map; +import net.minecraft.core.Registry; +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.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.StructureType; +import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceType; + +public final class Village306 { + public static final ResourceKey KEY = ResourceKey.create(Registries.STRUCTURE, SanctuaryMod.id("underground_abandoned_village")); + public static final StructureType TYPE = Registry.register(BuiltInRegistries.STRUCTURE_TYPE, + SanctuaryMod.id("underground_site_v306"), () -> VillageStructure306.CODEC); + public static final StructurePieceType PIECE = Registry.register(BuiltInRegistries.STRUCTURE_PIECE, + SanctuaryMod.id("village_cavern_v306"), VillageCavern306::new); + public record Plan(StructureStart start, VillageCavern306 cavern, Map diagnostics) { + public static final Plan EMPTY = new Plan(null, null, Map.of()); + public boolean intersects(int x0,int y0,int z0,int x1,int y1,int z1) { + return cavern != null && cavern.getBoundingBox().intersects(new net.minecraft.world.level.levelgen.structure.BoundingBox(x0,y0,z0,x1,y1,z1)); + } + } + private static final ThreadLocal PLACING = new ThreadLocal<>(); + public static void register() {} + public static void placing(StructureStart start, Runnable action) { + VillageCavern306 cavity = start.getPieces().stream().filter(VillageCavern306.class::isInstance) + .map(VillageCavern306.class::cast).findFirst().orElse(null); + var previous = PLACING.get(); + if (cavity != null) PLACING.set(cavity); + try { action.run(); } finally { if (previous == null) PLACING.remove(); else PLACING.set(previous); } + } + public static boolean isPlacing() { return PLACING.get() != null; } + public static boolean mayWrite(net.minecraft.core.BlockPos pos) { var c = PLACING.get(); return c != null && c.getBoundingBox().isInside(pos); } + public static Integer projectedHeight(int x,int z) { var c = PLACING.get(); return c != null && c.projects(x,z) ? c.floorY() : null; } + private Village306() {} +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/underground306/VillageCavern306.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/underground306/VillageCavern306.java new file mode 100644 index 0000000..e2ba2f1 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/underground306/VillageCavern306.java @@ -0,0 +1,106 @@ +package fr.koka.sanctuary.worldgen.underground306; + +import java.util.ArrayList; +import java.util.List; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.StructureManager; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.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; + +/** Saved local cavern. All houses, roads, chests and inhabitants are native village pieces. */ +public final class VillageCavern306 extends StructurePiece { + private final int floorY; + private final long seed; + private final BlockPos entrance; + private final List volumes; + + public VillageCavern306(int floorY, long seed, BlockPos entrance, List volumes) { + super(Village306.PIECE, 0, bounds(volumes)); + this.floorY = floorY; this.seed = seed; this.entrance = entrance.immutable(); + this.volumes = List.copyOf(volumes); setOrientation(null); validate(); + } + public VillageCavern306(StructurePieceSerializationContext context, CompoundTag tag) { + super(Village306.PIECE, tag); + if (tag.getIntOr("Schema", 0) != 1) throw new IllegalArgumentException("Unknown underground306 piece schema"); + floorY = tag.getInt("Floor").orElseThrow(); + seed = tag.getLong("Seed").orElseThrow(); + entrance = new BlockPos(tag.getInt("EntryX").orElseThrow(), tag.getInt("EntryY").orElseThrow(), tag.getInt("EntryZ").orElseThrow()); + List decoded = new ArrayList<>(); + ListTag list = tag.getList("Volumes").orElseThrow(); + if (list.isEmpty() || list.size() > 512) throw new IllegalArgumentException("Invalid underground306 volume count"); + for (int i = 0; i < list.size(); i++) { + var t = list.getCompound(i).orElseThrow(); + decoded.add(new BoundingBox(t.getInt("x0").orElseThrow(), t.getInt("y0").orElseThrow(), t.getInt("z0").orElseThrow(), + t.getInt("x1").orElseThrow(), t.getInt("y1").orElseThrow(), t.getInt("z1").orElseThrow())); + } + volumes = List.copyOf(decoded); + setOrientation(null); validate(); + if (!getBoundingBox().equals(bounds(volumes))) throw new IllegalArgumentException("Underground306 piece bounds mismatch"); + } + private void validate() { + if (floorY < 3 || floorY > 345 || volumes.isEmpty() || volumes.size() > 512 + || getBoundingBox().getXSpan() > 256 || getBoundingBox().getZSpan() > 256 + || getBoundingBox().minY() < 1 || getBoundingBox().maxY() > 358) + throw new IllegalArgumentException("Invalid underground306 geometry"); + } + public static BoundingBox bounds(List volumes) { + return BoundingBox.encapsulatingBoxes(volumes).orElseThrow(); + } + public int floorY() { return floorY; } + public long seed() { return seed; } + public List volumes() { return volumes; } + @Override public BlockPos getLocatorPosition() { return entrance; } + public boolean projects(int x, int z) { + return roofAt(x, z) >= floorY + 2; + } + /** Broad rounded chambers overlap around the complete native graph; no global cave field. */ + public int roofAt(int x, int z) { + int roof = -1; + for (var b : volumes) { + double dx = Math.max(0, Math.max(b.minX() + 8 - x, x - (b.maxX() - 8))) / 8.0; + double dz = Math.max(0, Math.max(b.minZ() + 8 - z, z - (b.maxZ() - 8))) / 8.0; + double edge = dx * dx + dz * dz; + if (edge > 1) continue; + double ripple = Math.sin((x + (seed & 255)) * .071) * Math.cos(z * .063) * 2; + roof = Math.max(roof, b.maxY() - 3 + (int) Math.round(ripple - edge * 5)); + } + return roof; + } + public BlockState terrainOverride(int x, int y, int z) { + if (!getBoundingBox().isInside(x, y, z) || roofAt(x,z) < floorY + 2 || y > roofAt(x,z)) return null; + if (y < floorY - 1) return Blocks.STONE.defaultBlockState(); + if (y == floorY - 1) return Blocks.DIRT.defaultBlockState(); + return Blocks.CAVE_AIR.defaultBlockState(); + } + @Override protected void addAdditionalSaveData(StructurePieceSerializationContext context, CompoundTag tag) { + tag.putInt("Schema", 1); tag.putInt("Floor", floorY); tag.putLong("Seed", seed); + tag.putInt("EntryX", entrance.getX()); tag.putInt("EntryY", entrance.getY()); tag.putInt("EntryZ", entrance.getZ()); + ListTag list = new ListTag(); + for (var b : volumes) { + var t = new CompoundTag(); t.putInt("x0", b.minX()); t.putInt("y0", b.minY()); t.putInt("z0", b.minZ()); + t.putInt("x1", b.maxX()); t.putInt("y1", b.maxY()); t.putInt("z1", b.maxZ()); list.add(t); + } + tag.put("Volumes", list); + } + @Override public void postProcess(WorldGenLevel level, StructureManager manager, ChunkGenerator generator, + RandomSource random, BoundingBox clip, ChunkPos chunk, BlockPos pivot) { + var b = getBoundingBox(); + for (int z = Math.max(b.minZ(), clip.minZ()); z <= Math.min(b.maxZ(), clip.maxZ()); z++) + for (int x = Math.max(b.minX(), clip.minX()); x <= Math.min(b.maxX(), clip.maxX()); x++) + for (int y = b.minY(); y <= b.maxY(); y++) { + var block = terrainOverride(x, y, z); + // Keep existing geology under the walking floor; only bridge local cracks. + if (block != null && !(y < floorY - 1 && level.getBlockState(new BlockPos(x,y,z)).isSolid())) + placeBlock(level, block, x, y, z, clip); + } + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/underground306/VillagePlanner306.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/underground306/VillagePlanner306.java new file mode 100644 index 0000000..5b82dae --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/underground306/VillagePlanner306.java @@ -0,0 +1,173 @@ +package fr.koka.sanctuary.worldgen.underground306; + +import com.google.gson.JsonObject; +import com.mojang.serialization.JsonOps; +import fr.koka.sanctuary.SanctuaryMod; +import fr.koka.sanctuary.expansion.ExpansionChunkGenerator; +import fr.koka.sanctuary.expansion.ExpansionIsland; +import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator; +import java.util.*; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.RegistryOps; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.LevelHeightAccessor; +import net.minecraft.world.level.NoiseColumn; +import net.minecraft.world.level.biome.Biomes; +import net.minecraft.world.level.chunk.PalettedContainerFactory; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.chunk.UpgradeData; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.levelgen.RandomState; +import net.minecraft.world.level.levelgen.densityfunction.SamplerContext; +import net.minecraft.world.level.levelgen.structure.*; +import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer; + +/** Count-bounded search, independent of loaded chunks and machine speed. */ +public final class VillagePlanner306 { + private final SanctuaryChunkGenerator host; + private final ServerLevel level; + private final ExpansionIsland root; + private final Map columns = new HashMap<>(); + private final Map diagnostics = new LinkedHashMap<>(); + private static final int MAX_COLUMNS = 32000; + private VillagePlanner306(ServerLevel level, SanctuaryChunkGenerator host) { + this.level=level; this.host=host; this.root=host.source().islands().stream().filter(ExpansionIsland::origin).findFirst().orElseThrow(); + } + public static Village306.Plan plan(ServerLevel level, SanctuaryChunkGenerator host) { + var p=new VillagePlanner306(level,host); long began=System.nanoTime(); + Village306.Plan result; + try { result=p.search(); } catch (BudgetExceeded exhausted) { p.reject("column-budget"); result=new Village306.Plan(null,null,Map.copyOf(p.diagnostics)); } + SanctuaryMod.LOGGER.info("Village306 seed {} size {}: {}, {} columns, {} ms; {}", level.getSeed(),host.initialDiameter(), + result.start()==null ? "REFUSED" : result.start().getBoundingBox(),p.columns.size(),(System.nanoTime()-began)/1000000,result.diagnostics()); + return result; + } + private void reject(String why) { diagnostics.merge(why,1,Integer::sum); } + private static final class BudgetExceeded extends RuntimeException {} + private NoiseColumn column(int x,int z) { + long key=BlockPos.asLong(x,0,z); var found=columns.get(key); + if(found==null) { if(columns.size()>=MAX_COLUMNS) throw new BudgetExceeded(); found=host.getBaseColumn(x,z,level,host.randomState(root)); columns.put(key,found); } + return found; + } + private boolean solid(int x,int y,int z) { var b=column(x,z).getBlock(y); return b.isSolid() && b.getFluidState().isEmpty(); } + private Village306.Plan search() { + // Prefer the island's interior rock, not its arrival or thin outer rim. + var candidates=new ArrayList(); + double phase=(mix(root.seed()) & 65535)*Math.PI*2/65536; + for(int ring=0;ring<3;ring++) for(int angle=0;angle<8;angle++) { + double a=phase+angle*Math.PI/4+ring*.19, radius=host.initialDiameter()*(.12+ring*.055); + candidates.add(new ChunkPos((int)Math.round(Math.cos(a)*radius)>>4,(int)Math.round(Math.sin(a)*radius)>>4)); + } + for(int ring=0;ring<3;ring++) for(int angle=0;angle<12;angle++) { + double a=phase+angle*Math.PI/6+ring*.17, radius=host.initialDiameter()*(.27+ring*.055); + var at=new ChunkPos((int)Math.round(Math.cos(a)*radius)>>4,(int)Math.round(Math.sin(a)*radius)>>4); + if(!candidates.contains(at))candidates.add(at); + } + for(var chunk:candidates) { + // Full graph once per position, never reduced to fit a cavity. + for(int floor:new int[]{112,144,80,176,208,192}) { + reject("attempts"); + var start=nativeStart(chunk,floor); + if(!start.isValid()) {reject("native-refused");continue;} + long houses=start.getPieces().stream().filter(p->p instanceof PoolElementStructurePiece pool && pool.getElement().toString().contains("/houses/")).count(); + if(houses<6) {reject("too-few-houses");break;} + var volumes=new ArrayList(); + for(var piece:start.getPieces()) { + var b=piece.getBoundingBox(); + volumes.add(new BoundingBox(b.minX()-10,Math.min(floor-10,b.minY()-2),b.minZ()-10,b.maxX()+10,Math.max(floor+19,b.maxY()+12),b.maxZ()+10)); + } + var bounds=VillageCavern306.bounds(volumes); + if(bounds.getXSpan()>240 || bounds.getZSpan()>240 || bounds.minX()chunk.getMaxBlockX()+128 + || bounds.minZ()chunk.getMaxBlockZ()+128) {reject("reference-range");continue;} + if(host.intersectsExperimentalStructures(bounds.minX(),bounds.minY(),bounds.minZ(),bounds.maxX(),bounds.maxY(),bounds.maxZ())) {reject("reserved");continue;} + boolean outside=false; + for(var b:volumes) if(!root.contains(b.minX(),b.minZ()) || !root.contains(b.maxX(),b.maxZ())) {outside=true;break;} + if(outside) {reject("outside-island");continue;} + var cavity=new VillageCavern306(floor,mix(root.seed()^chunk.pack()),new BlockPos(chunk.getMiddleBlockX(),floor,chunk.getMiddleBlockZ()),volumes); + String refusal=certify(cavity); + if(refusal!=null) {reject(refusal);continue;} + var entrance=access(start,cavity,volumes,chunk); + if(entrance==null) {reject("no-access");continue;} + cavity=new VillageCavern306(floor,mix(root.seed()^chunk.pack()),entrance,volumes); + var completed=cavity.getBoundingBox(); + if(host.intersectsExperimentalStructures(completed.minX(),completed.minY(),completed.minZ(),completed.maxX(),completed.maxY(),completed.maxZ()) + || !root.contains(completed.minX(),completed.minZ()) || !root.contains(completed.maxX(),completed.maxZ()) + || completed.minX()chunk.getMaxBlockX()+128 + || completed.minZ()chunk.getMaxBlockZ()+128) {reject("access-reservation");continue;} + if(certify(cavity)!=null) {reject("access-terrain");continue;} + List pieces=new ArrayList<>(); pieces.add(cavity);pieces.addAll(start.getPieces()); + var complete=new StructureStart(level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(Village306.KEY).value(),chunk,0,new PiecesContainer(pieces)); + diagnostics.put("houses",(int)houses);diagnostics.put("native-pieces",start.getPieces().size());diagnostics.put("columns",columns.size()); + return new Village306.Plan(complete,cavity,Map.copyOf(diagnostics)); + } + } + reject("no-fitting-site");return new Village306.Plan(null,null,Map.copyOf(diagnostics)); + } + private String certify(VillageCavern306 cavity) { + var b=cavity.getBoundingBox(); int count=0,floors=0,roofs=0; + for(int z=b.minZ()+2;z<=b.maxZ();z+=6) for(int x=b.minX()+2;x<=b.maxX();x+=6) { + int roof=cavity.roofAt(x,z), floor=cavity.floorY(); if(roof volumes,ChunkPos origin) { + int floor=cave.floorY(); var centre=start.getBoundingBox().getCenter(); + var streets=start.getPieces().stream().filter(p->p instanceof PoolElementStructurePiece pool && pool.getElement().toString().contains("/streets/")) + .sorted(Comparator.comparingDouble((StructurePiece p)->p.getBoundingBox().getCenter().distSqr(centre)).reversed()).toList(); + for(var street:streets) { + var at=street.getBoundingBox().getCenter(); + for(var dir:net.minecraft.core.Direction.Plane.HORIZONTAL) { + List path=new ArrayList<>(); + for(int step=0;step<=48;step+=3) { + int x=at.getX()+dir.getStepX()*step,z=at.getZ()+dir.getStepZ()*step; + if(Math.abs(x-origin.getMiddleBlockX())>116 || Math.abs(z-origin.getMiddleBlockZ())>116 || !root.contains(x,z))break; + var box=new BoundingBox(x-8,floor-5,z-8,x+8,floor+12,z+8); + if(host.intersectsExperimentalStructures(box.minX(),box.minY(),box.minZ(),box.maxX(),box.maxY(),box.maxZ()))break; + if(!solid(x,floor-5,z))break; + boolean fluid=false;for(int y=floor-5;y<=floor+6;y++)if(!column(x,z).getBlock(y).getFluidState().isEmpty())fluid=true; + if(fluid)break; + // Narrow connector volumes must retain the same eight-block shoulder convention. + path.add(new BoundingBox(x-8,floor-5,z-8,x+8,floor+12,z+8)); + if(cave.roofAt(x,z)<0 && column(x,z).getBlock(floor).isAir() && column(x,z).getBlock(floor+2).isAir()) { + volumes.addAll(path);return new BlockPos(x,floor,z); + } + } + } + } + // Natural openings already intersecting a chamber are useful entrances too. + var b=cave.getBoundingBox(); + for(int z=b.minZ();z<=b.maxZ();z+=4)for(int x=b.minX();x<=b.maxX();x+=4) + if(cave.projects(x,z)&&column(x,z).getBlock(floor).isAir()&&column(x,z).getBlock(floor+2).isAir()&&solid(x,floor-5,z))return new BlockPos(x,floor,z); + return null; + } + private StructureStart nativeStart(ChunkPos chunk,int floor) { + var registry=level.registryAccess().lookupOrThrow(Registries.STRUCTURE); var ops=RegistryOps.create(JsonOps.INSTANCE,level.registryAccess()); + var original=registry.getValue(Identifier.withDefaultNamespace("village_plains")); + JsonObject json=Structure.DIRECT_CODEC.encodeStart(ops,original).getOrThrow().getAsJsonObject().deepCopy(); + json.addProperty("start_pool","sanctuary:underground306/zombie_plains_starts");json.addProperty("size",6);json.addProperty("max_distance_from_center",80); + json.addProperty("terrain_adaptation","none");json.remove("project_start_to_heightmap"); + var height=new JsonObject();height.addProperty("absolute",floor);json.add("start_height",height); + var structure=Structure.DIRECT_CODEC.parse(ops,json).getOrThrow();var random=host.randomState(root); + var proto=new ProtoChunk(chunk,UpgradeData.EMPTY,level,PalettedContainerFactory.create(level.registryAccess()),null); + return structure.generate(registry.getOrThrow(Village306.KEY),level.dimension(),level.registryAccess(),new FloorView(host,level,floor),host.getBiomeSource(), + random.createClimateSampler(SamplerContext.builder().enableCaches().build()),random,level.getStructureTemplateManager(),root.seed(),chunk,0,proto,b->!b.is(Biomes.DEEP_DARK)); + } + private static final class FloorView extends ExpansionChunkGenerator { + final SanctuaryChunkGenerator host;final int floor; + FloorView(SanctuaryChunkGenerator host,ServerLevel level,int floor){super(host.source(),host.initialDiameter(),true);this.host=host;this.floor=floor;registries=level.registryAccess();} + @Override public int getBaseHeight(int x,int z,Heightmap.Types type,LevelHeightAccessor height,RandomState state){return floor;} + @Override public NoiseColumn getBaseColumn(int x,int z,LevelHeightAccessor height,RandomState state){return host.getBaseColumn(x,z,height,state);} + @Override public RandomState randomState(ExpansionIsland island){return host.randomState(island);} + } + private static long mix(long v){v=(v^(v>>>30))*0xbf58476d1ce4e5b9L;v=(v^(v>>>27))*0x94d049bb133111ebL;return v^(v>>>31);} +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/underground306/VillageStructure306.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/underground306/VillageStructure306.java new file mode 100644 index 0000000..27b33af --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/underground306/VillageStructure306.java @@ -0,0 +1,11 @@ +package fr.koka.sanctuary.worldgen.underground306; +import com.mojang.serialization.MapCodec; +import java.util.Optional; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureType; +public final class VillageStructure306 extends Structure { + public static final MapCodec CODEC = simpleCodec(VillageStructure306::new); + public VillageStructure306(StructureSettings settings) { super(settings); } + @Override protected Optional findGenerationPoint(GenerationContext context) { return Optional.empty(); } + @Override public StructureType type() { return Village306.TYPE; } +} 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 e85950b..18f37bc 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 @@ -210,6 +210,7 @@ "sanctuary.world_options.secrets23_hint": "Natural island, mineral secrets and abandoned railways. Vanilla structures remain possible. Expansions may contain oceans.", "sanctuary.expansion.relief.ocean": "Oceanic continent", "sanctuary.expansion.error.native_structure_unavailable": "No suitable structure found: expansion was not reserved.", + "structure.sanctuary.underground_abandoned_village": "Underground abandoned village", "structure.sanctuary.mineral_sanctuary": "Mineral sanctuary", "sanctuary.structures.census.unavailable": "Structure registry unavailable: coverage is unknown.", "sanctuary.structures.census.summary": "Structures: %s starts, %s monster rooms, %s observed chunks.", 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 8f10ea1..0ddbaab 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 @@ -210,6 +210,7 @@ "sanctuary.world_options.secrets23_hint": "Île naturelle, secrets minéraux et voies abandonnées. Les structures vanilla restent possibles. Les expansions peuvent accueillir des océans.", "sanctuary.expansion.relief.ocean": "Continent océanique", "sanctuary.expansion.error.native_structure_unavailable": "Aucune structure adaptée trouvée : expansion non réservée.", + "structure.sanctuary.underground_abandoned_village": "Village abandonné souterrain", "structure.sanctuary.mineral_sanctuary": "Sanctuaire minéral", "sanctuary.structures.census.unavailable": "Registre des structures indisponible : couverture inconnue.", "sanctuary.structures.census.summary": "Structures : %s départs, %s donjons, %s chunks observés.", diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/structure/underground_abandoned_village.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/structure/underground_abandoned_village.json new file mode 100644 index 0000000..af3db5a --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/structure/underground_abandoned_village.json @@ -0,0 +1,7 @@ +{ + "type": "sanctuary:underground_site_v306", + "biomes": "#minecraft:is_overworld", + "step": "top_layer_modification", + "terrain_adaptation": "none", + "spawn_overrides": {} +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/template_pool/underground306/zombie_plains_starts.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/template_pool/underground306/zombie_plains_starts.json new file mode 100644 index 0000000..3e2a4fc --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/template_pool/underground306/zombie_plains_starts.json @@ -0,0 +1,41 @@ +{ + "elements": [ + { + "element": { + "element_type": "minecraft:legacy_single_pool_element", + "location": "minecraft:village/plains/zombie/town_centers/plains_fountain_01", + "processors": "minecraft:zombie_plains", + "projection": "rigid" + }, + "weight": 1 + }, + { + "element": { + "element_type": "minecraft:legacy_single_pool_element", + "location": "minecraft:village/plains/zombie/town_centers/plains_meeting_point_1", + "processors": "minecraft:zombie_plains", + "projection": "rigid" + }, + "weight": 1 + }, + { + "element": { + "element_type": "minecraft:legacy_single_pool_element", + "location": "minecraft:village/plains/zombie/town_centers/plains_meeting_point_2", + "processors": "minecraft:zombie_plains", + "projection": "rigid" + }, + "weight": 1 + }, + { + "element": { + "element_type": "minecraft:legacy_single_pool_element", + "location": "minecraft:village/plains/zombie/town_centers/plains_meeting_point_3", + "processors": "minecraft:zombie_plains", + "projection": "rigid" + }, + "weight": 1 + } + ], + "fallback": "minecraft:empty" +} diff --git a/mods/sanctuary/src/main/resources/sanctuary.mixins.json b/mods/sanctuary/src/main/resources/sanctuary.mixins.json index a800dc4..a9617c0 100644 --- a/mods/sanctuary/src/main/resources/sanctuary.mixins.json +++ b/mods/sanctuary/src/main/resources/sanctuary.mixins.json @@ -33,7 +33,9 @@ "StructureCensusMonsterRoomMixin", "RuinedPortal23Accessor", "IglooHeight23Mixin", - "ScatteredHeight23Accessor" + "ScatteredHeight23Accessor", + "UndergroundHeight306Mixin", + "UndergroundStart306Mixin" ], "client": [ "client.SanctuarySkyMixin", diff --git a/packwiz/pack.toml b/packwiz/pack.toml index 8a58e0e..5ad3221 100644 --- a/packwiz/pack.toml +++ b/packwiz/pack.toml @@ -1,6 +1,6 @@ name = "Sanctuary" author = "KOKA99CAB" -version = "0.1.0-alpha.30.5" +version = "0.1.0-alpha.30.6" pack-format = "packwiz:1.1.0" [index] diff --git a/scripts/archive_terrain_atlas.py b/scripts/archive_terrain_atlas.py index 88da977..b81d152 100644 --- a/scripts/archive_terrain_atlas.py +++ b/scripts/archive_terrain_atlas.py @@ -20,6 +20,7 @@ def main(): parser.add_argument("--rendered", type=Path, required=True) parser.add_argument("--jar", type=Path, required=True) parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--extra", type=Path, action="append", default=[]) args = parser.parse_args() if args.output.exists(): raise SystemExit("Refusing to overwrite an existing immutable atlas") @@ -48,14 +49,20 @@ def main(): files["raw/" + source.name] = raw for name in ("maps.png", "maps.pdf", "sections.png", "sections.pdf", "summary.json", "provenance.json"): files[f"figures/{source.stem}/{name}"] = (rendered / name).read_bytes() + for name in ("upper-terrain.png", "upper-terrain.pdf"): + if (rendered / name).is_file(): files[f"figures/{source.stem}/{name}"] = (rendered / name).read_bytes() cases.append({"seed": data["seed"], "diameter": data["diameter"], "directory": source.stem}) identities = {(c["seed"], c["diameter"]) for c in cases} if not cases or len(identities) != len(cases): raise SystemExit("Empty atlas or duplicate seed/size") for name in ("scripts/terrain_atlas.py", "scripts/terrain-atlas-requirements.txt", "scripts/archive_terrain_atlas.py", "docs/terrain-atlas.md", - "docs/generation-alpha30.5.md", "docs/testing-alpha30.5.md"): + "scripts/structure_atlas.py", f"docs/generation-alpha{version.split('alpha.')[-1]}.md", f"docs/testing-alpha{version.split('alpha.')[-1]}.md"): files[name] = (ROOT / name).read_bytes() + for path in args.extra: + key = "structures/" + path.name + if key in files: raise SystemExit("Duplicate extra atlas file") + files[key] = path.read_bytes() rows = ["# Sanctuary — atlas " + version, "", f"Source : `{commit}`.", "", "Mesures natives avant décoration ; voir docs/terrain-atlas.md pour les échelles et limites.", "", "| Diamètre | Graine | Cartes | Coupes |", "|---|---|---|---|"] diff --git a/scripts/structure_atlas.py b/scripts/structure_atlas.py new file mode 100644 index 0000000..60002a7 --- /dev/null +++ b/scripts/structure_atlas.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Plot observed village blocks and planned surface rails against the native terrain atlas.""" +import argparse,json,hashlib +from pathlib import Path +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt +from matplotlib.colors import ListedColormap +import numpy as np + +def render(source, terrain, output): + data=json.loads(source.read_text()); native=json.loads(terrain.read_text()) + if not data.get('passed') or data['seed']!=str(native['seed']) or data['diameter']!=native['diameter']: + raise ValueError('Mismatched or failed native witnesses') + output.mkdir(parents=True,exist_ok=True) + name=f"structures-{data['diameter']}-{data['seed']}" + columns=np.asarray(native['variants']['current306']['columns']) + xs,zs=np.unique(columns[:,0]),np.unique(columns[:,1]);grid=columns.reshape(len(zs),len(xs),5) + extent=(xs[0]-6,xs[-1]+6,zs[-1]+6,zs[0]-6) + fig,ax=plt.subplots(figsize=(10,10),layout='constrained') + img=ax.imshow(np.ma.masked_where(grid[:,:,2]==0,grid[:,:,2]),extent=extent,cmap='terrain',vmin=0,vmax=400,interpolation='nearest') + freight=[l for l in data['rail_links'] if l['kind']=='FREIGHT'] + rail_area=len({(c['x'],c['z']) for c in data['rail_cells'] if c['rail']!='NONE'}) + ballast=len({(c['x'],c['z']) for c in data['rail_cells']}) + land=int(np.count_nonzero(grid[:,:,2]))*native['map_step']**2 + upper=int(np.count_nonzero(grid[:,:,2]>=180))*native['map_step']**2 + for link in data['rail_links']: + points=link['points'];ax.plot([p['x'] for p in points],[p['z'] for p in points],color='#601d15' if link['kind']=='FREIGHT' else '#404040',lw=1.5 if link['kind']=='FREIGHT' else .9) + ax.scatter([0],[0],marker='+',s=70,c='black',label='Origine naturelle protégée') + ax.plot([],[],color='#601d15',label='Voies ferrées prévues (lacunes réparables)') + ax.plot([],[],color='#404040',label='Chemins prévus') + ax.set(xlabel='X (blocs)',ylabel='Z (blocs) · nord en haut',aspect='equal') + ax.set_title(f"Sanctuary {data['diameter']} · graine {data['seed']}\nRéseau de surface sur le relief natif 30.6") + ax.legend(fontsize=9,loc='upper left');fig.colorbar(img,ax=ax,shrink=.72,label='Altitude du terrain (Y)') + fig.supxlabel(f"{rail_area} colonnes de voie · {ballast} colonnes de sol aménagé (chemins inclus)\n" + f"{100*ballast/land:.2f} % de l’emprise terrestre projetée estimée ; {100*ballast/upper:.2f} % des surfaces ≥ Y180.\n" + "Relief échantillonné tous les 12 blocs ; tracés issus du plan, avant placement complet des rails.",fontsize=9) + for ext in ['png','pdf']:fig.savefig(output/f'{name}-reseau.{ext}',dpi=160) + plt.close(fig) + if 'floor_cut' in data: + floor=data['floor_cut']; ids=np.array(floor['blocks']); y=floor['y'] + def category(s): + if s in ('minecraft:air','minecraft:cave_air','minecraft:void_air'):return 0 + if s in ('minecraft:stone','minecraft:dirt','minecraft:granite','minecraft:andesite','minecraft:diorite','minecraft:deepslate','minecraft:tuff'):return 1 + if 'water' in s:return 4 + if 'cobweb' in s:return 3 + return 2 + pixels=np.vectorize(category)(ids) + fig,axes=plt.subplots(1,3,figsize=(18,7),layout='constrained') + palette=ListedColormap(['#eef1f0','#666d70','#b98651','#976da6','#489dc8']) + axes[0].imshow(pixels,extent=(floor['min_x']-.5,floor['min_x']+ids.shape[1]-.5,floor['min_z']+ids.shape[0]-.5,floor['min_z']-.5),cmap=palette,vmin=0,vmax=4,interpolation='nearest',aspect='equal') + axes[0].set_title(f'Coupe horizontale Y{y}\nBrun : constructions / décor ; violet : toiles') + axes[0].set(xlabel='X (blocs)',ylabel='Z (blocs)') + for ax,cut in zip(axes[1:],data['native_cuts']): + blocks=np.asarray(cut['blocks']);step=cut['step'] + ax.imshow(blocks,extent=(cut['min_x']-1,cut['min_x']+blocks.shape[1]*step-1,-1,301),cmap=ListedColormap(['#eef1f0','#666d70','#489dc8','#ea8542']),vmin=0,vmax=3,interpolation='nearest',aspect='equal') + ax.axhline(data['floor'],color='#b98651',lw=.8,ls='--');ax.set(xlabel='X (blocs)',ylabel='Y (blocs)');ax.set_title(f"Coupe verticale Z{cut['z']}\nTrait : sol du village") + fig.suptitle(f"Village abandonné · diamètre {data['diameter']} · graine {data['seed']}\nBlocs réellement générés, après structures et décoration",fontsize=15) + fig.supxlabel(f"{data['diagnostics']['houses']} maisons natives · {data['chests']} coffres · {data['zombie_villagers']} zombie-villageois\n" + "Échantillonnage horizontal : 1 bloc ; vertical : 2 blocs. Aucune interpolation d’affichage.",fontsize=10) + for ext in ['png','pdf']:fig.savefig(output/f'{name}-village.{ext}',dpi=160) + plt.close(fig) + result={'seed':data['seed'],'diameter':data['diameter'],'rail_columns':rail_area,'prepared_columns_including_paths':ballast,'estimated_land_area':land, + 'ratio_percent':100*ballast/land,'ratio_upper_percent':100*ballast/upper,'structure_source_sha256':hashlib.sha256(source.read_bytes()).hexdigest(), + 'terrain_source_sha256':hashlib.sha256(terrain.read_bytes()).hexdigest()} + (output/f'{name}-summary.json').write_text(json.dumps(result,indent=2)+'\n') + print(json.dumps(result)) + +if __name__=='__main__': + p=argparse.ArgumentParser(description=__doc__);p.add_argument('source',type=Path);p.add_argument('terrain',type=Path);p.add_argument('--output',type=Path,required=True);a=p.parse_args();render(a.source,a.terrain,a.output) diff --git a/scripts/terrain_atlas.py b/scripts/terrain_atlas.py index 0ad8ceb..072ce59 100644 --- a/scripts/terrain_atlas.py +++ b/scripts/terrain_atlas.py @@ -20,6 +20,7 @@ LABELS = { "global301": "Bruit 3D global · 30.1", "stretch304": "Étirement vertical · 30.4", "current305": "Plateaux + déformation 3D · 30.5", + "current306": "Relief légèrement renforcé · 30.6", } @@ -32,8 +33,9 @@ def save(fig, directory, name): def render(source, output): raw = source.read_bytes() data = json.loads(raw) - if data["schema"] != 1 or list(data["variants"]) != list(LABELS): + if data["schema"] != 1 or len(data["variants"]) != 4 or any(k not in LABELS for k in data["variants"]): raise ValueError(f"Unsupported atlas schema/variants: {source}") + labels = {key: LABELS[key] for key in data["variants"]} target = output / source.stem target.mkdir(parents=True, exist_ok=True) title = f"Sanctuary · diamètre {data['diameter']} · graine {data['seed']}" @@ -51,11 +53,11 @@ def render(source, output): extent = (xs[0] - step / 2, xs[-1] + step / 2, zs[-1] + step / 2, zs[0] - step / 2) fig, axes = plt.subplots(3, 4, figsize=(18, 14), layout="constrained", sharex=True, sharey=True) fig.suptitle(title + "\nRelief et épaisseur de roche", fontsize=18) - metrics = [(2, "Altitude du sommet (Y)", "terrain", 0, 360), + metrics = [(2, "Altitude du sommet (Y)", "terrain", 0, data["domain_y"][1]), (3, "Épaisseur solide cumulée (blocs)", "viridis", 0, 300), (4, "Roche continue sous le sommet (blocs)", "magma", 0, 100)] for row, (index, label, cmap, vmin, vmax) in enumerate(metrics): - for col, (key, grid) in enumerate(zip(LABELS, maps)): + for col, (key, grid) in enumerate(zip(labels, maps)): ax = axes[row, col] values = np.ma.masked_where(grid[:, :, 2] == 0, grid[:, :, index]) image = ax.imshow(values, extent=extent, origin="upper", interpolation="nearest", @@ -65,7 +67,7 @@ def render(source, output): if col == 0: ax.set_ylabel("Z (blocs) · nord en haut") if row == 0: - ax.set_title(LABELS[key], fontsize=11) + ax.set_title(labels[key], fontsize=11) ax.plot(0, 0, "+", color="black", ms=8, label="Origine") fig.colorbar(image, ax=axes[row, :], shrink=.85, label=label, extend="max") fig.supxlabel(f"Pas horizontal : {step} blocs · vertical : {data['vertical_step']} blocs · gris = aucun solide échantillonné.\n" + provenance, fontsize=9) @@ -74,7 +76,7 @@ def render(source, output): section_count = len(variants[0]["sections"]) fig, axes = plt.subplots(4, section_count, figsize=(20, 12), layout="constrained", sharex=True, sharey=True) fig.suptitle(title + "\nCoupes de densité · proportions spatiales conservées", fontsize=18) - for row, (key, variant) in enumerate(zip(LABELS, variants)): + for row, (key, variant) in enumerate(zip(labels, variants)): for col, section in enumerate(variant["sections"]): ax = axes[row, col] density = np.asarray(section["density"], dtype=float) / data["density_scale"] @@ -87,17 +89,30 @@ def render(source, output): if row == 0: ax.set_title(f"Plan vertical Z = {section['z']}") if col == 0: - ax.set_ylabel(LABELS[key] + "\nY (blocs)", fontsize=10) + ax.set_ylabel(labels[key] + "\nY (blocs)", fontsize=10) ax.set_xlabel("X (blocs)") - ax.set_ylim(0, 384) + ax.set_ylim(0, data["domain_y"][1] + 1) fig.colorbar(image, ax=axes, shrink=.6, label="Densité sans unité · bleu : vide (≤ 0) · rouge : roche (> 0)", extend="both") fig.supxlabel("Coupes échantillonnées tous les 2 blocs, sans interpolation d’affichage ; isosurface zéro en noir.\n" + provenance, fontsize=9) save(fig, target, "sections") + upper = data["variants"].get("current306", {}).get("upper_sections", []) + if upper: + fig, axes = plt.subplots(1, len(upper), figsize=(5*len(upper), 6), layout="constrained", squeeze=False) + fig.suptitle(title + "\nMorceaux de terrain supérieur · densité native", fontsize=15) + for ax, cut in zip(axes[0], upper): + values = np.array(cut["solid"]) + dy=cut["step"] + ax.imshow(values, cmap="Greys", vmin=0, vmax=1, interpolation="nearest", aspect="equal", + extent=(cut["min_x"]-1,cut["min_x"]+values.shape[1]*dy-1,cut["max_y"]-values.shape[0]*dy+1,cut["max_y"]+1)) + ax.set_title(f"Z = {cut['z']}"); ax.set_xlabel("X");ax.set_ylabel("Y") + fig.supxlabel("Noir : roche · blanc : air. Aucune interpolation d’affichage. Avant végétation et structures.",fontsize=9) + save(fig,target,"upper-terrain") + summary = {"seed": data["seed"], "diameter": data["diameter"], "variants": {}} base = maps[0][:, :, 2] common = np.logical_and.reduce([m[:, :, 2] >= 200 for m in maps]) - for key, grid in zip(LABELS, maps): + for key, grid in zip(labels, maps): land = grid[:, :, 2] > 0 uplands = base >= 200 summary["variants"][key] = {