From d5cd8954034ee805210add258f77939b4739048b Mon Sep 17 00:00:00 2001 From: koka Date: Sat, 12 Sep 2026 20:29:32 +0200 Subject: [PATCH] Finish alpha.30.7 with natural island treasures, direct rails and dry ruins --- README.md | 11 ++ docs/generation-alpha30.7.md | 66 ++++++++++++ docs/terrain-atlas.md | 26 +++++ docs/testing-alpha30.7.md | 91 ++++++++++++++++ gradle.properties | 4 +- mods/sanctuary/build.gradle | 9 ++ .../gametest/Aerial24WorldGameTests.java | 30 ++++-- .../gametest/Drained307GameTests.java | 62 +++++++++++ .../gametest/Transit29WorldGameTests.java | 21 +++- .../src/gametest/resources/fabric.mod.json | 2 +- .../java/fr/koka/sanctuary/SanctuaryMod.java | 1 + .../expansion/ExpansionChunkGenerator.java | 6 ++ .../expansion/ExpansionStructures23.java | 1 + .../UnifiedDecorationProtectionMixin.java | 13 +++ .../worldgen/SanctuaryChunkGenerator.java | 24 ++++- .../worldgen/aerial/AerialGeometry24.java | 2 + .../worldgen/aerial/AerialPiece24.java | 1 + .../worldgen/aerial/AerialPlan24.java | 24 +++-- .../worldgen/aerial/AerialPlanner24.java | 28 ++++- .../worldgen/aerial/AerialRenderer24.java | 5 +- .../worldgen/aerial/AerialStructures24.java | 4 +- .../worldgen/aerial/NaturalIslet307.java | 39 +++++++ .../worldgen/drained307/DryOcean307.java | 71 ++++++++++++ .../worldgen/drained307/DryOceanPiece307.java | 102 ++++++++++++++++++ .../drained307/DryOceanStructure307.java | 11 ++ .../worldgen/heritage/HeritagePlanner23.java | 18 +++- .../worldgen/transit/TransitPlan21.java | 4 +- .../worldgen/transit/TransitPlanner29.java | 21 ++++ .../assets/sanctuary/lang/en_us.json | 3 +- .../assets/sanctuary/lang/fr_fr.json | 3 +- .../structure/drained_ocean_ruin.json | 7 ++ packwiz/pack.toml | 2 +- scripts/archive_terrain_atlas.py | 2 +- scripts/finishing_atlas.py | 46 ++++++++ 34 files changed, 715 insertions(+), 45 deletions(-) create mode 100644 docs/generation-alpha30.7.md create mode 100644 docs/testing-alpha30.7.md create mode 100644 mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Drained307GameTests.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/NaturalIslet307.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/drained307/DryOcean307.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/drained307/DryOceanPiece307.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/drained307/DryOceanStructure307.java create mode 100644 mods/sanctuary/src/main/resources/data/sanctuary/worldgen/structure/drained_ocean_ruin.json create mode 100644 scripts/finishing_atlas.py diff --git a/README.md b/README.md index dcc73f0..38dec24 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,17 @@ 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.7 — trésors aériens et vestiges asséchés + +Le relief de 30.6 est conservé. Les nouveaux morceaux de terrain aérien reçoivent +les minerais et trésors des anciens îlots artificiels, retirés de la planification. +Les rails privilégient les lignes directes. Une seconde traversée souterraine et +quelques petites ruines océaniques asséchées sont cherchées lorsque le terrain +les accueille. + +Voir [la génération](docs/generation-alpha30.7.md) et [les essais](docs/testing-alpha30.7.md). +**Nouveau monde requis.** + ## 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 diff --git a/docs/generation-alpha30.7.md b/docs/generation-alpha30.7.md new file mode 100644 index 0000000..70d9a66 --- /dev/null +++ b/docs/generation-alpha30.7.md @@ -0,0 +1,66 @@ +# Alpha.30.7 — dernières installations de la génération + +Cette passe conserve le relief et les morceaux de terrain naturel de l’alpha.30.6. +Les fonctions de densité, cavités, rivières, biomes et tailles ne changent pas. +Minecraft reste en 26.3-pre-2 ; plafond à Y640, Moyen 724 par défaut. +Créer un nouveau monde pour voir la nouvelle répartition des installations. + +## Trésors dans le terrain aérien + +Les deux anciens îlots minéraux construits comme des structures ne sont plus +planifiés dans les nouveaux mondes. Les deux morceaux naturels de Petit et Moyen, +ou les trois de Grand, portent désormais chacun un coffre enterré et des filons. +Le coffre utilise la table de richesses aériennes existante, uniquement vanilla. +Aucun objet de quête n’est ajouté. + +L’enrichissement remplace seulement la roche existante entre Y300 et Y399 : +charbon, cuivre et fer dans les couches supérieures ; or, lapis, redstone, diamant +et émeraude plus à l’intérieur. La profondeur est mesurée dans chaque colonne de +l’îlot. Les réserves sont finies, sans renouvellement après minage ou pillage. +Le profil est volontairement très riche ; les comptages par minerai figurent +dans les rapports aériens de l’atlas. La forme, le sol et la végétation de ces morceaux naturels restent ceux de 30.6. +L’ancienne cascade artificielle disparaît avec son ancien îlot. + +Les deux navires restaurés restent près de leurs côtes. Les nouveaux trésors +possèdent des départs natifs, des pièces sauvegardées et des réservations limitées +à leurs emprises. Ils utilisent l’identifiant de localisation +`sanctuary:aerial_mineral_islet`. Le terrain aérien naturel existe également quand +les structures sont désactivées ; ses coffres et son enrichissement expérimental +suivent les options de structures. + +## Rails et traversées + +Chaque prolongement ferroviaire garde son axe. Un terrain incompatible termine +le parcours au lieu de provoquer une succession de virages. Les embranchements +partent des longues lignes principales ; les extensions successives ne produisent +plus de branches tournantes récursives. Une séparation évite les lignes presque +parallèles autour d’une même voie. Cela peut réduire la longueur totale du réseau : +les 6–8 % anciennement évoqués ne constituent pas un quota. + +Après la première traversée souterraine, une seconde recherche est effectuée dans +l’espace restant. Elle respecte les mêmes règles de sol, couverture et accès, +avec une séparation de 24 blocs autour des modules de la première. Si elle ne +tient pas dans le terrain et le budget de recherche, la première reste conservée. +Chaque traversée possède ses deux accès ; aucun croisement aveugle n’est creusé +pour forcer la seconde. + +## Ruines d’un ancien fond marin + +`sanctuary:drained_ocean_ruin` place quelques petits templates vanilla de ruines +océaniques chaudes dans les cavités sèches existantes entre Y60 et Y174. Trois +emplacements sont recherchés, quatre en Grand, sans garantie de quantité. Les +refus de sol, dégagement ou réservation sont journalisés. Aucun nouveau grand +volume souterrain n’est creusé pour les accueillir. + +Les modèles vanilla habités (`warm_2`, `warm_3`, `warm_4`, `warm_5`, `warm_8`), +leur dégradation et leurs tables de butin sont conservés. +Les marqueurs d’habitants placent des zombies vanilla persistants ; des coraux +morts complètent les vestiges et sont posés avec `waterlogged=false`. Les blocs +gorgés d’eau sont asséchés. La protection contre la décoration ultérieure reste +limitée à la petite emprise de chaque ruine. Les pièces +sauvegardées conservent leur altitude souterraine au lieu de recalculer un fond +océanique lors du rechargement. Ces ruines Sanctuary ont leur propre identifiant ; +elles ne remplacent pas les ruines aquatiques des expansions océaniques. + +Voir [les vérifications](testing-alpha30.7.md). La structure envisagée à Y600 reste +une idée ; cette livraison n’ajoute rien à cette hauteur. diff --git a/docs/terrain-atlas.md b/docs/terrain-atlas.md index e4e6bb2..eb8e009 100644 --- a/docs/terrain-atlas.md +++ b/docs/terrain-atlas.md @@ -100,3 +100,29 @@ build/atlas-venv/bin/python scripts/structure_atlas.py \ mods/sanctuary/build/run/gameTest/diagnostics/atlas306/terrain-724-0.json \ --output build/atlas306/structures ``` + +## Finition 30.7 + +Le terrain de 30.6 est conservé. L’export natif est répété sur les neuf couples +512 / 724 / 1 024 × 0 / 42 / -7228211907433324401 pour comparer exactement les +colonnes et les coupes avant décoration. L’enrichissement des minerais et les +petites structures n’appartiennent pas à ce champ de densité. + +`scripts/finishing_atlas.py` superpose les rails planifiés, les axes des traversées +parcourues dans les blocs, les ruines asséchées et les réservations aériennes. +Les rectangles représentent les emprises ; ils ne représentent pas des îlots +rectangulaires. Le script refuse des rapports de graines ou tailles différentes +et des témoins natifs non réussis. + +```sh +build/atlas-venv/bin/python scripts/finishing_atlas.py \ + --terrain build/atlas307/raw/terrain-724-0.json \ + --village CHEMIN_DU_RAPPORT_VILLAGE \ + --transit CHEMIN_DU_RAPPORT_TRANSIT \ + --aerial CHEMIN_DU_RAPPORT_AERIEN \ + --ruins CHEMIN_DU_RAPPORT_RUINES --output build/atlas307/installations +``` + +Les preuves, cartes PNG/PDF et leurs empreintes sont ajoutées à l’archive de +livraison. Ces cartes sont des vues de diagnostic opérateur, pas des cartes +automatiquement révélées aux joueurs. diff --git a/docs/testing-alpha30.7.md b/docs/testing-alpha30.7.md new file mode 100644 index 0000000..a8f877a --- /dev/null +++ b/docs/testing-alpha30.7.md @@ -0,0 +1,91 @@ +# Vérifications alpha.30.7 + +Essais automatisés sur macOS, Java 25, Minecraft 26.3-pre-2. Aucun essai Windows +ni visite visuelle interactive de l’alpha.30.7 n’a été réalisé. Les mondes des +fixtures restent dans le dossier de développement ignoré. + +## Témoins dans de vrais chunks + +| Témoin | Terrain aérien enrichi | Navires / villageois | Traversées praticables | Village abandonné | Mines | +|---|---:|---:|---|---|---:| +| Moyen 724, graine 0 | 2 corps, 2 coffres | 2 / 2 | 2, portées 272 et 204 blocs | 15 maisons, 99 pièces, 10 zombie-villageois | 1 | +| Grand 1 024, graine -7228211907433324401 | 3 corps, 3 coffres | 2 / 2 | 1, portée 374 blocs | 12 maisons, 70 pièces, 9 zombie-villageois | 2 | + +La seconde traversée du Grand est refusée : aucun accès ne satisfait le contrôle +civil. La première reste générée et parcourable. « Deux si la place le permet » +ne signifie donc pas deux dans chaque monde. + +Les fixtures aériennes contrôlent les départs natifs, les coffres enterrés, +les huit minerais dans chaque corps, la présence des deux villageois, puis +pillent les coffres, minent un témoin et déplacent les habitants dans leur monde +jetable. Les fixtures de traversée vérifient les blocs de circulation de chaque +réseau séparément, ses accès et ses parois. Le village conserve ses pièces +Jigsaw, ses coffres et ses habitants. Les rails sont mesurés comme plans et +ballast : aucune circulation de wagonnet n’est certifiée sur les voies abandonnées. + +## Ruines asséchées + +Sur Moyen / 0 : trois ruines réellement posées, trois coffres, trois zombies +persistants et neuf coraux morts. Le contrôle après ticks vérifie l’absence +d’eau dans les emprises, les blocs vanilla et le roundtrip NBT des départs. + +Sur Grand / -7228211907433324401 : quatre ruines réellement posées, quatre +coffres, cinq zombies persistants et douze coraux morts. La passe dédiée réussit +en 3 min 21 s, dont 15 s d’exécution des deux tests après préparation du monde. + +Le test a révélé que les coraux morts ont un état `waterlogged=true` par défaut. +Ils sont désormais explicitement posés secs. La correction est locale à la ruine ; +aucun filtre étendu ne supprime les eaux ou la décoration des grottes voisines. + +Les sauvegardes de pièces et les marqueurs d’habitants sont contrôlés par +sérialisation native. Une reprise complète dans un nouveau processus JVM après +pillage et démolition n’a pas été rejouée pour cette livraison ; elle ne doit pas +être confondue avec ce roundtrip NBT. + +## Commandes et portée + +Une première exécution complète de `check build` a réussi avec 26 GameTests. +Après la correction finale des coraux, les contrôles purs et les cinq GameTests +ciblant les comportements modifiés ont tous réussi (5 min 47 s) : + +```sh +./gradlew check build \ + -PsanctuaryFocusedTests=aerial,transit,village,drained \ + -PsanctuaryAerial24Tests=true -PsanctuaryTransit29Tests=true +``` + +Le sélecteur `sanctuaryFocusedTests` limite seulement les fixtures natives ; +les contrôles de `check` restent exécutés. Sans ce paramètre, la suite native +complète conserve son fonctionnement habituel. + +Les témoins Grand pour les îlots, navires, traversée et village proviennent de +la passe précédente de cette même livraison. Seuls la pose sèche des coraux et +les contrôles locaux des ruines ont ensuite changé ; la validation dédiée des +ruines Grand a réussi séparément avec la correction finale. + +Les durées ci-dessus sont celles des fixtures (préparation et lecture de milliers +de blocs incluses), pas une mesure de FPS ni une promesse de durée de chargement. +Les neuf graines/tailles de l’atlas ne constituent pas neuf visites complètes de +structures. Les plafonds, reliefs et corps flottants restent ceux de 30.6. + +## Atlas et intégrité du terrain + +L’export natif final (3 min 42 s) a réussi pour les neuf couples +512 / 724 / 1 024 × graines 0 / 42 / -7228211907433324401. +Toutes les colonnes et coupes des quatre variantes sont **exactement égales** à +l’export 30.6, avec les mêmes coordonnées et résolutions. La comparaison porte +sur les tableaux de densité avant décoration, pas sur les matériaux enrichis ni +sur les installations. Les empreintes des deux jeux de données sont archivées +avec le rapport `density-306-307-comparison.json`. + +`./gradlew assemblePack -x :sanctuary:check` a réussi après le `check build` final. +Le MRpack a été vérifié : version, dépendance exacte, tailles, empreintes, CRC, +absence de sauvegardes personnelles et absence de second mécanisme de mise à jour. + +## Livraison + +Créer un **nouveau monde Sanctuary**. Le codec de prototype `sanctuary:island_v24` +est conservé ; aucune migration ni régénération des sauvegardes n’est exécutée. +Les réservations aériennes déjà enregistrées dans les anciens mondes sont gardées. +Le pack reste sur Minecraft 26.3-pre-2, Fabric Loader 0.19.5 et Fabric API +0.160.0+26.3. La réception publique et Prism est consignée dans `docs/packwiz.md`. diff --git a/gradle.properties b/gradle.properties index 53c177d..5c29f56 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.6 -pack_version=0.1.0-alpha.30.6 +mod_version=0.1.0-alpha.30.7 +pack_version=0.1.0-alpha.30.7 maven_group=fr.koka.sanctuary diff --git a/mods/sanctuary/build.gradle b/mods/sanctuary/build.gradle index 95e1cef..63080b8 100644 --- a/mods/sanctuary/build.gradle +++ b/mods/sanctuary/build.gradle @@ -89,6 +89,11 @@ tasks.named('runGameTest') { tasks.named('processGametestResources') { def villageOnly = providers.gradleProperty('sanctuaryVillageOnly').map { it.toBoolean() }.getOrElse(false) + def focused = providers.gradleProperty('sanctuaryFocusedTests').getOrElse('').split(',').findAll { !it.isEmpty() } + def focusClasses = [aerial: 'Aerial24WorldGameTests', transit: 'Transit29WorldGameTests', village: 'Village306GameTests', drained: 'Drained307GameTests'] + focused.each { if (!focusClasses.containsKey(it)) throw new GradleException('Unknown focused native test: ' + it) } + inputs.property('focusedNative', focused) + inputs.property('villageOnly', villageOnly) def atlasOnly = providers.gradleProperty('sanctuaryAtlasOnly').map { it.toBoolean() }.getOrElse(false) inputs.property('atlasOnly', atlasOnly) @@ -140,6 +145,10 @@ tasks.named('processGametestResources') { : unified ? 'fr.koka.sanctuary.gametest.UnifiedWorldGameTests' : 'fr.koka.sanctuary.gametest.SanctuaryWorldGameTests') if (atlasOnly || villageOnly) filter { line -> line.replaceAll(/("fabric-gametest": \["[^"]+")[^\]]*/, '$1') } + if (!focused.isEmpty()) filter { line -> + line.contains('"fabric-gametest":') ? ' "fabric-gametest": [' + focused.collect { '"fr.koka.sanctuary.gametest.' + focusClasses[it] + '"' }.join(', ') + '],' : line + } + } } diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Aerial24WorldGameTests.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Aerial24WorldGameTests.java index 4df535d..28024bf 100644 --- a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Aerial24WorldGameTests.java +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Aerial24WorldGameTests.java @@ -69,9 +69,9 @@ public final class Aerial24WorldGameTests { helper.assertTrue(StructureCensusService.aerialSites(level).isEmpty(), "Disabled aerial sites are not presented as planned"); report.put("passed", true); write(Path.of("diagnostics/aerial24/off.json"), report); helper.succeed(); return; } - helper.assertTrue(plan.sites().size() == 4, "All four initial aerial sites have a finite saved plan"); + helper.assertTrue(plan.sites().size() == (generator.initialDiameter()==1024?5:4), "Both ships and all natural floating bodies have a saved plan"); helper.assertTrue(plan.sites().stream().filter(AerialPlan24.Site::merchant).count() == 2, "Two merchant ships"); - helper.assertTrue(plan.sites().stream().filter(AerialPlan24.Site::waterfall).count() == 1, "One waterfall site"); + helper.assertTrue(plan.sites().stream().filter(AerialPlan24.Site::waterfall).count() == 0, "No artificial mineral-islet waterfall remains"); if (generator.initialDiameter() == 724 && (level.getSeed() == 0 || level.getSeed() == 42)) AerialRenderer24.writeDiagnostic(Path.of("diagnostics/aerial24/blocks.json"), plan.sites()); report.put("geometry", JSON.toJsonTree(plan.sites())); report.put("claim_count", plan.claimsChunks().size()); @@ -121,6 +121,9 @@ public final class Aerial24WorldGameTests { if (!tickets[0]) { entityChunks.forEach(pos -> level.getChunkSource().addTicketWithRadius(TicketType.DRAGON, pos, 2)); tickets[0] = true; } + // Other simultaneous fixtures may release a shared vanilla DRAGON ticket. + // Refresh our own still-required chunks until the lifecycle inspection completes. + entityChunks.forEach(pos -> level.getChunkSource().addTicketWithRadius(TicketType.DRAGON, pos, 2)); var occupants = occupants(level, plan); var readiness = new TreeMap(); boolean entitiesReady = true; for (var pos : entityChunks) { @@ -171,7 +174,7 @@ public final class Aerial24WorldGameTests { report.put("native_tags", tags); report.put("full_chunks", chunks.size()); report.put("blocks_after", countSites(level, plan)); var census = StructureCensusService.aerialSites(level); - helper.assertTrue(census.size() == 4 && census.stream().allMatch(site -> site.status() == StructureCensusService.AerialStatus.GENERATED), + helper.assertTrue(census.size() == plan.sites().size() && census.stream().allMatch(site -> site.status() == StructureCensusService.AerialStatus.GENERATED), "Four historical generated sites remain indexed after loot, mining and demolition"); report.put("census_after", census); helper.assertTrue(equalFiles(journalBefore, files(world.resolve("data").resolve(generator.journalDirectory()))), "Aerial tests never reserve or activate an expansion"); @@ -197,7 +200,7 @@ public final class Aerial24WorldGameTests { double gap = AerialPlanner24.shoreDistance(site); helper.assertTrue(gap >= 16 && gap <= 48, "Native ship is just off its verified shore"); helper.assertTrue(Math.abs(AerialGeometry24.entry(site).getY() - site.shoreY()) <= 4, "Boarding deck follows the nearby shore height"); - } else { + } else if(site.kind()==AerialPlan24.Kind.MINERAL_ISLET) { helper.assertTrue(root.ownsChunk(site.startChunkX(), site.startChunkZ()), "Mineral islet floats above Sanctuary terrain"); var column = generator.aerialBaseColumn24(site.shoreX(), site.shoreZ()); helper.assertTrue(column.getBlock(site.shoreY() - 1).isSolid(), "Overhead islet retains a real ground witness below it"); @@ -275,12 +278,12 @@ public final class Aerial24WorldGameTests { helper.assertTrue(AerialRenderer24.loot(site).equals(chest.getLootTable()), "Aerial chest retains its unopened site-specific loot table"); for (var entry : model.entrySet()) if (!entry.getValue().isAir() && !entry.getValue().is(Blocks.WATER)) helper.assertTrue(level.getBlockState(entry.getKey()).is(entry.getValue().getBlock()), "Native FULL keeps authored material at " + site.id() + " " + entry.getKey()); - for (var pos : AerialGeometry24.interiorChecks(site)) { + for (var pos : site.kind()==AerialPlan24.Kind.NATURAL_ISLET ? List.of() : AerialGeometry24.interiorChecks(site)) { helper.assertTrue(level.getBlockState(pos).getCollisionShape(level, pos).isEmpty() && level.getBlockState(pos.above()).getCollisionShape(level, pos.above()).isEmpty(), "Usable two-block headroom at " + site.id()); helper.assertTrue(!level.getBlockState(pos.below()).getCollisionShape(level, pos.below()).isEmpty(), "Interior stands on a real floor"); } - if (site.kind() == AerialPlan24.Kind.MINERAL_ISLET) for (int dy = 1; dy <= 3; dy++) + if (!site.merchant()) for (int dy = 1; dy <= 3; dy++) helper.assertTrue(level.getBlockState(chestAt.above(dy)).isSolid(), "Mineral chest needs excavation through three layers"); } else helper.assertTrue(chest.getLootTable() == null && chest.isEmpty(), "Looted chest never refills on reload"); if (site.kind() == AerialPlan24.Kind.MINERAL_ISLET) @@ -299,9 +302,14 @@ public final class Aerial24WorldGameTests { exterior.add(Map.of("position", coordinates(at), "observed_block", level.getBlockState(at).toString())); report.put(site.id() + "_exterior_observations", exterior); } - helper.assertTrue(chestCount == 4 && waterSources == 1, "Four physical chests and exactly one planned source"); + helper.assertTrue(chestCount == plan.sites().size() && waterSources == 0, "One physical chest per site and no artificial waterfall"); report.put("chests", chestCount); report.put("waterfall_sources", waterSources); report.put("blocks_before", countSites(level, plan)); report.put("vegetation", vegetation); + if(fresh) for(var site:plan.sites()) if(site.kind()==AerialPlan24.Kind.NATURAL_ISLET) { + var blocks=countSites(level,plan).get(site.id()); + for(String id:List.of("coal","iron","copper","gold","lapis","redstone","diamond","emerald")) + helper.assertTrue(blocks.getOrDefault("minecraft:"+id+"_ore",0)>0,"Finite "+id+" veins in "+site.id()); + } return expected; } @@ -359,8 +367,12 @@ public final class Aerial24WorldGameTests { for (int slot = 0; slot < chest.getContainerSize(); slot++) quantity += chest.getItem(slot).getCount(); helper.assertTrue(quantity > 0, "Site-specific chest produces usable vanilla loot"); loot.put(site.id(), quantity); chest.clearContent(); chest.setChanged(); emptied.add(coordinates(at)); - if (site.kind() == AerialPlan24.Kind.MINERAL_ISLET) { - var ores = models.get(site.id()).entrySet().stream().filter(entry -> ore(entry.getValue())).sorted(Map.Entry.comparingByKey( + if (!site.merchant()) { + var body = new HashMap<>(models.get(site.id())); + if(site.kind()==AerialPlan24.Kind.NATURAL_ISLET) for(var pos:BlockPos.betweenClosed(site.minX(),site.minY(),site.minZ(),site.maxX(),site.maxY(),site.maxZ())) { + var state=level.getBlockState(pos); if(ore(state)) body.put(pos.immutable(),state); + } + var ores = body.entrySet().stream().filter(entry -> ore(entry.getValue())).sorted(Map.Entry.comparingByKey( Comparator.comparingInt(BlockPos::getX).thenComparingInt(BlockPos::getY).thenComparingInt(BlockPos::getZ))).limit(16).toList(); helper.assertTrue(!ores.isEmpty(), "Each mineral island contains actual finite veins"); for (var entry : ores) { level.setBlock(entry.getKey(), Blocks.AIR.defaultBlockState(), 2); removed.add(coordinates(entry.getKey())); } diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Drained307GameTests.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Drained307GameTests.java new file mode 100644 index 0000000..bb6b2f9 --- /dev/null +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Drained307GameTests.java @@ -0,0 +1,62 @@ +package fr.koka.sanctuary.gametest; + +import com.google.gson.GsonBuilder; +import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator; +import java.nio.file.*; +import java.util.*; +import net.fabricmc.fabric.api.gametest.v1.GameTest; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.BuiltInRegistries; +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 Drained307GameTests { + @GameTest(maxTicks=12000) + public void nativeRuinsRemainDryAndKeepMarkers(GameTestHelper helper) throws Exception { + var level=helper.getLevel();var host=(SanctuaryChunkGenerator)level.getChunkSource().getGenerator(); + if(host.rootGeneration()!=24){helper.succeed();return;} + var plans=host.dryRuins307(); + if(!level.structureManager().shouldGenerateStructures()||!host.experimentalStructures()) { + helper.assertTrue(plans.isEmpty(),"Disabled structures reserve no dry ruins");helper.succeed();return; + } + var report=new LinkedHashMap();report.put("seed",Long.toString(level.getSeed()));report.put("diameter",host.initialDiameter()); + report.put("planned",plans.size());report.put("bounds",plans.stream().map(s->s.getBoundingBox().toString()).toList()); + Path out=Path.of("diagnostics/drained307",host.initialDiameter()+"-"+level.getSeed()+".json");Files.createDirectories(out.getParent()); + Files.writeString(out,new GsonBuilder().setPrettyPrinting().create().toJson(report)); + if(Boolean.getBoolean("sanctuary.test.village306.planOnly")) {helper.succeed();return;} + var chunks=new LinkedHashSet();plans.forEach(s->s.getBoundingBox().intersectingChunks().forEach(chunks::add)); + var iterator=chunks.iterator();Runnable[] step=new Runnable[1];boolean[] tickets={false};int[] wait={0}; + step[0]=()->{try { + if(iterator.hasNext()) {var cp=iterator.next();level.getChunk(cp.x(),cp.z(),ChunkStatus.FULL,true);helper.runAtTickTime(helper.getTick()+1,()->step[0].run());return;} + if(!tickets[0]){chunks.forEach(cp->level.getChunkSource().addTicketWithRadius(net.minecraft.server.level.TicketType.DRAGON,cp,2));tickets[0]=true;} + if(wait[0]++<40){helper.runAtTickTime(helper.getTick()+1,()->step[0].run());return;} + var counts=new TreeMap();var saved=new ArrayList();int zombies=0,chests=0,corals=0; + var ctx=StructurePieceSerializationContext.fromLevel(level); + for(var planned:plans) { + var cp=planned.getChunkPos();var actual=level.getChunk(cp.x(),cp.z()).getStartForStructure(planned.getStructure()); + helper.assertTrue(actual!=null&&actual.isValid(),"Dry ruin has a native start"); + var tag=actual.createTag(ctx,cp);var restored=StructureStart.loadStaticStart(ctx,tag,level.getSeed()); + helper.assertTrue(restored!=null&&tag.equals(restored.createTag(ctx,cp)),"Ruin floor, orientation, seed and occupant flags survive native serialization"); + saved.add(tag.toString());var box=planned.getBoundingBox(); + for(var at:BlockPos.betweenClosed(box.minX(),box.minY(),box.minZ(),box.maxX(),box.maxY(),box.maxZ())) { + var state=level.getBlockState(at);var id=BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(); + if(!state.isAir())counts.merge(id,1,Integer::sum); + helper.assertTrue(state.getFluidState().isEmpty(),"Drained ruin has no water or waterlogged block at "+at); + if(id.contains("dead_")&&id.contains("coral"))corals++; + if(level.getBlockEntity(at) instanceof net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity chest) { + chests++;helper.assertTrue(net.minecraft.world.level.storage.loot.BuiltInLootTables.UNDERWATER_RUIN_SMALL.equals(chest.getLootTable()),"Native ocean loot retained"); + } + } + var inhabitants=level.getEntitiesOfClass(net.minecraft.world.entity.monster.zombie.Zombie.class,net.minecraft.world.phys.AABB.of(box).inflate(8)); + zombies+=inhabitants.stream().filter(z->z.isPersistenceRequired()).count(); + } + report.put("blocks",counts);report.put("chests",chests);report.put("dead_corals",corals);report.put("persistent_zombies",zombies);report.put("saved_starts",saved); + if(!plans.isEmpty()) {helper.assertTrue(chests>0,"Native treasure marker placed");helper.assertTrue(corals>0,"Dead coral accents present");helper.assertTrue(zombies>0,"Persistent vanilla zombie occupants present");} + report.put("passed",true);Files.writeString(out,new GsonBuilder().setPrettyPrinting().create().toJson(report)); + chunks.forEach(cp->level.getChunkSource().removeTicketWithRadius(net.minecraft.server.level.TicketType.DRAGON,cp,2));helper.succeed(); + }catch(Exception e){throw new IllegalStateException(e);}}; + helper.runAtTickTime(helper.getTick()+1,()->step[0].run()); + } +} diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Transit29WorldGameTests.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Transit29WorldGameTests.java index a458740..d5dd959 100644 --- a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Transit29WorldGameTests.java +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Transit29WorldGameTests.java @@ -39,9 +39,9 @@ public final class Transit29WorldGameTests { } helper.assertFalse(plan.isEmpty(), "A structures-enabled Sanctuary world must retain its main traversal: " + plan.diagnostic()); var accesses = plan.modules().stream().filter(m -> m.kind() == TransitPlan21.Kind.ACCESS).toList(); - helper.assertTrue(accesses.size() == 2, "The complete traversal has two independent surface accesses"); - int span = Math.max(Math.abs(accesses.getFirst().centerX() - accesses.getLast().centerX()), - Math.abs(accesses.getFirst().centerZ() - accesses.getLast().centerZ())); + helper.assertTrue(List.of(2,4).contains(accesses.size()), "The complete traversal has two independent surface accesses"); + int span = Math.max(Math.abs(accesses.getFirst().centerX() - accesses.get(1).centerX()), + Math.abs(accesses.getFirst().centerZ() - accesses.get(1).centerZ())); helper.assertTrue(span >= generator.initialDiameter() / 4, "The main route spans at least a quarter of the island diameter"); report.put("span", span); var holder = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(TransitStructures21.KEY); @@ -72,7 +72,7 @@ public final class Transit29WorldGameTests { for (int z = module.minZ() - 3; z < module.minZ(); z++) chunks.add(new ChunkPos(x >> 4, z >> 4)); } } - helper.assertTrue(chunks.size() <= 160, "The native traversal witness stays inside its bounded footprint"); + helper.assertTrue(chunks.size() <= 320, "The native traversal witness stays inside its bounded footprint"); report.put("local_starts", starts.size()); report.put("planned_footprint_chunks", chunks.size()); boolean planOnly = Boolean.getBoolean("sanctuary.test.transit29.planOnly"); report.put("plan_only", planOnly); @@ -107,7 +107,18 @@ public final class Transit29WorldGameTests { } report.put("closed_wall_checks", closedWallChecks); report.put("module_biomes", biomes); - report.put("walk", Transit21WorldGameTests.verifyAccess(helper, plan)); + var walks=new ArrayList(); + var remaining=new ArrayList<>(plan.modules()); + while(!remaining.isEmpty()) { + var group=new ArrayList();group.add(remaining.removeFirst()); + boolean grew; + do {grew=false;for(var candidate:List.copyOf(remaining)) if(group.stream().anyMatch(m-> + m.minX()<=candidate.maxX()+1&&m.maxX()+1>=candidate.minX()&&m.minZ()<=candidate.maxZ()+1&&m.maxZ()+1>=candidate.minZ())) { + group.add(candidate);remaining.remove(candidate);grew=true; + }}while(grew); + walks.add(Transit21WorldGameTests.verifyAccess(helper,new TransitPlan21(group,0,"accepted29 native component"))); + } + report.put("walk",walks); var nearest = TransitStructures21.findNearest(level, HolderSet.direct(holder), net.minecraft.core.BlockPos.ZERO, 128, false); helper.assertTrue(nearest != null && accesses.stream().anyMatch(m -> TransitPiece21.locator(m).equals(nearest.getFirst())), "The real structure locator returns one of the walkable surface gates"); diff --git a/mods/sanctuary/src/gametest/resources/fabric.mod.json b/mods/sanctuary/src/gametest/resources/fabric.mod.json index 35dc604..f1100f6 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.Village306GameTests", "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.Drained307GameTests", "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 970541f..e83ef69 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/SanctuaryMod.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/SanctuaryMod.java @@ -80,6 +80,7 @@ public final class SanctuaryMod implements ModInitializer { 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.drained307.DryOcean307.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 7e86737..1e4b0f0 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 @@ -138,6 +138,12 @@ public class ExpansionChunkGenerator extends ChunkGenerator { if (existing == null || !existing.isValid()) structures.setStartForStructure(planned.getStructure(), planned, chunk); } } + if (this instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator host && structures.shouldGenerateStructures() && Level.OVERWORLD.equals(dimension)) { + for (var planned : host.dryRuins307()) 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 bfbce81..f4796c6 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 @@ -554,6 +554,7 @@ public final class ExpansionStructures23 { if (generator.village306().start() != null) { var village = generator.village306().start().getChunkPos(); addPosition(positions, village.x(), village.z()); } + generator.dryRuins307().forEach(s -> addPosition(positions, s.getChunkPos().x(), s.getChunkPos().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/mixin/UnifiedDecorationProtectionMixin.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/UnifiedDecorationProtectionMixin.java index 0ee5208..60e8098 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.drained307.DryOcean307.isPlacing()) { + if (!fr.koka.sanctuary.worldgen.drained307.DryOcean307.mayWrite(pos)) callback.setReturnValue(false); + return; + } if (fr.koka.sanctuary.worldgen.underground306.Village306.isPlacing()) { if (!fr.koka.sanctuary.worldgen.underground306.Village306.mayWrite(pos)) callback.setReturnValue(false); return; @@ -48,6 +52,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.drained307.DryOcean307.isPlacing()) { + if (!fr.koka.sanctuary.worldgen.drained307.DryOcean307.mayWrite(pos)) callback.setReturnValue(false); + return; + } if (fr.koka.sanctuary.worldgen.underground306.Village306.isPlacing()) { if (!fr.koka.sanctuary.worldgen.underground306.Village306.mayWrite(pos)) callback.setReturnValue(false); return; @@ -56,6 +64,11 @@ public abstract class UnifiedDecorationProtectionMixin { if (!fr.koka.sanctuary.worldgen.aerial.AerialStructures24.mayWrite(pos)) callback.setReturnValue(false); return; } + // Native vegetation patches may call setBlock without ensureCanWrite, including + // from a neighbouring chunk after this ruin was placed. Preserve its dry footprint. + if (generator.protectsDryRuin307(pos)) { + callback.setReturnValue(false); return; + } if ((generator.rootGeneration() == 23 || generator.rootGeneration() == 24)) { if (fr.koka.sanctuary.worldgen.mineral.MineralSanctuaryStructures23.isPlacing()) return; var island = generator.islandAt(pos.getX(), pos.getZ()); 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 64bd729..f4de075 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 @@ -104,6 +104,16 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { prepareHeritage(root); prepareTransit(root); } + public java.util.List naturalMasses306(ServerLevel level) { + int players=initialDiameter()==512?5:initialDiameter()==724?10:20; + var field=level.registryAccess().lookupOrThrow(net.minecraft.core.registries.Registries.DENSITY_FUNCTION) + .getOrThrow(net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.DENSITY_FUNCTION, + SanctuaryMod.id("population_"+players))).value(); + var sampler=originContext.random().getSampler(field); + if(sampler instanceof DetachedRock302 detached) sampler=detached.input(); + if(!(sampler instanceof FloatingTerrain306 floating)) throw new IllegalStateException("Missing natural upper terrain sampler"); + return floating.masses(net.minecraft.world.level.levelgen.densityfunction.SamplerContext.builder().enableCaches().build()); + } public net.minecraft.world.level.NoiseColumn aerialBaseColumn24(int x, int z) { var current = originContext; if (current == null || heightAccessor == null) throw new IllegalStateException("Aerial sampling before world attachment"); @@ -140,6 +150,12 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { 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 boolean dryRuinsPrepared307; + private volatile java.util.List dryRuins307 = java.util.List.of(); + public java.util.List dryRuins307() { return dryRuins307; } + public boolean protectsDryRuin307(net.minecraft.core.BlockPos pos) { + return dryRuins307.stream().anyMatch(s -> s.getBoundingBox().isInside(pos)); + } 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; } @@ -196,6 +212,7 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { 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 ruin : dryRuins307) if (ruin.getBoundingBox().intersects(new net.minecraft.world.level.levelgen.structure.BoundingBox(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()) { @@ -263,6 +280,10 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { minesPrepared306 = true; starterMines306 = fr.koka.sanctuary.expansion.ExpansionStructures23.starterMines306(level,this,islands.stream().filter(ExpansionIsland::origin).findFirst().orElseThrow()); } + if (!dryRuinsPrepared307 && generation == 24 && cityStructuresEnabled && experimentalStructures) { + dryRuinsPrepared307 = true; + dryRuins307 = fr.koka.sanctuary.worldgen.drained307.DryOcean307.plan(level, this); + } } @Override public void setIslands(List islands) { // Complete and publish city geometry before workers can observe the new owner list. @@ -372,7 +393,7 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { || ruinsPlan17.protects(pos.getX(), pos.getY(), pos.getZ()) || heritagePlan19.protects(pos.getX(), pos.getY(), pos.getZ())); long start = System.nanoTime(); transitPlan21 = generation == 24 - ? fr.koka.sanctuary.worldgen.transit.TransitPlanner29.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled) + ? fr.koka.sanctuary.worldgen.transit.TransitPlanner29.plan307(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled) : generation >= 22 ? fr.koka.sanctuary.worldgen.transit.TransitPlanner22.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled) : fr.koka.sanctuary.worldgen.transit.TransitPlanner21.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled); @@ -570,6 +591,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; + for (var ruin : dryRuins307) if (ruin.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/aerial/AerialGeometry24.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialGeometry24.java index 590df8b..a70c097 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialGeometry24.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialGeometry24.java @@ -15,6 +15,7 @@ public final class AerialGeometry24 { public static BlockPos chest(AerialPlan24.Site s) { return switch (s.kind()) { case MERCHANT_PROVISIONS, MERCHANT_TOOLS -> world(s, 7, 4, 24); + case NATURAL_ISLET -> world(s, s.width()/2, s.height()/2, s.depth()/2); case MINERAL_ISLET -> { int x = s.width() / 2 + 4, z = s.depth() / 2 + 2; yield world(s, x, terrainTop(s, x, z) - 3, z); @@ -37,6 +38,7 @@ public final class AerialGeometry24 { public static BlockPos entry(AerialPlan24.Site s) { return switch (s.kind()) { case MERCHANT_PROVISIONS, MERCHANT_TOOLS -> world(s, 3, 5, 13); + case NATURAL_ISLET -> chest(s); case MINERAL_ISLET -> world(s, s.width() / 2, terrainTop(s, s.width() / 2, s.depth() / 2) + 1, s.depth() / 2); }; } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialPiece24.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialPiece24.java index bdd4199..89049ce 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialPiece24.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialPiece24.java @@ -75,6 +75,7 @@ public final class AerialPiece24 extends StructurePiece { @Override public void postProcess(WorldGenLevel level, StructureManager manager, ChunkGenerator generator, RandomSource random, BoundingBox clip, ChunkPos chunk, BlockPos pivot) { AerialStructures24.placing(site, () -> { + if(site.kind()==AerialPlan24.Kind.NATURAL_ISLET) NaturalIslet307.enrich(level,site,clip); AerialRenderer24.render(site, new LostCityRenderer.BlockWriter() { private BlockPos position(int x, int y, int z) { return new BlockPos(site.minX() + x, site.minY() + y, site.minZ() + z); } @Override public void set(int x, int y, int z, BlockState state) { diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialPlan24.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialPlan24.java index 4168ece..011b591 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialPlan24.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialPlan24.java @@ -19,16 +19,19 @@ public final class AerialPlan24 { private final List sites; private final Set claims; - public enum Kind { MERCHANT_PROVISIONS, MERCHANT_TOOLS, MINERAL_ISLET } + public enum Kind { MERCHANT_PROVISIONS, MERCHANT_TOOLS, MINERAL_ISLET, NATURAL_ISLET } public AerialPlan24(List sites) { this.sites = List.copyOf(sites); - if (!sites.isEmpty() && (sites.size() != 4 - || sites.stream().filter(s -> s.kind() == Kind.MERCHANT_PROVISIONS).count() != 1 - || sites.stream().filter(s -> s.kind() == Kind.MERCHANT_TOOLS).count() != 1 - || sites.stream().filter(s -> s.kind() == Kind.MINERAL_ISLET).count() != 2 - || sites.stream().filter(Site::waterfall).count() != 1)) - throw new IllegalArgumentException("An aerial plan contains all four sites and exactly one waterfall, or is disabled"); + boolean legacy = sites.size()==4 && sites.stream().filter(s -> s.kind()==Kind.MINERAL_ISLET).count()==2 + && sites.stream().filter(Site::waterfall).count()==1; + boolean natural = (sites.size()==4 || sites.size()==5) + && sites.stream().filter(s -> s.kind()==Kind.NATURAL_ISLET).count()==sites.size()-2 + && sites.stream().noneMatch(Site::waterfall); + if (!sites.isEmpty() && (!(legacy || natural) + || sites.stream().filter(s -> s.kind()==Kind.MERCHANT_PROVISIONS).count()!=1 + || sites.stream().filter(s -> s.kind()==Kind.MERCHANT_TOOLS).count()!=1)) + throw new IllegalArgumentException("Incomplete aerial sites"); var ids = new HashSet(); var columns = new HashSet(); for (Site site : sites) { @@ -55,7 +58,8 @@ public final class AerialPlan24 { } return false; } - public boolean protects(BlockPos pos) { return sites.stream().anyMatch(s -> s.mayWrite(pos)); } + public boolean protects(BlockPos pos) { return sites.stream().anyMatch(s -> s.kind()==Kind.NATURAL_ISLET + ? AerialGeometry24.chest(s).distManhattan(pos)<=2 : s.mayWrite(pos)); } /** Width and depth are local dimensions; facing rotates them around the world-space bounding-box minimum. */ public record Site(String id, Kind kind, int minX, int minY, int minZ, int width, int height, int depth, @@ -64,8 +68,8 @@ public final class AerialPlan24 { if (id == null || !id.matches("[a-z][a-z0-9_]{0,47}") || kind == null || facing < 0 || facing > 3 || Math.abs((long) minX) > 2048 || Math.abs((long) minZ) > 2048 || Math.abs((long) shoreX) > 1024 || Math.abs((long) shoreZ) > 1024 - || shoreY < 1 || shoreY > 383 || minY < 32 || height < 8 || height > 35 + VEGETATION_HEADROOM || minY + height > 350 - || width < 7 || width > 64 || depth < 7 || depth > 64 + || shoreY < 1 || shoreY > 383 || minY < 32 || height < 8 || height > (kind==Kind.NATURAL_ISLET ? 110 : 35 + VEGETATION_HEADROOM) || minY + height > (kind==Kind.NATURAL_ISLET ? 430 : 350) + || width < 7 || width > (kind==Kind.NATURAL_ISLET ? 136 : 64) || depth < 7 || depth > (kind==Kind.NATURAL_ISLET ? 136 : 64) || waterfall && kind != Kind.MINERAL_ISLET) throw new IllegalArgumentException("Invalid aerial site geometry"); if (kind == Kind.MINERAL_ISLET && (width < 40 || depth < 40 || height < 20 + VEGETATION_HEADROOM) diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialPlanner24.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialPlanner24.java index 8590c93..97cb057 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialPlanner24.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialPlanner24.java @@ -38,10 +38,14 @@ public final class AerialPlanner24 { @Override public boolean reserved(int x, int z, int minY, int maxY) { return generator.intersectsExperimentalStructures(x, minY, z, x, maxY, z); } - }); + }, generator.naturalMasses306(level)); } public static AerialPlan24 plan(long seed, int diameter, Terrain terrain) { + return plan(seed,diameter,terrain,null); + } + private static AerialPlan24 plan(long seed,int diameter,Terrain terrain, + java.util.List natural) { if (diameter != 512 && diameter != 724 && diameter != 1024) throw new IllegalArgumentException("Unsupported aerial root size"); int radius = diameter == 724 ? 395 : diameter / 2 + 32; var probe = new Probe(terrain, radius); @@ -50,7 +54,7 @@ public final class AerialPlanner24 { Kind[] kinds = {Kind.MERCHANT_PROVISIONS, Kind.MERCHANT_TOOLS, Kind.MINERAL_ISLET, Kind.MINERAL_ISLET}; String[] ids = {"merchant_provisions", "merchant_tools", "mineral_islet_water", "mineral_islet_dry"}; double initialAngle = unit(mix(seed ^ 0xA24AE21AL)) * Math.PI * 2; - for (int ordinal = 0; ordinal < kinds.length; ordinal++) { + for (int ordinal = 0; ordinal < (natural==null ? kinds.length : 2); ordinal++) { long siteSeed = ExpansionIsland.deriveSeed(seed ^ 0xA24AE21AL, ids[ordinal]); boolean merchant = ordinal < 2; int width = merchant ? 11 : 40 + 4 * (int) Math.floorMod(siteSeed, 7); @@ -153,6 +157,26 @@ public final class AerialPlanner24 { sites.add(accepted); accepted.claimedChunks().forEach(c -> claimed.add(c.pack())); } + if(natural!=null) { + if(natural.size()!=(diameter==1024?3:2)) throw new IllegalStateException("Missing natural upper terrain"); + for(int i=0;iclaimed.contains(c.pack()))) + throw new IllegalStateException("Natural treasure reservation overlaps another aerial site"); + sites.add(site);site.claimedChunks().forEach(c->claimed.add(c.pack())); + } + } var result = new AerialPlan24(sites); System.getLogger(AerialPlanner24.class.getName()).log(System.Logger.Level.INFO, "Aerial24 seed=" + seed + " diameter=" + diameter + " sites=" + result.sites().size() diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialRenderer24.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialRenderer24.java index 7770f21..6a4c21c 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialRenderer24.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialRenderer24.java @@ -60,7 +60,7 @@ public final class AerialRenderer24 { return Map.copyOf(blocks); } public static ResourceKey loot(AerialPlan24.Site s) { - return ResourceKey.create(Registries.LOOT_TABLE, SanctuaryMod.id("chests/aerial24/" + s.kind().name().toLowerCase(java.util.Locale.ROOT))); + return ResourceKey.create(Registries.LOOT_TABLE, SanctuaryMod.id("chests/aerial24/" + (s.kind()==AerialPlan24.Kind.NATURAL_ISLET ? "mineral_islet" : s.kind().name().toLowerCase(java.util.Locale.ROOT)))); } public static void render(AerialPlan24.Site s, LostCityRenderer.BlockWriter out) { render(s, out, s.bounds()); } public static void render(AerialPlan24.Site s, LostCityRenderer.BlockWriter out, BoundingBox clip) { @@ -74,12 +74,13 @@ public final class AerialRenderer24 { if (clip.isInside(s.minX() + x, s.minY() + y, s.minZ() + z)) out.chest(x, y, z, table, seed); } }; - var connected = s.kind() == AerialPlan24.Kind.MINERAL_ISLET ? null : ConnectedBlocks20.buffer(clipped); + var connected = !s.merchant() ? null : ConnectedBlocks20.buffer(clipped); var destination = connected == null ? clipped : connected; var b = new Builder(s, destination, connected == null ? clip : s.bounds()); switch (s.kind()) { case MERCHANT_PROVISIONS, MERCHANT_TOOLS -> b.ship(); case MINERAL_ISLET -> b.islet(); + case NATURAL_ISLET -> { /* Existing density owns the whole natural silhouette. */ } } var chest = AerialGeometry24.chest(s); destination.chest(chest.getX() - s.minX(), chest.getY() - s.minY(), chest.getZ() - s.minZ(), loot(s), s.seed() ^ 0x24C4E57L); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialStructures24.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialStructures24.java index 89d4162..a626c01 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialStructures24.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialStructures24.java @@ -36,10 +36,10 @@ public final class AerialStructures24 { private static ResourceKey key(String id) { return ResourceKey.create(Registries.STRUCTURE, SanctuaryMod.id(id)); } public static void register() {} public static String family(AerialPlan24.Site site) { - return switch (site.kind()) { case MINERAL_ISLET -> "mineral_islet"; default -> "merchant_ship"; }; + return switch (site.kind()) { case MINERAL_ISLET, NATURAL_ISLET -> "mineral_islet"; default -> "merchant_ship"; }; } public static ResourceKey key(AerialPlan24.Site site) { - return switch (site.kind()) { case MINERAL_ISLET -> MINERAL_ISLET; default -> MERCHANT_SHIP; }; + return switch (site.kind()) { case MINERAL_ISLET, NATURAL_ISLET -> MINERAL_ISLET; default -> MERCHANT_SHIP; }; } public static String structureId(AerialPlan24.Site site) { return key(site).identifier().toString(); } public static boolean isPlacing() { return PLACING.get() != null; } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/NaturalIslet307.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/NaturalIslet307.java new file mode 100644 index 0000000..bd00cfc --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/NaturalIslet307.java @@ -0,0 +1,39 @@ +package fr.koka.sanctuary.worldgen.aerial; + +import net.minecraft.core.BlockPos; +import net.minecraft.tags.BlockTags; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.levelgen.structure.BoundingBox; + +/** Finite seams replace only the existing rock. The accepted30.6 density is untouched. */ +public final class NaturalIslet307 { + private NaturalIslet307() {} + public static void enrich(WorldGenLevel level,AerialPlan24.Site site,BoundingBox clip) { + for(int x=Math.max(site.minX(),clip.minX());x<=Math.min(site.maxX(),clip.maxX());x++) + for(int z=Math.max(site.minZ(),clip.minZ());z<=Math.min(site.maxZ(),clip.maxZ());z++) { + int top=-1,bottom=-1; + for(int y=Math.min(399,site.maxY());y>=Math.max(300,site.minY());y--) { + var state=level.getBlockState(new BlockPos(x,y,z)); + if(state.is(BlockTags.BASE_STONE_OVERWORLD)) {if(top<0)top=y;bottom=y;} + } + if(top<0)continue; + for(int y=bottom;y<=top;y++) { + var at=new BlockPos(x,y,z);var state=level.getBlockState(at); + if(!state.is(BlockTags.BASE_STONE_OVERWORLD))continue; + var ore=ore(site.seed(),x,y,z,(top-y)/(double)Math.max(1,top-bottom)); + if(ore!=null)level.setBlock(at,ore.defaultBlockState(),2); + } + } + } + static Block ore(long seed,int x,int y,int z,double depth) { + long cell=AerialGeometry24.mix(seed ^ ((long)Math.floorDiv(x,4)<<42) + ^ ((long)Math.floorDiv(z,4)<<21) ^ Math.floorDiv(y,3)); + int roll=(int)Math.floorMod(cell,100); + if(roll>=37)return null; + if(depth<.27)return roll<15?Blocks.COAL_ORE:roll<27?Blocks.COPPER_ORE:Blocks.IRON_ORE; + if(depth<.60)return roll<13?Blocks.IRON_ORE:roll<21?Blocks.GOLD_ORE:roll<29?Blocks.LAPIS_ORE:Blocks.COAL_ORE; + return roll<10?Blocks.IRON_ORE:roll<19?Blocks.REDSTONE_ORE:roll<27?Blocks.GOLD_ORE:roll<34?Blocks.DIAMOND_ORE:Blocks.EMERALD_ORE; + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/drained307/DryOcean307.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/drained307/DryOcean307.java new file mode 100644 index 0000000..77c899d --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/drained307/DryOcean307.java @@ -0,0 +1,71 @@ +package fr.koka.sanctuary.worldgen.drained307; + +import fr.koka.sanctuary.SanctuaryMod; +import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator; +import java.util.*; +import net.minecraft.core.*; +import net.minecraft.core.registries.*; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.*; +import net.minecraft.world.level.block.Rotation; +import net.minecraft.world.level.levelgen.structure.*; +import net.minecraft.world.level.levelgen.structure.pieces.*; + +/** Sparse dry remnants on existing lower cave floors; no new cavern or ocean density. */ +public final class DryOcean307 { + public static final ResourceKey KEY=ResourceKey.create(Registries.STRUCTURE,SanctuaryMod.id("drained_ocean_ruin")); + public static final StructureType TYPE=Registry.register(BuiltInRegistries.STRUCTURE_TYPE, + SanctuaryMod.id("drained_ocean_v307"),()->DryOceanStructure307.CODEC); + public static final StructurePieceType PIECE=Registry.register(BuiltInRegistries.STRUCTURE_PIECE, + SanctuaryMod.id("drained_ocean_piece_v307"),DryOceanPiece307::new); + private static final ThreadLocal PLACING=new ThreadLocal<>(); + private DryOcean307(){} + public static void register(){} + public static boolean isPlacing(){return PLACING.get()!=null;} + public static boolean mayWrite(BlockPos pos){return PLACING.get()!=null&&PLACING.get().isInside(pos);} + static void placing(BoundingBox box,Runnable action){var old=PLACING.get();PLACING.set(box);try{action.run();}finally{if(old==null)PLACING.remove();else PLACING.set(old);}} + public static List plan(ServerLevel level,SanctuaryChunkGenerator host) { + var result=new ArrayList();var columns=new HashMap();var reasons=new TreeMap(); + var random=new Random(level.getSeed() ^ 0x3070CEA1L); + java.util.function.BiFunction column=(x,z)->{ + long key=BlockPos.asLong(x,0,z);var c=columns.get(key); + if(c==null){if(columns.size()>=8000)throw new Budget();c=host.aerialBaseColumn24(x,z);columns.put(key,c);}return c; + }; + int target=host.initialDiameter()==1024?4:3; + try { + outer:for(int attempt=0;attempt<512 && result.size()Math.hypot(s.getBoundingBox().getCenter().getX()-x,s.getBoundingBox().getCenter().getZ()-z)<48))continue; + var center=column.apply(x,z); + for(int floor=60;floor<=174;floor++) { + if(!center.getBlock(floor-1).isSolid()||!center.getBlock(floor).isAir()||!center.getBlock(floor+6).isAir())continue; + String template="underwater_ruin/warm_"+new int[]{2,3,4,5,8}[random.nextInt(5)]; + var piece=new DryOceanPiece307(level.getStructureTemplateManager(),template,new BlockPos(x,floor,z), + Rotation.values()[random.nextInt(4)],random.nextLong()); + var box=piece.getBoundingBox(); + if(host.intersectsExperimentalStructures(box.minX()-1,box.minY()-2,box.minZ()-1,box.maxX()+1,box.maxY()+2,box.maxZ()+1)) { + reasons.merge("reserved",1,Integer::sum);continue; + } + int support=0,total=0;boolean fits=true; + for(int px=box.minX();fits&&px<=box.maxX();px++)for(int pz=box.minZ();fits&&pz<=box.maxZ();pz++) { + var c=column.apply(px,pz);total++; + if(c.getBlock(floor-1).isSolid()&&c.getBlock(floor-2).isSolid())support++; + for(int py=floor;py<=box.maxY()+1;py++) + if(!c.getBlock(py).isAir()||host.protectsDecoration(new BlockPos(px,py,pz))) {fits=false;break;} + } + if(!fits||support*100>4,z>>4); + if(result.stream().anyMatch(s->s.getChunkPos().equals(cp)))continue; + result.add(new StructureStart(level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(KEY).value(), + cp,0,new PiecesContainer(List.of(piece)))); + continue outer; + } + } + }catch(Budget exhausted){reasons.merge("column-budget",1,Integer::sum);} + SanctuaryMod.LOGGER.info("Drained307 seed {}: {} / {} dry ruins, {} columns; {}",level.getSeed(),result.size(),target,columns.size(),reasons); + return List.copyOf(result); + } + private static final class Budget extends RuntimeException {} +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/drained307/DryOceanPiece307.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/drained307/DryOceanPiece307.java new file mode 100644 index 0000000..6cae13c --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/drained307/DryOceanPiece307.java @@ -0,0 +1,102 @@ +package fr.koka.sanctuary.worldgen.drained307; + +import java.nio.charset.StandardCharsets; +import java.util.*; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.Identifier; +import net.minecraft.util.RandomSource; +import net.minecraft.util.valueproviders.ConstantInt; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityTypes; +import net.minecraft.world.level.*; +import net.minecraft.world.level.block.*; +import net.minecraft.world.level.block.entity.ChestBlockEntity; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.levelgen.structure.*; +import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext; +import net.minecraft.world.level.levelgen.structure.templatesystem.*; +import net.minecraft.world.level.levelgen.structure.templatesystem.rule.blockentity.AppendLoot; +import net.minecraft.world.level.storage.loot.BuiltInLootTables; + +/** Native ocean-ruin templates at a certified cave floor, with dry markers and native loot. */ +public final class DryOceanPiece307 extends TemplateStructurePiece { + private final long seed; + private final Set occupants=new HashSet<>(); + public DryOceanPiece307(StructureTemplateManager manager,String template,BlockPos at,Rotation rotation,long seed) { + super(DryOcean307.PIECE,0,manager,Identifier.withDefaultNamespace(template),template,settings(rotation),at); + this.seed=seed; + } + public DryOceanPiece307(StructurePieceSerializationContext context,CompoundTag tag) { + super(DryOcean307.PIECE,tag,context.structureTemplateManager(),ignored->settings(tag.read("Rot",Rotation.LEGACY_CODEC).orElseThrow())); + seed=tag.getLong("Seed").orElseThrow(); + for(long pos:tag.getLongArray("Occupants").orElseThrow())occupants.add(pos); + if(!templateName.matches("underwater_ruin/warm_[1-8]") || occupants.size()>16) + throw new IllegalArgumentException("Invalid drained ruin"); + } + private static StructurePlaceSettings settings(Rotation rotation) { + // Same warm-ruin archaeology recipe: up to five native suspicious sand blocks. + var archaeology=new CappedProcessor(new RuleProcessor(List.of(new ProcessorRule( + new BlockMatchTest(Blocks.SAND),AlwaysTrueTest.INSTANCE,PosAlwaysTrueTest.INSTANCE, + Blocks.SUSPICIOUS_SAND.defaultBlockState(),new AppendLoot(BuiltInLootTables.OCEAN_RUIN_WARM_ARCHAEOLOGY)))),ConstantInt.of(5)); + return new StructurePlaceSettings().setRotation(rotation).addProcessor(new BlockRotProcessor(.8F)) + .addProcessor(BlockIgnoreProcessor.STRUCTURE_AND_AIR).addProcessor(archaeology); + } + @Override protected void addAdditionalSaveData(StructurePieceSerializationContext context,CompoundTag tag) { + super.addAdditionalSaveData(context,tag);tag.store("Rot",Rotation.LEGACY_CODEC,placeSettings.getRotation()); + tag.putLong("Seed",seed);tag.putLongArray("Occupants",occupants.stream().sorted().mapToLong(Long::longValue).toArray()); + } + @Override protected synchronized void handleDataMarker(String marker,BlockPos pos,ServerLevelAccessor level,RandomSource random,BoundingBox clip) { + if(!clip.isInside(pos))return; + if(marker.equals("chest")) { + if(level.getBlockState(pos).is(Blocks.CHEST))return; + level.setBlock(pos,Blocks.CHEST.defaultBlockState(),2); + if(level.getBlockEntity(pos) instanceof ChestBlockEntity chest) + chest.setLootTable(BuiltInLootTables.UNDERWATER_RUIN_SMALL,seed ^ pos.asLong()); + } else if(marker.equals("drowned")) { + level.setBlock(pos,Blocks.AIR.defaultBlockState(),2); + if(!occupants.add(pos.asLong()))return; + var zombie=EntityTypes.ZOMBIE.create(level.getLevel(),EntitySpawnReason.STRUCTURE); + if(zombie==null)throw new IllegalStateException("Missing ruin zombie"); + zombie.snapTo(pos,0,0);zombie.finalizeSpawn(level,level.getCurrentDifficultyAt(pos),EntitySpawnReason.STRUCTURE,null); + zombie.setPersistenceRequired();zombie.setUUID(UUID.nameUUIDFromBytes(("sanctuary:drained307:"+seed+":"+pos.asLong()).getBytes(StandardCharsets.UTF_8))); + level.addFreshEntityWithPassengers(zombie); + } + } + @Override public synchronized void postProcess(WorldGenLevel level,StructureManager manager,ChunkGenerator generator, + RandomSource random,BoundingBox clip,ChunkPos chunk,BlockPos pivot) { + DryOcean307.placing(getBoundingBox(),()->{ + super.postProcess(level,manager,generator,RandomSource.create(seed ^ chunk.pack()),clip,chunk,pivot); + var box=getBoundingBox(); + for(int x=Math.max(box.minX(),clip.minX());x<=Math.min(box.maxX(),clip.maxX());x++) + for(int z=Math.max(box.minZ(),clip.minZ());z<=Math.min(box.maxZ(),clip.maxZ());z++) { + for(int y=box.minY();y<=box.maxY();y++) { + var pos=new BlockPos(x,y,z);var state=level.getBlockState(pos); + if(state.is(Blocks.WATER))level.setBlock(pos,Blocks.AIR.defaultBlockState(),2); + else if(state.hasProperty(BlockStateProperties.WATERLOGGED)&&state.getValue(BlockStateProperties.WATERLOGGED)) + level.setBlock(pos,state.setValue(BlockStateProperties.WATERLOGGED,false),2); + } + long roll=mix(seed ^ BlockPos.asLong(x,0,z)); + if(Math.floorMod(roll,7)!=0)continue; + for(int y=box.minY()+1;y>>8,4)) { + case 0->Blocks.DEAD_BRAIN_CORAL_BLOCK;case 1->Blocks.DEAD_TUBE_CORAL; + case 2->Blocks.DEAD_FIRE_CORAL;default->Blocks.DEAD_BUBBLE_CORAL; + }; + var dry=coral.defaultBlockState(); + // Coral plants default to waterlogged even when already dead. + // A broken support would otherwise release a new water source here. + if(dry.hasProperty(BlockStateProperties.WATERLOGGED)) dry=dry.setValue(BlockStateProperties.WATERLOGGED,false); + if(dry.canSurvive(level,pos))level.setBlock(pos,dry,2); + break; + } + } + } + }); + } + private static long mix(long n){n=(n^n>>>30)*0xbf58476d1ce4e5b9L;n=(n^n>>>27)*0x94d049bb133111ebL;return n^n>>>31;} +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/drained307/DryOceanStructure307.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/drained307/DryOceanStructure307.java new file mode 100644 index 0000000..0bdbac7 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/drained307/DryOceanStructure307.java @@ -0,0 +1,11 @@ +package fr.koka.sanctuary.worldgen.drained307; +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 DryOceanStructure307 extends Structure { + public static final MapCodec CODEC=simpleCodec(DryOceanStructure307::new); + public DryOceanStructure307(StructureSettings settings){super(settings);} + @Override protected Optional findGenerationPoint(GenerationContext context){return Optional.empty();} + @Override public StructureType type(){return DryOcean307.TYPE;} +} 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 c33497e..cc6ffb1 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 @@ -240,13 +240,14 @@ public final class HeritagePlanner23 { for(int step=0;step chosenPath=null; int chosenFace=face; var last=path.getLast(); - for(int turn:new int[]{0,1,-1}) { + for(int turn:new int[]{0}) { int direction=(face+turn+4)&3; - // Give long straight runs priority, with curves only where the grade needs them. + // A branch stays on its initial axis. An obstruction ends it instead of curling back. 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; + // Keep a real separation from existing parallel routes, outside the shared junction. + if(proposedNearRoute307(path.getFirst(), end, cells)) 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(); @@ -260,7 +261,7 @@ public final class HeritagePlanner23 { 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) { + if(arm.depth()==0 && best.path().size() >= 65 && 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)); @@ -279,11 +280,18 @@ public final class HeritagePlanner23 { } 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));} + if(arm.depth()==0) queue.addLast(new Arm(terminal,face,1)); } } catch(BudgetExceeded exhausted){p.reject("branched-rail-budget");} } + private static boolean proposedNearRoute307(XZ start, XZ end, Map cells) { + if(Math.abs(start.x()-end.x())+Math.abs(start.z()-end.z()) <= 16) return false; + for(int dx=-10;dx<=10;dx++) for(int dz=-10;dz<=10;dz++) + if(cells.containsKey(key(end.x()+dx,end.z()+dz))) return true; + return false; + } + 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; diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitPlan21.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitPlan21.java index e07ae62..5b38301 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitPlan21.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitPlan21.java @@ -64,8 +64,8 @@ public record TransitPlan21(List modules, int samples, String diagnostic modules = List.copyOf(modules); if (modules.size() > 64 || samples < 0 || samples > MAX_SAMPLES || diagnostic == null || diagnostic.length() > 1024) throw new IllegalArgumentException("Invalid transit planning result"); - if (!modules.isEmpty() && modules.stream().filter(m -> m.kind() == Kind.ACCESS).count() != 2) - throw new IllegalArgumentException("A through-gallery needs two surface accesses"); + if (!modules.isEmpty() && !List.of(2L,4L).contains(modules.stream().filter(m -> m.kind() == Kind.ACCESS).count())) + throw new IllegalArgumentException("Each through-gallery needs two surface accesses"); for (int i = 0; i < modules.size(); i++) for (int j = i + 1; j < modules.size(); j++) { var a = modules.get(i); var b = modules.get(j); if (a.minX() <= b.maxX() && a.maxX() >= b.minX() && a.minZ() <= b.maxZ() && a.maxZ() >= b.minZ()) diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitPlanner29.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitPlanner29.java index 3beb4bc..c7093ff 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitPlanner29.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitPlanner29.java @@ -12,6 +12,27 @@ public final class TransitPlanner29 { public static final int MAX_COLUMNS = 12_000; private TransitPlanner29() {} + public static TransitPlan21 plan307(long seed, int cx, int cz, int diameter, Terrain terrain) { + var first=plan(seed,cx,cz,diameter,terrain); + if(first.isEmpty()) return first; + Terrain remaining=new Terrain() { + @Override public boolean owns(int x,int z) {return terrain.owns(x,z);} + @Override public boolean solid(int x,int y,int z) {return terrain.solid(x,y,z);} + @Override public int surfaceY(int x,int z) {return terrain.surfaceY(x,z);} + @Override public boolean protectedAt(int x,int y,int z) { + // Separate corridors and their entrances, with untouched rock between them. + for(var m:first.modules()) if(x>=m.minX()-24&&x<=m.maxX()+24&&z>=m.minZ()-24&&z<=m.maxZ()+24) return true; + return terrain.protectedAt(x,y,z); + } + }; + var second=plan(seed ^ 0x3077A115L,cx,cz,diameter,remaining); + if(second.isEmpty() || first.modules().size()+second.modules().size()>64 + || first.samples()+second.samples()>TransitPlan21.MAX_SAMPLES) + return new TransitPlan21(first.modules(),first.samples(),first.diagnostic()+"; second-refused="+second.diagnostic()); + var modules=new ArrayList<>(first.modules());modules.addAll(second.modules()); + return new TransitPlan21(modules,first.samples()+second.samples(),"two-traversals; "+first.diagnostic()+"; "+second.diagnostic()); + } + public static TransitPlan21 plan(long seed, int cx, int cz, int diameter, Terrain terrain) { if (!List.of(512, 724, 1024).contains(diameter)) throw new IllegalArgumentException("Unsupported transit diameter"); var p = new Probe(terrain); int layouts = 0; String reason = "no natural entrance pair"; 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 18f37bc..aadd837 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 @@ -238,5 +238,6 @@ "sanctuary.structures.census.aerial.status.unknown": "unknown", "structure.sanctuary.aerial_merchant_ship": "Merchant ship", "structure.sanctuary.aerial_mineral_islet": "Mineral islet", - "biome.sanctuary.swamp27_caves": "Underground swamp" + "biome.sanctuary.swamp27_caves": "Underground swamp", + "structure.sanctuary.drained_ocean_ruin": "Drained Ocean Ruins" } 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 0ddbaab..5374dd4 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 @@ -238,5 +238,6 @@ "sanctuary.structures.census.aerial.status.unknown": "inconnu", "structure.sanctuary.aerial_merchant_ship": "Bateau marchand", "structure.sanctuary.aerial_mineral_islet": "Îlot minéral", - "biome.sanctuary.swamp27_caves": "Marais souterrain" + "biome.sanctuary.swamp27_caves": "Marais souterrain", + "structure.sanctuary.drained_ocean_ruin": "Ruines océaniques asséchées" } diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/structure/drained_ocean_ruin.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/structure/drained_ocean_ruin.json new file mode 100644 index 0000000..fc974af --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/structure/drained_ocean_ruin.json @@ -0,0 +1,7 @@ +{ + "type": "sanctuary:drained_ocean_v307", + "biomes": "#minecraft:is_overworld", + "step": "top_layer_modification", + "terrain_adaptation": "none", + "spawn_overrides": {} +} diff --git a/packwiz/pack.toml b/packwiz/pack.toml index 5ad3221..d8a67fb 100644 --- a/packwiz/pack.toml +++ b/packwiz/pack.toml @@ -1,6 +1,6 @@ name = "Sanctuary" author = "KOKA99CAB" -version = "0.1.0-alpha.30.6" +version = "0.1.0-alpha.30.7" pack-format = "packwiz:1.1.0" [index] diff --git a/scripts/archive_terrain_atlas.py b/scripts/archive_terrain_atlas.py index b81d152..9f8b2f4 100644 --- a/scripts/archive_terrain_atlas.py +++ b/scripts/archive_terrain_atlas.py @@ -57,7 +57,7 @@ def main(): 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", - "scripts/structure_atlas.py", f"docs/generation-alpha{version.split('alpha.')[-1]}.md", f"docs/testing-alpha{version.split('alpha.')[-1]}.md"): + "scripts/structure_atlas.py", "scripts/finishing_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 diff --git a/scripts/finishing_atlas.py b/scripts/finishing_atlas.py new file mode 100644 index 0000000..1a3cd33 --- /dev/null +++ b/scripts/finishing_atlas.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Archive native 30.7 structure witnesses over the unchanged 30.6 terrain field.""" +import argparse, hashlib, json, re +from pathlib import Path +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt +from matplotlib.patches import Rectangle +import numpy as np + +p=argparse.ArgumentParser(description=__doc__) +for name in ('terrain','village','transit','aerial','ruins','output'): p.add_argument('--'+name,type=Path,required=True) +a=p.parse_args() +data={name:json.loads(getattr(a,name).read_text()) for name in ('terrain','village','transit','aerial','ruins')} +t=data['terrain']; seed=str(t['seed']); size=t['diameter'] +for name in ('village','transit','aerial','ruins'): + d=data[name] + if str(d['seed'])!=seed or d['diameter']!=size or not d.get('passed'): raise ValueError('Unmatched/failed '+name) +cols=np.asarray(t['variants']['current306']['columns']);xs,zs=np.unique(cols[:,0]),np.unique(cols[:,1]);g=cols.reshape(len(zs),len(xs),5) +fig,ax=plt.subplots(figsize=(11,10),layout='constrained') +step=t['map_step'];extent=(xs[0]-step/2,xs[-1]+step/2,zs[-1]+step/2,zs[0]-step/2) +im=ax.imshow(np.ma.masked_where(g[:,:,2]==0,g[:,:,2]),extent=extent,cmap='terrain',vmin=0,vmax=400,interpolation='nearest') +for link in data['village']['rail_links']: + if link['kind']=='FREIGHT': ax.plot([v['x'] for v in link['points']],[v['z'] for v in link['points']],color='#962427',lw=2) +walks=data['transit'].get('walk',[]) +if isinstance(walks,dict): walks=[walks] +for walk in walks: + gates=[v['gate'] for v in walk['surface_accesses']] + ax.plot([v[0] for v in gates],[v[2] for v in gates],color='#223494',lw=2,ls='--',marker='s',ms=4) +for b in data['ruins']['bounds']: + v={k:int(n) for k,n in re.findall(r'(minX|minZ|maxX|maxZ)=(-?\d+)',b)} + ax.add_patch(Rectangle((v['minX'],v['minZ']),v['maxX']-v['minX']+1,v['maxZ']-v['minZ']+1,fill=False,edgecolor='#02abb2',linewidth=2)) +for site in data['aerial']['geometry']: + width,depth=(site['width'],site['depth']) if site['facing']%2==0 else (site['depth'],site['width']) + colour='#8a4196' if site['kind']=='NATURAL_ISLET' else '#41301f' + ax.add_patch(Rectangle((site['minX'],site['minZ']),width,depth,fill=False,edgecolor=colour,linewidth=1.3)) + ax.scatter([site['minX']+width//2],[site['minZ']+depth//2],color=colour,s=20,marker='x') +ax.scatter([0],[0],marker='+',s=70,color='black') +for label,color,style in [('Rails prévus — lacunes réparables','#962427','-'),('Axes des traversées parcourues','#223494','--'),('Ruines asséchées — emprises','#02abb2','-'),('Trésors du terrain aérien — emprises','#8a4196','-'),('Navires restaurés — emprises','#41301f','-')]:ax.plot([],[],color=color,ls=style,label=label) +ax.legend(loc='upper left',fontsize=8);ax.set(xlabel='X (blocs)',ylabel='Z (blocs) · nord en haut',aspect='equal') +ax.set_title(f'Sanctuary alpha.30.7 · {size} · graine {seed}\nInstallations sur le relief natif inchangé de 30.6') +fig.colorbar(im,ax=ax,shrink=.7,label='Altitude du terrain Y') +fig.supxlabel('Relief échantillonné ; rails planifiés. Traversées, coffres et ruines contrôlés dans les chunks FULL.\nLes rectangles indiquent des réservations, pas la forme construite ni les frontières des biomes.',fontsize=9) +a.output.mkdir(parents=True,exist_ok=True) +for ext in ('png','pdf'):fig.savefig(a.output/f'installations-{size}-{seed}.{ext}',dpi=160) +(a.output/f'installations-{size}-{seed}-provenance.json').write_text(json.dumps({k:{'path':str(getattr(a,k)),'sha256':hashlib.sha256(getattr(a,k).read_bytes()).hexdigest()} for k in data},indent=2)+'\n')