From 79d3185cbacd8d039e5958238a976cf134ac4778 Mon Sep 17 00:00:00 2001 From: koka Date: Sat, 12 Sep 2026 01:36:05 +0200 Subject: [PATCH] feat(worldgen): add volumetric caves and nearby aerial sites for alpha28 --- CHANGELOG.md | 8 + README.md | 17 +- docs/backlog.md | 12 ++ docs/generation-alpha28.md | 61 ++++++++ docs/testing-alpha28.md | 99 ++++++++++++ gradle.properties | 4 +- mods/sanctuary/build.gradle | 8 + .../Aerial24FlowAdmissionGameTests.java | 20 ++- .../gametest/Aerial24WorldGameTests.java | 64 ++++++-- .../Alpha28NativeStructuresGameTests.java | 148 ++++++++++++++++++ .../gametest/Caves28WorldGameTests.java | 117 ++++++++++++++ ...pulationDensityEquivalenceDiagnostics.java | 6 +- .../gametest/PopulationDiagnostics.java | 6 +- .../src/gametest/resources/fabric.mod.json | 2 +- .../expansion/ExpansionChunkGenerator.java | 12 +- .../sanctuary/expansion/ExpansionRelief.java | 6 +- .../expansion/ExpansionStructures23.java | 73 +++++++-- .../expansion/MineshaftSupport28.java | 32 ++++ .../expansion/NativeStructurePlacement23.java | 24 +++ .../expansion/SwampHutPlacement28.java | 87 ++++++++++ .../mixin/NativeStructureStart23Mixin.java | 3 +- .../UnifiedDecorationProtectionMixin.java | 4 + .../sanctuary/worldgen/CavesRelief28.java | 37 +++++ .../worldgen/CavityMonsterRoom28.java | 65 ++++++++ .../koka/sanctuary/worldgen/IslandOres28.java | 53 +++++++ .../worldgen/PopulationIslandDensity.java | 66 +++++++- .../fr/koka/sanctuary/worldgen/RiftShape.java | 49 ++++++ .../worldgen/SanctuaryChunkGenerator.java | 11 +- .../sanctuary/worldgen/Swamp27Volumes.java | 2 +- .../worldgen/aerial/AerialPlanner24.java | 113 +++++++++++-- .../worldgen/aerial/AerialStore24.java | 10 +- .../worldgen/aerial/AerialStructures24.java | 7 +- .../density_function/caves28_native.json | 58 +++++++ .../density_function/population_10.json | 14 ++ .../density_function/population_20.json | 14 ++ .../density_function/population_5.json | 14 ++ .../worldgen/CavesRelief28Smoke.java | 54 +++++++ .../worldgen/aerial/Aerial24Smoke.java | 18 ++- .../aerial/AerialPersistence24Smoke.java | 7 +- packwiz/pack.toml | 2 +- 40 files changed, 1324 insertions(+), 83 deletions(-) create mode 100644 docs/generation-alpha28.md create mode 100644 docs/testing-alpha28.md create mode 100644 mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Alpha28NativeStructuresGameTests.java create mode 100644 mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Caves28WorldGameTests.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/MineshaftSupport28.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/SwampHutPlacement28.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavesRelief28.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavityMonsterRoom28.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/IslandOres28.java create mode 100644 mods/sanctuary/src/main/resources/data/sanctuary/worldgen/density_function/caves28_native.json create mode 100644 mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/CavesRelief28Smoke.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 76304a8..1607a0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.1.0-alpha.28 — 2026-09-12 + +Spéléologie par bruits natifs, déformations volumiques 3D et failles obliques bornées ; +bateaux rapprochés et îlots au-dessus de Sanctuary. Marais réduits. Mines, +huttes et donjons vanilla adaptés aux souterrains ; filons natifs selon la +profondeur dans la roche, dans l’île et les expansions. Nouveau monde de test. +Voir [génération](docs/generation-alpha28.md) et [validation](docs/testing-alpha28.md). + ## 0.1.0-alpha.27 — 2026-09-12 Retour à la base alpha.24 après rejet visuel de l’alpha.25. Le relief et les diff --git a/README.md b/README.md index fa530e1..641be7f 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,19 @@ 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.28 — grottes et lieux proches + +Cette passe prolonge la 27 validée en jeu : bruits de grottes vanilla dans +la roche, failles moins traversantes, reliefs déplacés et affaissés par du bruit 3D, +bateaux près des berges et deux îlots au-dessus de Sanctuary. Les marais +occupent moins de place. Mines, huttes et donjons vanilla bénéficient de +contrôles adaptés aux cavités ; les filons suivent la profondeur de la masse +rocheuse, y compris dans les expansions. + +Créer un **nouveau monde**. Voir le [périmètre](docs/generation-alpha28.md), +les [résultats effectivement vérifiés](docs/testing-alpha28.md) et la +[distribution](docs/packwiz.md). + ## Alpha.27 — retour au terrain de la 24 La base est le commit `e7d1f29`, sans les massifs coniques, chambres creusées @@ -572,7 +585,7 @@ sera une mise à jour explicite, avec vérification des API et des sauvegardes. | Fabric API | 0.160.0+26.3 | | Fabric Loom | 1.17.20 | | Gradle Wrapper | 9.5.1, distribution vérifiée par SHA-256 | -| Sanctuary / pack | 0.1.0-alpha.23 | +| Sanctuary / pack | 0.1.0-alpha.28 | Java 25 et Python 3.11 ou plus récent sont nécessaires. Le script pack utilise uniquement la bibliothèque standard et repère aussi une installation Python @@ -601,7 +614,7 @@ décrits dans [Validation](docs/testing.md). Résultats : -- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.23.jar` : mod à installer avec +- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.28.jar` : mod à installer avec Fabric API sur la version Minecraft indiquée. - `build/packwiz/` : pack de développement complet, avec le mod construit et l'index vérifié. Voir [Installation packwiz](packwiz/README.md). diff --git a/docs/backlog.md b/docs/backlog.md index bd7660a..c0b7583 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -966,3 +966,15 @@ marais volumétriques dans les cavités existantes et affiner les coques. Voir l [contrat](generation-alpha27.md). Les étagements, l’érosion, les nouvelles grottes et les petites sorties d’eau seront traités un à un, avec retour en jeu entre les changements ; ils ne sont pas déclarés implémentés dans cette passe. + + +### WG-30 — Spéléologie, reliefs et satellites proches — alpha.28 + +Branche `codex/caves-relief-alpha28`, depuis la 27 validée visuellement par le +créateur. Ajouter les bruits de grottes vanilla dans les masses rocheuses, +limiter et incliner les failles, développer des hauteurs avec ridge/erosion, +rapprocher les bateaux des vraies berges et placer les îlots au-dessus du terrain. +Moins de marais ; mines, huttes et donjons vanilla adaptés aux cavités réelles. +Les filons natifs doivent aussi alimenter les continents ouverts ensuite. +Aucun retour des salles ellipsoïdales ni des cônes de l’alpha.25. +Résultats et limites : [génération](generation-alpha28.md), [essais](testing-alpha28.md). diff --git a/docs/generation-alpha28.md b/docs/generation-alpha28.md new file mode 100644 index 0000000..b3aff10 --- /dev/null +++ b/docs/generation-alpha28.md @@ -0,0 +1,61 @@ +# Alpha.28 — roche à explorer + +La 27 est la base validée par le créateur. Cette passe garde son île, les +secrets minéraux et les réseaux, puis ajoute du creusement dans la roche. +Les nouvelles cavités ne sont pas des salles de structure aux volumes prescrits. + +## Terrain + +Le champ commun compose les fonctions de bruit de Minecraft 26.3-pre-2 : +cheese et cave_layer, entrées/spaghetti 3D, spaghetti 2D et roughness, piliers +et noodle. Leur altitude est déplacée pour la masse flottante. Les coupes +restent soustractives et s’atténuent vers les limites verticales ; il ne +s’agit pas de recopier toute la génération Overworld ou ses aquifères. +Les carvers historiques restent désactivés pour ne pas ajouter une seconde +passe étrangère aux colonnes de certification. + +Le relief supérieur déforme le volume rocheux dans les trois axes avec les +bruits ridge/erosion natifs échantillonnés en 3D. Les déplacements sont signés : +soulèvements, affaissements et cisaillements varient aussi avec la profondeur. +Le déplacement est borné à ±16 blocs horizontalement et ±40 verticalement, +avec une transition progressive de Y180 à Y228. L’enveloppe de l’île reste +évaluée aux coordonnées réelles ; seul le champ rocheux interne est déplacé. +Ce n’est pas une simulation physique de plaques tectoniques. Aucun cône ni +salle géométrique ne sert de forme à ajouter au terrain. +L’arrivée et les côtes gardent leur place. Les anciennes failles deviennent +obliques, interrompues et limitées en hauteur, avec de la roche au-dessus et +en dessous. Les expansions utilisent la même source de terrain ; leur climat +et leur déformation propre s’appliquent ensuite. + +Le seuil du champ de marais passe de 0,04 à 0,25 : les anciens secteurs se +réduisent au profit des biomes de grottes précédents, dont les champignons. +La hauteur du monde, les nuages et les trois diamètres sont conservés. + +## Exploration et ressources + +Les bateaux vanilla restaurés recherchent désormais les vraies berges proches. +Les deux îlots sont au-dessus de Sanctuary, avec un espace libre sous leur +roche et un seul exutoire d’eau. Leurs corps et leurs chutes d’eau ont des +emprises distinctes ; rien ne réserve toute la colonne sous un îlot sec. + +Les mines emploient les pièces vanilla, leurs supports, rails, coffres et +spawners. Une partie exposée peut passer si l’ensemble reste ancré. Les huttes +cherchent aussi des berges souterraines de marais : sol local, couverture de +la cavité et quatre pilotis natifs. Les salles à spawner sont de vraies +MonsterRoomFeature, posées sur des candidats réels et soumises aux refus +natifs. Leur présence n’est pas garantie sur chaque île. + +Une passe de filons native complète les distributions existantes dans les +chunks de l’île et des expansions. Charbon, fer, cuivre, or, lapis, redstone, +diamant et émeraude utilisent les formes OreFeature vanilla. La profondeur +se mesure dans l’enveloppe rocheuse entière de la colonne, sans recommencer +le calcul à chaque plafond de grotte. Les stocks restent finis. Les minerais +du Nether restent dans le Nether ; le Deep Dark n’est pas ajouté à l’île. + +## Livraison de test + +Mod et pack `0.1.0-alpha.28`, Minecraft `26.3-pre-2`, codec de prototype +`sanctuary:island_v24` conservé. Créer un nouveau monde. Aucune migration, +aucune régénération ni intervention dans les sauvegardes personnelles. +L’alpha.27 et ses artefacts restent disponibles. Les vérifications réelles +et leurs limites sont dans [le compte rendu](testing-alpha28.md). diff --git a/docs/testing-alpha28.md b/docs/testing-alpha28.md new file mode 100644 index 0000000..666a286 --- /dev/null +++ b/docs/testing-alpha28.md @@ -0,0 +1,99 @@ +# Alpha.28 — vérifications + +## Protocole + +Tests sur macOS avec Java 25 et Minecraft 26.3-pre-2. Aucun essai Windows +natif ni jugement de rendu client n’est revendiqué. Le créateur a validé +visuellement la 27 ; les coupes de densité servent ici à contrôler cette +nouvelle passe, sans représenter l’eau, la végétation ou les structures. + +Commande de livraison : + +```sh +./gradlew check build -PsanctuaryDensityMaps=false -PsanctuaryAerial24Tests=true +``` + +`sanctuaryDensityMaps=false` coupe la production des anciennes images, +pas les assertions. Le monde GameTest est recréé dans le dossier de développement +ignoré. Les sauvegardes personnelles ne sont pas utilisées par les tests. + +Les smokes de placement aérien examinent 512 / 724 / 1 024, graines 0 / 42 / +2026. Le témoin natif chargé en chunks FULL est Moyen 724, graine 0 ; ceci +ne prétend pas être neuf îles entièrement explorées. Les comparaisons de +terrain analysent deux coupes espacées de 2 blocs et une grille de hauteurs +espacée de 32 blocs. Une expansion naturelle de 512 est contrôlée par son +vrai champ de densité sans être réservée ni ouverte dans le serveur. + +## Résultats + +`check build` passe en **4 min 15 s**, dont **21/21 GameTests natifs** +en 57,06 s. `assemblePack` passe ensuite sans recompilation des binaires vérifiés. +Les smokes couvrent aussi le Blocodex, les formats de journal, les priorités +et les réservations d’expansion, ainsi que les anciennes géométries pures. + +Le terrain de référence conserve **6 098 des 6 860** anciens échantillons +rocheux intérieurs (88,9 %), avec **762** désormais creusés. Cette comparaison +inclut les raccords du nouveau cisaillement, pas seulement les grottes. +L’expansion de 512 conserve 1 299 échantillons contre 136 creusés. + +Sur la grille de hauteurs, 54 colonnes hautes montent et 84 s’abaissent. +La hausse maximale relevée est de 32 blocs et le sommet échantillonné atteint +Y280. La baisse maximale de sommet, 132 blocs en (0,160), correspond à un +lobe supérieur qui quitte cette colonne : le sol observé passe de Y248 à +Y116. Elle ne désigne pas un déplacement vertical de 132 blocs ; le déplacement +3D est borné à ±40 en Y. Des surplombs et étranglements subsistent et restent +à apprécier visuellement dans le jeu. + +Les **9 chunks FULL** inspectés contiennent les huit minerais Overworld : + +| Minerai | Blocs observés, variantes deepslate comprises | +|---|---:| +| Charbon | 1408 | +| Fer | 325 | +| Cuivre | 302 | +| Or | 87 | +| Lapis | 53 | +| Redstone | 77 | +| Diamant | 16 | +| Émeraude | 4 | + +Il s’agit d’un relevé après génération, pas de quotas imposés à chaque chunk. + +La relecture finale des fichiers de région confirme **trois donjons vanilla** +avec spawners zombies et coffres `minecraft:chests/simple_dungeon` : +`-101 170 100`, `-92 175 -92` et `-85 178 11`. Une vraie mine est présente +vers `152 176 -120`, avec **143 pièces sauvegardées**. Dans les 14 chunks FULL +du contrôle des structures : 91 planches, 88 barrières, 13 rails, 29 toiles +et un spawner ont été observés dans les pièces chargées. Ce relevé sur les +fichiers MCA après fermeture du serveur ne génère aucun chunk supplémentaire. + +Les quatre lieux aériens ont de vrais départs et pièces sauvegardées, +quatre coffres et deux marchands adultes au total, puis passent les contrôles +après ouverture, extraction et modification dans le protocole natif. Il y a +exactement une source de cascade et deux îlots survolant le terrain. Aucun +nouveau site ni habitant n’est recréé pour remplacer ce qui a été utilisé. + +Preuves locales ignorées : `build/alpha28-check-build-final.log`, +`build/alpha28-preview/geology-final.json`, +`build/alpha28-preview/geology-final-comparison.png`, +`build/alpha28-evidence/final/` et reçus de distribution `build/alpha28-*`. + + +## Limites et essai du créateur + +- La hutte de sorcière est conditionnelle aux berges 3D, à l’espace et aux quatre + appuis natifs. Le premier petit échantillon n’en contient aucune : son audit + de placement ne constitue pas une preuve de présence en jeu. +- La source de la cascade est contrôlée ; son écoulement complet jusqu’au vide + n’est pas mesuré par ce protocole. La propagation reste celle de Minecraft. +- Les témoins historiques purs restent vérifiés ; les comparaisons exactes de + l’ancien champ utilisent explicitement `legacy27()`. Elles ne prétendent pas + que le nouveau relief serait identique à celui de la 27. +- Il ne s’agit pas d’un benchmark client ni d’une simulation physique tectonique. + Les performances pourront être étudiées après ce retour visuel. + +Créer un **nouveau monde Sanctuary**, taille **Moyen**, graine **0** pour +retrouver le témoin. Garder ses autres sauvegardes ; aucune migration ni +régénération n’est réalisée. Valider en priorité les masses soulevées et +abaissées, les cavités à explorer, les îlots au-dessus du terrain et les +navires proches des berges. diff --git a/gradle.properties b/gradle.properties index 3044c72..9959883 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.27 -pack_version=0.1.0-alpha.27 +mod_version=0.1.0-alpha.28 +pack_version=0.1.0-alpha.28 maven_group=fr.koka.sanctuary diff --git a/mods/sanctuary/build.gradle b/mods/sanctuary/build.gradle index 620fe98..7b06405 100644 --- a/mods/sanctuary/build.gradle +++ b/mods/sanctuary/build.gradle @@ -623,3 +623,11 @@ tasks.register('swamp27VolumesSmoke', JavaExec) { mainClass = 'fr.koka.sanctuary.worldgen.Swamp27VolumesSmoke' } tasks.named('check') { dependsOn('swamp27VolumesSmoke') } + +tasks.register('cavesRelief28Smoke', JavaExec) { + group = 'verification' + dependsOn('testClasses') + classpath = sourceSets.test.runtimeClasspath + mainClass = 'fr.koka.sanctuary.worldgen.CavesRelief28Smoke' +} +tasks.named('check') { dependsOn('cavesRelief28Smoke') } diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Aerial24FlowAdmissionGameTests.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Aerial24FlowAdmissionGameTests.java index 6d29738..cd5df01 100644 --- a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Aerial24FlowAdmissionGameTests.java +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Aerial24FlowAdmissionGameTests.java @@ -44,7 +44,9 @@ public final class Aerial24FlowAdmissionGameTests { var admitted = new ArrayList(); try { report.put("stage", "real admission toward an unseen satellite"); - admitted.add(admit(helper, level, generator, session, plan, true, report)); + if (!requests(level.getSeed(), session.islands(), plan, true).isEmpty()) + admitted.add(admit(helper, level, generator, session, plan, true, report)); + else report.put("collision_admission", "No raw expansion candidate reaches the close sites; initial island ownership already excludes them"); report.put("stage", "real admission through an unclaimed interval"); admitted.add(admit(helper, level, generator, session, plan, false, report)); } catch (Exception error) { failure(report, error); throw error; } @@ -88,15 +90,17 @@ public final class Aerial24FlowAdmissionGameTests { if (!tickets[0]) { requested.forEach(chunk -> level.getChunkSource().addTicketWithRadius(TicketType.DRAGON, chunk, 2)); tickets[0] = true; } - flowTicks[0] += 20; report.put("flow_wait_ticks", flowTicks[0]); report.put("stage", "native fluid ticks toward the void"); + flowTicks[0] += 20; report.put("flow_wait_ticks", flowTicks[0]); report.put("stage", "native fluid ticks beneath the suspended island"); var source = AerialGeometry24.waterfallSource(waterfall); helper.assertTrue(level.getFluidState(source).is(FluidTags.WATER) && level.getFluidState(source).isSource(), "The authored source remains a single native water source"); - var below = waterAt(level, waterfall, waterfall.minY() - 16); - var low = waterAt(level, waterfall, 8); + // Alpha28 islets stand above real terrain: a cascade may land on Sanctuary. + // Require native flow through the free air gap, not an impossible route through rock. + var below = waterAt(level, waterfall, waterfall.minY() - 1); + var low = waterAt(level, waterfall, waterfall.minY() - AerialPlanner24.MIN_ISLET_GAP); report.put("water_below_islet", below == null ? List.of() : coords(below)); - report.put("water_near_y0", low == null ? List.of() : coords(low)); + report.put("water_across_air_gap", low == null ? List.of() : coords(low)); if (below == null || low == null) { - helper.assertTrue(flowTicks[0] <= 4000, "Scheduled vanilla fluid ticks carry the waterfall below the island and down to y=8"); + helper.assertTrue(flowTicks[0] <= 4000, "Scheduled vanilla fluid ticks carry the waterfall through the sixteen-block air gap"); helper.runAtTickTime(helper.getTick() + 20, () -> step[0].run()); return; } helper.assertTrue(!level.getFluidState(low).isSource(), "Low waterfall water is flowing, not a fabricated source column"); @@ -128,7 +132,7 @@ public final class Aerial24FlowAdmissionGameTests { helper.assertTrue(elapsed <= 31_000, "Actual Session.create keeps its production thirty-second deadline"); helper.assertTrue(!plan.conflicts(reserved), "The admitted expansion cannot claim any satellite chunk"); helper.assertTrue(!ExpansionVoidReuse.requiresReload(level, reserved.id()), "Untouched candidate prepares in the same development session"); - if (!collision) helper.assertTrue(inAerialBand(reserved, plan), "The admitted free interval lies among the nearby satellites, not beyond a blanket exclusion ring"); + helper.assertTrue(session.create(request.id(), "sanctuary", request.direction(), "temperate", request.diameter(), "natural").equals(reserved), "Reservation retry is idempotent"); attempts.add(Map.of("request", request, "reserved", reserved, "elapsed_ms", elapsed)); @@ -149,7 +153,7 @@ public final class Aerial24FlowAdmissionGameTests { var raw = ExpansionPlacement.candidates(seed, islands, id, "sanctuary", direction, "temperate", 128, "natural", 0); if (raw.isEmpty() || plan.conflicts(raw.getFirst()) != collision) continue; var filtered = raw.stream().filter(candidate -> !plan.conflicts(candidate)).findFirst().orElse(null); - if (filtered == null || !collision && !inAerialBand(filtered, plan)) continue; + if (filtered == null) continue; result.add(new Request(id, direction, 128, raw.getFirst(), filtered)); } return result; 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 067f070..0c7c572 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 @@ -112,6 +112,12 @@ public final class Aerial24WorldGameTests { level.getChunk(chunk.x(), chunk.z(), ChunkStatus.FULL, true); helper.runAtTickTime(helper.getTick() + 1, () -> step[0].run()); return; } + if (!report.containsKey("native_departures_ready")) { + // Fail on a missing native start immediately instead of waiting for entities + // which could never have been placed (for example, an obsolete void-only gate). + report.put("stage", "verify saved aerial departures before entity loading"); + report.put("native_departures_ready", starts(helper, level, plan).size()); + } if (!tickets[0]) { entityChunks.forEach(pos -> level.getChunkSource().addTicketWithRadius(TicketType.DRAGON, pos, 2)); tickets[0] = true; } @@ -170,14 +176,25 @@ public final class Aerial24WorldGameTests { var root = generator.source().islands().stream().filter(ExpansionIsland::origin).findFirst().orElseThrow(); var views = new LinkedHashMap(); for (var site : plan.sites()) { - helper.assertTrue(site.claimedChunks().stream().noneMatch(chunk -> root.ownsChunk(chunk.x(), chunk.z())), "No aerial claim replaces natural starter chunks"); + if (site.merchant()) { + 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 { + 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"); + int highest = 0; + for (int y = 382; y > 0; y--) if (!column.getBlock(y).isAir()) { highest = y; break; } + helper.assertTrue(site.minY() - highest - 1 >= AerialPlanner24.MIN_ISLET_GAP, "At least sixteen air blocks separate ground and suspended rock"); + } var overlap = new ExpansionIsland("aerial_overlap", root.id(), "east", "temperate", 64, site.startChunkX() * 16, site.startChunkZ() * 16, 42, "natural", "reserved", 24); helper.assertTrue(plan.conflicts(overlap), "An unseen site already prevents overlapping expansion ownership"); double distance = Math.hypot(site.locator().getX() - site.shoreX(), site.locator().getZ() - site.shoreZ()); helper.assertTrue(distance <= 512, "Aerial site remains within 512 blocks of a real shore witness"); views.put(site.id(), Map.of("shore", List.of(site.shoreX(), site.shoreY() + 2, site.shoreZ()), - "entry", coordinates(AerialGeometry24.entry(site)), "centre", coordinates(site.locator()), "shore_distance", distance, + "entry", coordinates(AerialGeometry24.entry(site)), "centre", coordinates(site.locator()), "shore_distance", distance, "hull_to_shore", AerialPlanner24.shoreDistance(site), "exterior_view", List.of(site.minX() - 24, site.minY() + site.height() / 2, site.minZ() - 24))); } helper.assertTrue(!plan.conflicts(new ExpansionIsland("aerial_far", root.id(), "east", "temperate", 64, 4096, 4096, 42, "natural", "reserved", 24)), @@ -223,8 +240,17 @@ public final class Aerial24WorldGameTests { for (int dy = -2; dy <= 20; dy++) column.put(shore.getY() + dy, level.getBlockState(shore.above(dy)).toString()); shores.put(site.id(), Map.of("position", coordinates(shore), "column", column)); helper.assertTrue(level.getBlockState(shore.below()).isSolid(), "Saved shore witness has a real FULL terrain support: " + site.id() + " " + shore); - helper.assertTrue(level.getBlockState(shore.above()).getCollisionShape(level, shore.above()).isEmpty(), - "Shore viewpoint is not buried inside the island: " + site.id() + " " + shore.above() + " " + level.getBlockState(shore.above())); + if (site.merchant()) { + helper.assertTrue(level.getBlockState(shore.above()).getCollisionShape(level, shore.above()).isEmpty(), + "Shore viewpoint is not buried inside the island: " + site.id() + " " + shore.above() + " " + level.getBlockState(shore.above())); + } else { + // Natural trees may occupy the mathematical ground reference below an islet. + // Observe a real nearby walking position without removing that vegetation. + var viewpoint = nearbyGroundView(level, shore); + helper.assertTrue(viewpoint != null, "A walkable ground viewpoint exists within twelve horizontal blocks below the suspended islet: " + site.id()); + shores.put(site.id(), Map.of("position", coordinates(shore), "column", column, + "walkable_viewpoint", coordinates(viewpoint), "reference_obstruction", level.getBlockState(shore.above()).toString())); + } var chestAt = AerialGeometry24.chest(site); helper.assertTrue(level.getBlockEntity(chestAt) instanceof RandomizableContainerBlockEntity, "Real chest at " + site.id()); chestCount++; var chest = (RandomizableContainerBlockEntity) level.getBlockEntity(chestAt); @@ -246,12 +272,15 @@ public final class Aerial24WorldGameTests { var source = AerialGeometry24.waterfallSource(site); helper.assertTrue(level.getFluidState(source).isSource(), "The one real source drives the native waterfall"); waterSources++; } - // These columns are beyond the authored box and well outside initial terrain ownership. - for (var pos : List.of(new BlockPos(site.minX() - 2, site.minY(), site.minZ() - 2), new BlockPos(site.maxX() + 2, site.minY(), site.maxZ() + 2))) - for (int y = site.minY(); y <= site.maxY() + 2; y++) { - var state = level.getBlockState(pos.atY(y)); - helper.assertTrue(state.isAir() || site.waterfall() && !state.getFluidState().isEmpty(), "No manufactured structure leaks outside its authored footprint"); - } + // Root terrain and its own trees may now exist outside these aerial envelopes. + // Chunk clipping is checked against every authored position; do not confuse legal + // surrounding terrain with a structure leak by requiring a whole exterior air column. + helper.assertTrue(model.keySet().stream().allMatch(site.bounds()::isInside), "Every proposed structure block is inside its finite envelope"); + var exterior = new ArrayList>(); + for (var at : List.of(new BlockPos(site.minX() - 2, site.minY(), site.minZ() - 2), + new BlockPos(site.maxX() + 2, site.minY(), site.maxZ() + 2))) + 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"); report.put("chests", chestCount); report.put("waterfall_sources", waterSources); report.put("blocks_before", countSites(level, plan)); @@ -259,6 +288,21 @@ public final class Aerial24WorldGameTests { return expected; } + private static BlockPos nearbyGroundView(ServerLevel level, BlockPos reference) { + for (int radius = 0; radius <= 12; radius++) for (int dx = -radius; dx <= radius; dx++) for (int dz = -radius; dz <= radius; dz++) { + if (Math.max(Math.abs(dx), Math.abs(dz)) != radius || Math.hypot(dx, dz) > 12) continue; + for (int dy = 8; dy >= -8; dy--) { + var at = reference.offset(dx, dy, dz); + var floor = level.getBlockState(at.below()); + if (!floor.isSolid() || floor.is(net.minecraft.tags.BlockTags.LEAVES) || floor.is(net.minecraft.tags.BlockTags.LOGS)) continue; + if (level.getBlockState(at).getCollisionShape(level, at).isEmpty() + && level.getBlockState(at.above()).getCollisionShape(level, at.above()).isEmpty() + && level.getFluidState(at).isEmpty() && level.getFluidState(at.above()).isEmpty()) return at; + } + } + return null; + } + private static Map vegetation(GameTestHelper helper, ServerLevel level, AerialPlan24.Site site) { helper.assertTrue(site.rockHeight() >= 20 && site.rockHeight() <= 35 && site.height() == site.rockHeight() + AerialPlan24.VEGETATION_HEADROOM, 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 new file mode 100644 index 0000000..6120c48 --- /dev/null +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Alpha28NativeStructuresGameTests.java @@ -0,0 +1,148 @@ +package fr.koka.sanctuary.gametest; + +import com.google.gson.GsonBuilder; +import fr.koka.sanctuary.SanctuaryMod; +import fr.koka.sanctuary.expansion.ExpansionStructures23; +import fr.koka.sanctuary.expansion.SwampHutPlacement28; +import fr.koka.sanctuary.knowledge.structures.StructureCensusMemory; +import fr.koka.sanctuary.knowledge.structures.StructureCensusService; +import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import net.fabricmc.fabric.api.gametest.v1.GameTest; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.SpawnerBlockEntity; +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; + +/** Observes normal root generation only. No start injection, fake room, expansion or seed search. */ +public final class Alpha28NativeStructuresGameTests { + private static final com.google.gson.Gson JSON = new GsonBuilder().setPrettyPrinting().create(); + private static final Path OUT = Path.of("diagnostics/native28"); + + @GameTest(maxTicks = 3600) + public void actualNativeStructures(GameTestHelper helper) throws Exception { + var report = new LinkedHashMap(); + try { + var level = helper.getLevel(); var host = (SanctuaryChunkGenerator) level.getChunkSource().getGenerator(); + report.put("seed", level.getSeed()); report.put("diameter", host.initialDiameter()); + report.put("process_id", ProcessHandle.current().pid()); report.put("generate_structures", level.structureManager().shouldGenerateStructures()); + if (!level.structureManager().shouldGenerateStructures()) { + report.put("scope", "Native structures globally disabled"); report.put("passed", true); write(report); helper.succeed(); return; + } + var root = host.source().islands().stream().filter(island -> island.origin()).findFirst().orElseThrow(); + var candidates = ExpansionStructures23.candidates(root).stream().limit(12).toList(); + var registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); + var found = new LinkedHashMap(); var observed = new TreeMap(); + for (var candidate : candidates) { + var chunk = level.getChunk(candidate.chunk().x(), candidate.chunk().z(), ChunkStatus.STRUCTURE_STARTS, true); + for (var start : chunk.getAllStarts().values()) { + if (!start.isValid()) continue; + String id = registry.getKey(start.getStructure()).toString(); observed.merge(id, 1, Integer::sum); + if (List.of("minecraft:mineshaft", "minecraft:mineshaft_mesa", "minecraft:swamp_hut").contains(id)) found.putIfAbsent(id, start); + } + } + report.put("candidate_chunks", candidates.size()); report.put("observed_starts", observed); + var full = new LinkedHashSet(); + // Only a few actual pieces are visited: this is not full-map pregeneration. + for (var entry : found.entrySet()) { + full.add(entry.getValue().getChunkPos()); + int remaining = entry.getKey().equals("minecraft:swamp_hut") ? 4 : 4; + for (var piece : entry.getValue().getPieces()) { + if (remaining-- <= 0) break; + var b = piece.getBoundingBox(); full.add(new ChunkPos(b.getCenter().getX() >> 4, b.getCenter().getZ() >> 4)); + if (entry.getKey().equals("minecraft:swamp_hut")) b.intersectingChunks().forEach(full::add); + } + } + // A few additional real generation chunks can contain native monster_room attempts. + for (var candidate : candidates) { + if (full.size() >= 16) break; + full.add(candidate.chunk()); + } + helper.assertTrue(full.size() <= 24, "Bounded native structure witness requests at most24 FULL chunks"); + var iterator = full.iterator(); Runnable[] step = new Runnable[1]; + step[0] = () -> { + try { + if (iterator.hasNext()) { + var at = iterator.next(); report.put("stage", "FULL " + at); + level.getChunk(at.x(), at.z(), ChunkStatus.FULL, true); + helper.runAtTickTime(helper.getTick() + 1, () -> step[0].run()); return; + } + inspect(helper, level, found, full, report); + report.put("passed", true); write(report); helper.succeed(); + } catch (Exception error) { failure(report, error); throw new IllegalStateException("Native28 witness failed", error); } + }; + helper.runAtTickTime(helper.getTick() + 1, () -> step[0].run()); + } catch (Exception error) { failure(report, error); throw error; } + } + + private static void inspect(GameTestHelper helper, ServerLevel level, Map found, + Set full, Map report) { + var registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); + var context = StructurePieceSerializationContext.fromLevel(level); + var proofs = new LinkedHashMap(); + for (var entry : found.entrySet()) { + var expected = entry.getValue(); var cp = expected.getChunkPos(); + var actual = level.getChunk(cp.x(), cp.z(), ChunkStatus.FULL, true) + .getStartForStructure(registry.getValue(Identifier.parse(entry.getKey()))); + helper.assertTrue(actual != null && actual.isValid(), "The real selected native start survives FULL: " + entry.getKey()); + var tag = actual.createTag(context, cp); + var restored = StructureStart.loadStaticStart(context, tag, level.getSeed()); + helper.assertTrue(restored != null && tag.equals(restored.createTag(context, cp)), "Native piece NBT roundtrip: " + entry.getKey()); + var counts = new TreeMap(); var positions = new HashSet(); + for (var piece : actual.getPieces()) { + var b = piece.getBoundingBox(); + for (int x = b.minX(); x <= b.maxX(); x++) for (int z = b.minZ(); z <= b.maxZ(); z++) { + if (!full.contains(new ChunkPos(x >> 4, z >> 4))) continue; + for (int y = b.minY(); y <= b.maxY(); y++) { + BlockPos pos = new BlockPos(x, y, z); if (!positions.add(pos)) continue; + var state = level.getBlockState(pos); + if (!state.isAir()) counts.merge(BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(), 1, Integer::sum); + } + } + if (entry.getKey().equals("minecraft:swamp_hut")) for (var pier : SwampHutPlacement28.piers(piece)) { + var state = level.getBlockState(pier); + helper.assertTrue(state.is(Blocks.OAK_LOG) || state.isSolid(), "A native hut pier has wood or a real solid foot: " + pier); + } + } + if (entry.getKey().contains("mineshaft")) helper.assertTrue(counts.entrySet().stream() + .filter(e -> e.getKey().endsWith("_planks") || e.getKey().endsWith("_fence") || e.getKey().equals("minecraft:rail")) + .mapToInt(Map.Entry::getValue).sum() > 0, "A selected mine creates actual native timber/rail geometry"); + proofs.put(entry.getKey(), Map.of("start_chunk", List.of(cp.x(), cp.z()), "pieces", actual.getPieces().size(), + "blocks_in_loaded_pieces", counts, "native_tag", tag.toString(), "visit", actual.getPieces().getFirst().getLocatorPosition().toShortString())); + } + var rooms = new ArrayList(); + for (var site : StructureCensusService.snapshot(level.getServer()).evidence().sites().values()) { + var key = site.key(); + if (!key.id().equals(StructureCensusMemory.MONSTER_ROOM) || !key.dimension().equals(level.dimension().identifier().toString())) continue; + var cp = new ChunkPos(key.x() >> 4, key.z() >> 4); + if (!full.contains(cp)) continue; + var at = new BlockPos(key.x(), key.y(), key.z()); + helper.assertTrue(level.getBlockEntity(at) instanceof SpawnerBlockEntity, "Successful vanilla monster_room has its real spawner at " + at); + rooms.add(Map.of("position", List.of(key.x(), key.y(), key.z()), "spawner", true)); + } + report.put("structures", proofs); report.put("monster_rooms", rooms); report.put("full_chunks", full.size()); + report.put("mineshaft_found", found.keySet().stream().anyMatch(id -> id.contains("mineshaft"))); + report.put("swamp_hut_found", found.containsKey("minecraft:swamp_hut")); + report.put("monster_room_found", !rooms.isEmpty()); + report.put("scope", "At most12 normal start candidates and24 FULL chunks; absence is reported, never replaced by synthetic fixtures"); + } + private static void write(Map report) throws Exception { + Files.createDirectories(OUT); Files.writeString(OUT.resolve("native.json"), JSON.toJson(report) + "\n"); + } + private static void failure(Map report, Exception error) { + report.put("passed", false); report.put("error", error.toString()); + try { Files.createDirectories(OUT); Files.writeString(OUT.resolve("failure.json"), JSON.toJson(report) + "\n"); } + catch (Exception io) { error.addSuppressed(io); } + SanctuaryMod.LOGGER.error("Native28 witness failed", error); + } +} diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Caves28WorldGameTests.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Caves28WorldGameTests.java new file mode 100644 index 0000000..372096f --- /dev/null +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Caves28WorldGameTests.java @@ -0,0 +1,117 @@ +package fr.koka.sanctuary.gametest; + +import com.google.gson.GsonBuilder; +import fr.koka.sanctuary.SanctuaryMod; +import fr.koka.sanctuary.worldgen.PopulationIslandDensity; +import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import net.fabricmc.fabric.api.gametest.v1.GameTest; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.levelgen.densityfunction.SamplerContext; + +/** Bounded comparison of the real registered density, followed by inspection of ordinary FULL chunks. */ +public final class Caves28WorldGameTests { + @GameTest(maxTicks = 3600) + public void geologyAndNativeOres(GameTestHelper helper) throws Exception { + var level = helper.getLevel(); + var generator = (SanctuaryChunkGenerator) level.getChunkSource().getGenerator(); + var binding = generator.starterContext(); + var field = (PopulationIslandDensity) level.registryAccess().lookupOrThrow(Registries.DENSITY_FUNCTION) + .getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, + SanctuaryMod.id("population_" + generator.capacity().players()))).value(); + helper.assertTrue(field.geology28(), "Current world uses the native-cave adaptation"); + var current = binding.random().getSampler(field); + var previous = binding.random().getSampler(field.legacy27()); + var context = SamplerContext.builder().enableCaches().build(); + var report = new LinkedHashMap(); + report.put("seed", level.getSeed()); report.put("diameter", generator.initialDiameter()); + report.put("scope", "Registered density comparison; no synthetic terrain, water, ore or room is placed by the test"); + var heights = new ArrayList>(); + long oldSolid = 0, remainingSolid = 0, excavated = 0, restored = 0; + int min = -Math.min(320, generator.initialDiameter() / 2), max = -min, raised = 0, lowered = 0, largestUplift = 0, largestSubsidence = 0; + for (int z = min; z <= max; z += 32) for (int x = min; x <= max; x += 32) { + int oldTop = 0, newTop = 0; + for (int y = 2; y <= 382; y += 2) { + boolean old = previous.sampleValue(context, x, y, z) > 0; + boolean now = current.sampleValue(context, x, y, z) > 0; + if (old) oldTop = y; if (now) newTop = y; + if (y >= 48 && y <= 202) { + if (old) { oldSolid++; if (now) remainingSolid++; else excavated++; } + else if (now) restored++; + } + if (Math.hypot(x, z) < 40 && y >= 240) + helper.assertTrue(old == now, "High arrival neighbourhood remains unchanged"); + } + if (newTop > oldTop && oldTop > 208) { raised++; largestUplift = Math.max(largestUplift, newTop - oldTop); } + if (newTop < oldTop && oldTop > 240) { lowered++; largestSubsidence = Math.max(largestSubsidence, oldTop - newTop); } + heights.add(List.of(x, z, oldTop, newTop)); + } + report.put("heights", heights); report.put("old_solid_samples", oldSolid); + report.put("remaining_solid_samples", remainingSolid); report.put("excavated_samples", excavated); + report.put("restored_rift_samples", restored); report.put("raised_columns", raised); + report.put("largest_uplift", largestUplift); + report.put("lowered_columns", lowered); report.put("largest_subsidence", largestSubsidence); + helper.assertTrue(excavated > 0 && remainingSolid > excavated, "New passages exist while most sampled former rock remains"); + helper.assertTrue(raised > 0, "Native volumetric deformation raises some uplands"); + var childField = (PopulationIslandDensity) level.registryAccess().lookupOrThrow(Registries.DENSITY_FUNCTION) + .getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, SanctuaryMod.id("population_5"))).value(); + var child = new fr.koka.sanctuary.expansion.ExpansionIsland("geology_witness", "sanctuary", "east", "temperate", + 512, 2048, 0, level.getSeed(), "natural", "ready", 24); + var childSettings = level.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS) + .getOrThrow(ResourceKey.create(Registries.NOISE_SETTINGS, SanctuaryMod.id("population_5"))).value(); + var childState = net.minecraft.world.level.levelgen.RandomState.create(level.registryAccess().lookupOrThrow(Registries.NOISE), child.seed(), childSettings); + var childNew = childState.getSampler(new fr.koka.sanctuary.expansion.ExpansionDensity(childField, child)); + var childOld = childState.getSampler(new fr.koka.sanctuary.expansion.ExpansionDensity(childField.legacy27(), child)); + long childExcavated = 0, childRemaining = 0; + for (int z = -160; z <= 160; z += 32) for (int dx = -160; dx <= 160; dx += 32) + for (int y = 48; y <= 202; y += 4) { + if (childOld.sampleValue(context, 2048 + dx, y, z) > 0) { + if (childNew.sampleValue(context, 2048 + dx, y, z) <= 0) childExcavated++; + else childRemaining++; + } + } + report.put("expansion_density", Map.of("diameter", 512, "excavated", childExcavated, + "remaining", childRemaining, "reserved_or_opened", false)); + helper.assertTrue(childExcavated > 0 && childRemaining > childExcavated, "The actual expansion wrapper uses the same finite cave field"); + var sections = new LinkedHashMap(); + for (int z : new int[]{0, 96}) { + var before = new ArrayList(); var after = new ArrayList(); + for (int y = 382; y >= 0; y -= 2) { + var a = new StringBuilder(); var b = new StringBuilder(); + for (int x = min; x <= max; x += 2) { + a.append(previous.sampleValue(context, x, y, z) > 0 ? '#' : '.'); + b.append(current.sampleValue(context, x, y, z) > 0 ? '#' : '.'); + } + before.add(a.toString()); after.add(b.toString()); + } + sections.put("z" + z, Map.of("before", before, "after", after, "min_x", min, "step", 2)); + } + report.put("sections", sections); + var ores = new TreeMap(); var oreDepths = new TreeMap>(); + for (int cx : new int[]{-6, 0, 6}) for (int cz : new int[]{-6, 0, 6}) { + var chunk = level.getChunk(cx, cz, ChunkStatus.FULL, true); + for (int x = 0; x < 16; x++) for (int z = 0; z < 16; z++) for (int y = 1; y < 383; y++) { + var state = chunk.getBlockState(new BlockPos(cx * 16 + x, y, cz * 16 + z)); + String id = BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(); + if (id.endsWith("_ore")) { + ores.merge(id, 1, Integer::sum); + var band = oreDepths.computeIfAbsent(id, ignored -> new ArrayList<>(List.of(384, 0))); + band.set(0, Math.min(band.get(0), y)); band.set(1, Math.max(band.get(1), y)); + } + } + } + report.put("full_chunks_inspected", 9); report.put("ores", ores); report.put("ore_y_ranges", oreDepths); + helper.assertTrue(!ores.isEmpty(), "Actual generated rock contains native ore deposits"); + report.put("passed", true); + Path path = Path.of("diagnostics/alpha28/geology.json"); Files.createDirectories(path.getParent()); + Files.writeString(path, new GsonBuilder().setPrettyPrinting().create().toJson(report)); + helper.succeed(); + } +} diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/PopulationDensityEquivalenceDiagnostics.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/PopulationDensityEquivalenceDiagnostics.java index 3f78297..26b457e 100644 --- a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/PopulationDensityEquivalenceDiagnostics.java +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/PopulationDensityEquivalenceDiagnostics.java @@ -23,7 +23,7 @@ import net.minecraft.world.level.levelgen.densityfunction.DensitySampler; import net.minecraft.world.level.levelgen.densityfunction.DensityVolume; import net.minecraft.world.level.levelgen.densityfunction.SamplerContext; -/** Compares production against the frozen alpha.10 implementation; no chunks are created. */ +/** Compares the explicit legacy27 mode against frozen alpha10, not the intentional alpha28 terrain changes. */ final class PopulationDensityEquivalenceDiagnostics { private record Pair(int players, long seed, DensitySampler reference, DensitySampler actual) {} @@ -39,7 +39,7 @@ final class PopulationDensityEquivalenceDiagnostics { var key = SanctuaryMod.id("population_" + players); var registered = densities.getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, key)).value(); helper.assertTrue(registered instanceof PopulationIslandDensity, "Compare the real registered Population density"); - var density = (PopulationIslandDensity) registered; + var density = ((PopulationIslandDensity) registered).legacy27(); helper.assertTrue(ExactInterpolationSampler.supportsTerrain(density.terrain()), "The shipped coordinate-only terrain must actually use the exact cache, not the fallback"); var reference = new Alpha10PopulationDensityReference(density.terrain(), density.distortion(), density.sculpt(), @@ -113,7 +113,7 @@ final class PopulationDensityEquivalenceDiagnostics { "reference_commit", "a26b72f", "reference_source_sha256", "fcc5a8a39b7c4d181d74ce4f9183a1ca93646137955ac64bab9d22e6db0752a1", "comparison", "Float.floatToRawIntBits; no tolerance", "profiles", reports, "adversarial", adversarial, "concurrent_comparisons", concurrentComparisons, "concurrent_workers", 3, "passed", true, - "scope", "Real compiled scalar/bulk densities, 4 capacities x 3 seeds (5/20/100 compatibility and new10 using the same parameterized algorithm), signed coordinates, region/coast/height boundaries, context switches and concurrency; no block changes")) + "\n"); + "scope", "Explicit legacy27 mode versus frozen alpha10: real compiled scalar/bulk densities, 4 capacities x 3 seeds, signed coordinates, region/coast/height boundaries, context switches and concurrency. Geology28 is intentionally disabled only for this historical equivalence proof; no alpha28 generation claim.")) + "\n"); } private static List volumes(IslandCapacity capacity) { diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/PopulationDiagnostics.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/PopulationDiagnostics.java index d5607a9..79abb6e 100644 --- a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/PopulationDiagnostics.java +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/PopulationDiagnostics.java @@ -87,6 +87,7 @@ public final class PopulationDiagnostics { ResourceKey.create(Registries.DENSITY_FUNCTION, SanctuaryMod.id("population_" + capacity.players()))).value(); helper.assertTrue(density instanceof PopulationIslandDensity, "Inspect the actual compiled population density"); var island = (PopulationIslandDensity) density; + var legacyEnvelope = island.legacy27(); helper.assertTrue(island.capacity() == capacity, "Noise settings and density must agree on saved capacity"); var random = binding.random(); var replay = RandomState.create(level.registryAccess().lookupOrThrow(Registries.NOISE), random.seed(), generator.generatorSettings().value()); @@ -102,7 +103,10 @@ public final class PopulationDiagnostics { float value = random.sampleBlockValueUncached(density, x, y, z); float raw = random.sampleBlockValueUncached(island.terrain(), x, y - 64, z); helper.assertTrue(Float.isFinite(value), "Density remains finite at every sampled position"); - helper.assertTrue(value <= 0 || raw > 0, "Enlarging the envelope must never invent terrain inside natural holes"); + float historical = island.geology28() + ? random.sampleBlockValueUncached(legacyEnvelope, x, y, z) : value; + helper.assertTrue(historical <= 0 || raw > 0, + "The explicit legacy27 envelope never fills its raw natural holes; alpha28 uplands change sample Y"); if (value > 0) { if (bottom < 0) bottom = y; top = y; } if (x % (step * 8) == 0 && z % (step * 8) == 0) helper.assertTrue(Float.floatToIntBits(value) == Float.floatToIntBits(replay.sampleBlockValueUncached(density, x, y, z)), diff --git a/mods/sanctuary/src/gametest/resources/fabric.mod.json b/mods/sanctuary/src/gametest/resources/fabric.mod.json index ea1ea4b..1641e2c 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.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.Alpha28NativeStructuresGameTests", "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/expansion/ExpansionChunkGenerator.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionChunkGenerator.java index 26176fe..f504bfd 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 @@ -146,9 +146,17 @@ public class ExpansionChunkGenerator extends ChunkGenerator { } @Override public void applyBiomeDecoration(net.minecraft.world.level.WorldGenLevel level, ChunkAccess chunk, StructureManager structures) { var island = islandAt(chunk.getPos().getMiddleBlockX(), chunk.getPos().getMiddleBlockZ()); - if (!ExpansionOcean23.active(island)) { super.applyBiomeDecoration(level, chunk, structures); return; } + fr.koka.sanctuary.worldgen.IslandOres28.place(level, this, chunk, island); + if (!ExpansionOcean23.active(island)) { + super.applyBiomeDecoration(level, chunk, structures); + fr.koka.sanctuary.worldgen.CavityMonsterRoom28.decorate(level, this, chunk); + return; + } Integer previous = ExpansionOcean23.enterDecoration(ExpansionOcean23.seaLevel(island)); - try { super.applyBiomeDecoration(level, chunk, structures); } + try { + super.applyBiomeDecoration(level, chunk, structures); + fr.koka.sanctuary.worldgen.CavityMonsterRoom28.decorate(level, this, chunk); + } finally { ExpansionOcean23.restoreDecoration(previous); } } @Override public int getSpawnHeight(LevelHeightAccessor level) { return 220; } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionRelief.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionRelief.java index 3798df5..865f776 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionRelief.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionRelief.java @@ -49,7 +49,11 @@ public final class ExpansionRelief { public static float sculpt(ExpansionIsland island, Column column, double y, float source) { if (source <= 0) return source; double reference = referenceY(island, y); - double canyon = 2.1 * column.canyonWeight() * smoothstep((reference - 120.0) / 34.0); + double canyonHeight = island.generation() == 24 + ? smoothstep((reference - 126 - column.uplift() * .35) / 16) + * smoothstep((226 + column.uplift() * .35 - reference) / 18) + : smoothstep((reference - 120.0) / 34.0); + double canyon = 2.1 * column.canyonWeight() * canyonHeight; double crater = 2.1 * column.craterWeight() * smoothstep((reference - CRATER_FLOOR) / 14.0); return (float) Math.clamp(source - Math.max(canyon, crater), -1.0, 1.0); } 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 7fed2dc..cf858fd 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 @@ -169,6 +169,15 @@ public final class ExpansionStructures23 { if (centre.top < 32) return null; var sampler = random.createClimateSampler(SamplerContext.builder().enableCaches().build()); var resolver = view.getBiomeSource().createResolver(sampler); + var hutHeights = new ArrayList(); + // The biome is genuinely three dimensional: a swamp bank can be below another island floor. + for (int y = Math.min(centre.top + 1, 280); y >= 48 && hutHeights.size() < 4; y--) { + var floor = centre.column.getBlock(y - 1); + if ((!floor.isSolid() && !floor.getFluidState().is(net.minecraft.tags.FluidTags.WATER)) + || !centre.column.getBlock(y).isAir() || !centre.column.getBlock(y + 1).isAir()) continue; + if (!host.structureBiome(resolver.getNoiseBiome(x >> 2, y >> 2, z >> 2)).is(net.minecraft.world.level.biome.Biomes.SWAMP)) continue; + if (hutHeights.isEmpty() || hutHeights.getLast() - y >= 8) hutHeights.add(y); + } int undergroundY = Math.clamp(centre.top - 60, 50, 210); Holder surfaceBiome = host.structureBiome(resolver.getNoiseBiome(x >> 2, centre.top >> 2, z >> 2)); Holder undergroundBiome = host.structureBiome(resolver.getNoiseBiome(x >> 2, undergroundY >> 2, z >> 2)); @@ -182,7 +191,8 @@ public final class ExpansionStructures23 { if (holder == null) continue; Holder biome = entry.habitat() == Habitat.UNDERGROUND ? undergroundBiome : entry.habitat() == Habitat.OCEAN ? waterBiome : surfaceBiome; - if (!holder.value().biomes().contains(biome)) continue; + if (entry.family().equals("swamp_hut")) { if (hutHeights.isEmpty()) continue; } + else if (!holder.value().biomes().contains(biome)) continue; if (entry.habitat() == Habitat.OCEAN && !ExpansionOcean23.column(island, x, z).water()) continue; long salt = mix(candidate.roll() ^ entry.id().hashCode() * 0x9e3779b97f4a7c15L); double unit = ((salt >>> 11) + 1.0) * 0x1.0p-53; @@ -196,10 +206,14 @@ public final class ExpansionStructures23 { checkDeadline(deadlineNanos); Structure original = choice.holder().value(); Entry entry = choice.entry(); - int variants = entry.habitat() == Habitat.UNDERGROUND || entry.family().equals("ruined_portal") ? 3 : 1; + boolean hut = entry.family().equals("swamp_hut"); + int variants = hut ? hutHeights.size() : entry.habitat() == Habitat.UNDERGROUND || entry.family().equals("ruined_portal") ? 3 : 1; for (int vertical = 0; vertical < variants; vertical++) { checkDeadline(deadlineNanos); - int targetY = undergroundY - vertical * 20; + int targetY = hut ? hutHeights.get(vertical) : undergroundY - vertical * 20; + ExpansionChunkGenerator nativeView = hut && view instanceof NativeView local + ? local.atHeight(targetY + 1) : view; + GroundSampler nativeGround = hut ? new GroundSampler(nativeView, chunk, random, deadlineNanos, targetY + 1) : ground; Structure generated = original; if (entry.family().equals("trial_chambers")) generated = withStartHeight(original, registries, targetY); Predicate> valid = b -> original.biomes().contains(host.structureBiome(b)); @@ -207,18 +221,18 @@ public final class ExpansionStructures23 { if (original instanceof StrongholdStructure) { // Vanilla stronghold piece weights are static mutable state. synchronized (StrongholdStructure.class) { - start = generated.generate(choice.holder(), dimension, registries, view, view.getBiomeSource(), sampler, + start = generated.generate(choice.holder(), dimension, registries, nativeView, view.getBiomeSource(), sampler, random, templates, island.seed(), candidate.chunk(), 0, chunk, valid); } } else { - start = generated.generate(choice.holder(), dimension, registries, view, view.getBiomeSource(), sampler, + start = generated.generate(choice.holder(), dimension, registries, nativeView, view.getBiomeSource(), sampler, random, templates, island.seed(), candidate.chunk(), 0, chunk, valid); } if (!start.isValid()) { diagnostic(island, candidate, "native-refused", entry.id()); continue; } // Only buried portal variants need the alternative underground elevations. if (vertical > 0 && entry.family().equals("ruined_portal") && !undergroundPortal(start)) break; - start = adjustNativeStart(original, start, entry, island, targetY, ground); - String rejection = supportRejection(host, island, entry, start, ground); + start = adjustNativeStart(original, start, entry, island, targetY, nativeGround); + String rejection = supportRejection(host, island, entry, start, nativeGround); if (rejection != null) { diagnostic(island, candidate, rejection, entry.id()); continue; } ((NativeStructureMetadata23) (Object) start).sanctuary$native23(new NativeStructureMetadata23.Data(island.seed(), seaLevel)); return new Accepted(choice.holder(), start); @@ -333,17 +347,35 @@ public final class ExpansionStructures23 { || city.intersectsCityConstruction15(total.minX() - 2, total.minY() - 12, total.minZ() - 2, total.maxX() + 2, total.maxY() + 4, total.maxZ() + 2) || city.intersectsCityConstruction16(total.minX() - 2, total.minY() - 12, total.minZ() - 2, total.maxX() + 2, total.maxY() + 4, total.maxZ() + 2) || city.intersectsExperimentalStructures(total.minX() - 2, total.minY() - 12, total.minZ() - 2, total.maxX() + 2, total.maxY() + 4, total.maxZ() + 2)) return "reserved-secret"; - if (island.origin()) { + if (island.origin() && !entry.family().equals("swamp_hut")) { var binding = city.starterContext(); if (fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime.protects(binding.generator(), binding.random(), total.getCenter().atY(total.minY()), Math.max(total.getXSpan(), total.getZSpan()) / 2 + 3, total.getYSpan() + 16, 26)) return "protected-hydrology"; } } + if (entry.family().equals("swamp_hut")) { + if (host instanceof SanctuaryChunkGenerator sanctuary && island.origin()) { + var binding = sanctuary.starterContext(); + var box = new BoundingBox(total.minX(), total.minY() - SwampHutPlacement28.MAX_PIER_DROP - 2, + total.minZ(), total.maxX(), total.maxY(), total.maxZ()); + for (var region : fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime.plansIntersecting( + binding.generator(), binding.random(), total.minX() - 16, total.minZ() - 16, total.maxX() + 16, total.maxZ() + 16)) { + String refusal = SwampHutPlacement28.hydrologyRejection(region, box); + if (refusal != null) return refusal; + } + } + return SwampHutPlacement28.rejection(start, (x, z) -> ground.at(x, z).column); + } boolean underground = entry.habitat() == Habitat.UNDERGROUND || undergroundPortal(start); boolean aquatic = entry.habitat() == Habitat.OCEAN; int siteMin = Integer.MAX_VALUE, siteMax = Integer.MIN_VALUE; for (StructurePiece piece : start.getPieces()) { BoundingBox box = piece.getBoundingBox(); + if (entry.family().equals("mineshaft") + && piece instanceof net.minecraft.world.level.levelgen.structure.structures.MineshaftPieces.MineShaftCorridor) { + if (!MineshaftSupport28.anchored(piece, (x, z) -> ground.at(x, z).column)) return "mineshaft-no-native-support"; + continue; + } int low = Integer.MAX_VALUE, high = Integer.MIN_VALUE, supported = 0, sampled = 0; boolean buriedPiece = underground || !aquatic && box.maxY() < ground.at(box.getCenter().getX(), box.getCenter().getZ()).top - 6; int step = buriedPiece ? 8 : 4; @@ -474,7 +506,7 @@ public final class ExpansionStructures23 { } private static int weight(String family) { return switch (family) { case "camp" -> 6; case "ruined_portal", "mineshaft", "ocean_ruin" -> 3; - case "village", "shipwreck", "trail_ruins", "buried_treasure" -> 2; default -> 1; }; + case "village", "shipwreck", "trail_ruins", "buried_treasure", "swamp_hut" -> 2; default -> 1; }; } private static int[] samples(int low, int high, int step) { var values = new java.util.TreeSet(); values.add(low); values.add(high); values.add((low + high) / 2); @@ -493,9 +525,11 @@ public final class ExpansionStructures23 { private record Choice(Entry entry, Holder holder, double score) {} private static final class Ground { final NoiseColumn column; final int top; final int thickness; - Ground(NoiseColumn column, int min, int max) { + Ground(NoiseColumn column, int min, int max) { this(column, min, max, false); } + Ground(NoiseColumn column, int min, int max, boolean waterSurface) { this.column = column; int y = max; - while (y >= min && !column.getBlock(y).isSolid()) y--; + while (y >= min && !column.getBlock(y).isSolid() + && !(waterSurface && column.getBlock(y).getFluidState().is(net.minecraft.tags.FluidTags.WATER))) y--; top = y; while (y >= min && top - y < 24 && solid(y)) y--; thickness = top - y; @@ -505,17 +539,23 @@ public final class ExpansionStructures23 { } private static final class GroundSampler { final ExpansionChunkGenerator generator; final LevelHeightAccessor level; final RandomState random; final long deadline; + final Integer localCeiling; final Map columns = new HashMap<>(); fr.koka.sanctuary.worldgen.city.LostCityProtection16 localCity; GroundSampler(ExpansionChunkGenerator generator, LevelHeightAccessor level, RandomState random, long deadline) { + this(generator, level, random, deadline, null); + } + GroundSampler(ExpansionChunkGenerator generator, LevelHeightAccessor level, RandomState random, long deadline, Integer localCeiling) { this.generator = generator; this.level = level; this.random = random; this.deadline = deadline; + this.localCeiling = localCeiling; } Ground at(int x, int z) { long key = ((long) x << 32) ^ (z & 0xffffffffL); Ground old = columns.get(key); if (old != null) return old; checkDeadline(deadline); if (columns.size() >= MAX_COLUMN_SAMPLES) throw new SampleBudgetExceeded(); - Ground result = new Ground(generator.getBaseColumn(x, z, level, random), level.getMinY(), level.getMaxY()); + Ground result = new Ground(generator.getBaseColumn(x, z, level, random), level.getMinY(), + localCeiling == null ? level.getMaxY() : localCeiling, localCeiling != null); columns.put(key, result); return result; } } @@ -536,14 +576,23 @@ public final class ExpansionStructures23 { /** Isolated biome snapshot during preflight, per-island sea level during native generation. */ private static final class NativeView extends ExpansionChunkGenerator { private final ExpansionChunkGenerator host; private final ExpansionIsland island; + private Integer localCeiling; NativeView(ExpansionChunkGenerator host, ExpansionIsland island, ExpansionBiomeSource source, RegistryAccess registries) { super(source, host.initialDiameter(), true); this.host = host; this.island = island; this.registries = registries; } + NativeView atHeight(int ceiling) { + var result = new NativeView(host, island, source(), registries); result.localCeiling = ceiling; return result; + } @Override public int rootGeneration() { return island.generation(); } @Override public Holder structureBiome(Holder biome) { return host.structureBiome(biome); } @Override public int getSeaLevel() { return ExpansionOcean23.active(island) ? ExpansionOcean23.seaLevel(island) : 192; } @Override public RandomState randomState(ExpansionIsland candidate) { return island.origin() ? host.randomState(candidate) : super.randomState(candidate); } @Override public int getBaseHeight(int x, int z, Heightmap.Types type, LevelHeightAccessor level, RandomState random) { + if (localCeiling != null) { + var column = getBaseColumn(x, z, level, random); + for (int y = localCeiling; y >= level.getMinY(); y--) if (type.isOpaque().test(column.getBlock(y))) return y + 1; + return level.getMinY(); + } return island.origin() ? host.getBaseHeight(x, z, type, level, random) : super.getBaseHeight(x, z, type, level, random); } @Override public NoiseColumn getBaseColumn(int x, int z, LevelHeightAccessor level, RandomState random) { diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/MineshaftSupport28.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/MineshaftSupport28.java new file mode 100644 index 0000000..4bdc28e --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/MineshaftSupport28.java @@ -0,0 +1,32 @@ +package fr.koka.sanctuary.expansion; + +import java.util.function.BiFunction; +import net.minecraft.world.level.NoiseColumn; +import net.minecraft.world.level.levelgen.structure.StructurePiece; + +/** Certifies the support positions actually used by vanilla MineShaftCorridor, not a percentage of nearby rock. */ +public final class MineshaftSupport28 { + private MineshaftSupport28() {} + public static boolean anchored(StructurePiece piece, BiFunction columns) { + var box = piece.getBoundingBox(); + int length = piece.getOrientation().getAxis() == net.minecraft.core.Direction.Axis.Z ? box.getZSpan() : box.getXSpan(); + for (int z : length > 5 ? new int[]{2, length - 3} : new int[]{2}) for (int x : new int[]{0, 2}) { + var pier = SwampHutPlacement28.worldPosition(piece, x, -1, z); + var column = columns.apply(pier.getX(), pier.getZ()); boolean anchor = false; + // Native wooden support searches at most20 below its floor plank. + for (int y = pier.getY() - 1; y >= pier.getY() - 20; y--) { + var state = column.getBlock(y); + if (!state.getFluidState().isEmpty()) break; + if (state.isSolid()) { anchor = true; break; } + } + // Its alternative is a chain up to50 blocks, anchored above the excavated corridor. + if (!anchor) for (int y = box.maxY() + 1; y <= pier.getY() + 50; y++) { + var state = column.getBlock(y); + if (!state.getFluidState().isEmpty()) break; + if (state.isSolid()) { anchor = true; break; } + } + if (!anchor) return false; + } + return true; + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/NativeStructurePlacement23.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/NativeStructurePlacement23.java index 5bb88b9..8db9985 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/NativeStructurePlacement23.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/NativeStructurePlacement23.java @@ -7,10 +7,34 @@ import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentPiec /** Scoped native placement parameters; never change the sea level of another dimension or start. */ public final class NativeStructurePlacement23 { private static final ThreadLocal SEA_LEVEL = new ThreadLocal<>(); + public record HutScope(java.util.List piers, + java.util.List bodies) {} + private static final ThreadLocal HUT = new ThreadLocal<>(); private NativeStructurePlacement23() {} public static Integer seaLevel() { return SEA_LEVEL.get(); } public static Integer enter(int seaLevel) { Integer previous = SEA_LEVEL.get(); SEA_LEVEL.set(seaLevel); return previous; } public static void restore(Integer previous) { if (previous == null) SEA_LEVEL.remove(); else SEA_LEVEL.set(previous); } + public static HutScope enterHut(net.minecraft.world.level.levelgen.structure.StructureStart start) { + var previous = HUT.get(); + var pieces = start.getPieces().stream().filter(net.minecraft.world.level.levelgen.structure.structures.SwampHutPiece.class::isInstance).toList(); + HUT.set(new HutScope(pieces.stream().flatMap(piece -> SwampHutPlacement28.piers(piece).stream()).toList(), + pieces.stream().map(StructurePiece::getBoundingBox).toList())); + return previous; + } + public static void restoreHut(HutScope previous) { + if (previous == null) HUT.remove(); else HUT.set(previous); + } + public static boolean allowsHutWrite(net.minecraft.core.BlockPos pos, net.minecraft.world.level.block.state.BlockState state, + net.minecraft.world.level.block.state.BlockState replaced) { + var scope = HUT.get(); if (scope == null) return false; + // Native construction in already empty air above a certified bank does not cut its retaining bed. + if (replaced.isAir() && scope.bodies().stream().anyMatch(box -> box.isInside(pos))) return true; + if (!state.is(net.minecraft.world.level.block.Blocks.OAK_LOG) + || !(replaced.isAir() || replaced.getFluidState().isSource() + && replaced.getFluidState().is(net.minecraft.tags.FluidTags.WATER))) return false; + return scope.piers().stream().anyMatch(pier -> pier.getX() == pos.getX() && pier.getZ() == pos.getZ() + && pos.getY() <= pier.getY() && pos.getY() > pier.getY() - SwampHutPlacement28.MAX_PIER_DROP); + } public static void move(StructurePiece piece, int dy) { if (piece instanceof OceanMonumentPieces.MonumentBuilding monument) { for (StructurePiece child : ((MonumentChildren23Accessor) monument).sanctuary$children()) child.move(0, dy, 0); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/SwampHutPlacement28.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/SwampHutPlacement28.java new file mode 100644 index 0000000..21d3722 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/SwampHutPlacement28.java @@ -0,0 +1,87 @@ +package fr.koka.sanctuary.expansion; + +import fr.koka.sanctuary.worldgen.PopulationHydrology; +import fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime; +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiFunction; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.level.NoiseColumn; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.StructurePiece; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.structures.SwampHutPiece; +import net.minecraft.world.level.material.Fluids; + +/** Native hut geometry on a real shallow bank, including banks below another island floor. */ +public final class SwampHutPlacement28 { + public static final int MAX_PIER_DROP = 8; + private SwampHutPlacement28() {} + + public static String rejection(StructureStart start, BiFunction columns) { + for (var piece : start.getPieces()) { + if (!(piece instanceof SwampHutPiece)) return "hut-unexpected-piece"; + var box = piece.getBoundingBox(); + for (int x = box.minX(); x <= box.maxX(); x++) for (int z = box.minZ(); z <= box.maxZ(); z++) { + var column = columns.apply(x, z); + int waterTop = Integer.MIN_VALUE, waterBottom = Integer.MAX_VALUE; + for (int y = box.minY() - MAX_PIER_DROP; y <= box.maxY(); y++) { + var state = column.getBlock(y); var fluid = state.getFluidState(); + if (y >= box.minY() + 2 && !state.isAir()) return "hut-natural-headroom"; + if (fluid.isEmpty()) continue; + if (fluid.getType() != Fluids.WATER || !fluid.isSource()) return "hut-flowing-or-nonwater"; + waterTop = Math.max(waterTop, y); waterBottom = Math.min(waterBottom, y); + } + if (waterTop != Integer.MIN_VALUE) { + if (waterTop > box.minY() || waterTop - waterBottom + 1 > 4) return "hut-deep-water"; + if (!solid(column.getBlock(waterBottom - 1)) || !solid(column.getBlock(waterBottom - 2))) return "hut-unsealed-bed"; + } + } + for (var pier : piers(piece)) { + var column = columns.apply(pier.getX(), pier.getZ()); int y = pier.getY(), depth = 0; + while (replaceable(column.getBlock(y)) && depth <= MAX_PIER_DROP) { y--; depth++; } + if (depth > MAX_PIER_DROP || !solid(column.getBlock(y)) || !solid(column.getBlock(y - 1))) return "hut-unsupported-pier"; + } + } + return null; + } + + /** Still water is compatible; springs, spills, channels and lava keep their protection. */ + public static String hydrologyRejection(PopulationHydrologyRuntime.RegionPlan region, BoundingBox box) { + for (var spring : region.water().springs()) if (intersects(box, spring.flowBounds(), 3)) return "hut-protected-spring"; + for (var spill : region.water().spills()) if (intersects(box, spill.flowBounds(), 3)) return "hut-protected-spill"; + for (var flow : region.lava().flowBounds()) if (intersects(box, flow, 4)) return "hut-protected-lava"; + var rivers = region.water().features().stream().filter(f -> f.kind() == PopulationHydrology.Kind.RIVER) + .map(PopulationHydrology.Feature::id).collect(java.util.stream.Collectors.toSet()); + for (int x = box.minX(); x <= box.maxX(); x++) for (int z = box.minZ(); z <= box.maxZ(); z++) + for (var cell : region.water().cellsAt(x, z)) if (rivers.contains(cell.featureId()) + && box.minY() <= cell.carveTop() && box.maxY() >= cell.bedY() - cell.sedimentDepth() - 1) return "hut-protected-river"; + return null; + } + + /** Exact four support origins used by SwampHutPiece.postProcess. */ + public static List piers(StructurePiece piece) { + var result = new ArrayList(4); + for (int z : new int[]{2, 7}) for (int x : new int[]{1, 5}) result.add(worldPosition(piece, x, -1, z)); + return List.copyOf(result); + } + public static BlockPos worldPosition(StructurePiece piece, int x, int y, int z) { + var b = piece.getBoundingBox(); Direction facing = piece.getOrientation(); + if (facing == null || facing.getAxis().isVertical()) throw new IllegalArgumentException("A hut requires horizontal orientation"); + int wx = switch (facing) { case WEST -> b.maxX() - z; case EAST -> b.minX() + z; default -> b.minX() + x; }; + int wz = switch (facing) { case NORTH -> b.maxZ() - z; case SOUTH -> b.minZ() + z; default -> b.minZ() + x; }; + return new BlockPos(wx, b.minY() + y, wz); + } + private static boolean solid(BlockState state) { return state.isSolid() && state.getFluidState().isEmpty(); } + private static boolean replaceable(BlockState state) { + return state.isAir() || state.liquid() || state.is(Blocks.GLOW_LICHEN) || state.is(Blocks.SEAGRASS) || state.is(Blocks.TALL_SEAGRASS); + } + private static boolean intersects(BoundingBox b, PopulationHydrology.FlowBounds flow, int margin) { + return b.minX() <= flow.maxX() + margin && b.maxX() >= flow.minX() - margin + && b.minY() <= flow.maxY() + margin && b.maxY() >= flow.minY() - margin + && b.minZ() <= flow.maxZ() + margin && b.maxZ() >= flow.minZ() - margin; + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/NativeStructureStart23Mixin.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/NativeStructureStart23Mixin.java index 514c702..19ef288 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/NativeStructureStart23Mixin.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/NativeStructureStart23Mixin.java @@ -67,8 +67,9 @@ public abstract class NativeStructureStart23Mixin implements NativeStructureMeta RandomSource random, BoundingBox bounds, ChunkPos pos, Operation original) { if (sanctuary$native23 == null) { original.call(level, structures, generator, random, bounds, pos); return; } Integer previous = NativeStructurePlacement23.enter(sanctuary$native23.seaLevel()); + var previousHut = NativeStructurePlacement23.enterHut((StructureStart) (Object) this); try { original.call(level, structures, generator, random, bounds, pos); } - finally { NativeStructurePlacement23.restore(previous); } + finally { NativeStructurePlacement23.restoreHut(previousHut); NativeStructurePlacement23.restore(previous); } } @Unique private static Data sanctuary$read(CompoundTag tag) { 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 76fb686..c3cde04 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 @@ -62,6 +62,10 @@ public abstract class UnifiedDecorationProtectionMixin { } // The dedicated shoreline feature still grows cane on its naturally valid dry banks. if (state.is(Blocks.SUGAR_CANE)) return; + // Admitted native huts use existing air and four bounded wooden piers in still water. + // The intact basin bed, all other water cells, island ownership and secrets remain protected. + if (!generator.protectsCityDecoration(pos) + && fr.koka.sanctuary.expansion.NativeStructurePlacement23.allowsHutWrite(pos, state, region.getBlockState(pos))) return; // Generation16 reserves its architectural volume before decoration. Only its own // native pieces may replace water there; published generations retain the old rule. if ((generator.rootGeneration() == 16 || generator.rootGeneration() == 17 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22 || (generator.rootGeneration() == 23 || generator.rootGeneration() == 24)) diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavesRelief28.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavesRelief28.java new file mode 100644 index 0000000..81505e1 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavesRelief28.java @@ -0,0 +1,37 @@ +package fr.koka.sanctuary.worldgen; + +/** Bounded domain deformation around native rock noise, followed by native cave cuts. */ +public final class CavesRelief28 { + public static final int CAVE_OFFSET = 96; + public static final int DEFORMATION_START = 180; + public static final double MAX_HORIZONTAL_SHIFT = 16; + public static final double MAX_VERTICAL_SHIFT = 40; + private CavesRelief28() {} + + public record Warp(double x, double y, double z) {} + + public static double deformationWeight(int x, int y, int z, double radius) { + double distance = Math.hypot((double) x, z); + double arrival = smooth((distance - 48) / 48); + double coast = 1 - smooth((distance / radius - .66) / .26); + return smooth((y - DEFORMATION_START) / 48.0) * arrival * coast; + } + + /** Signed smooth displacements: no folded ridge, plateau saturation or height-map uplift. */ + public static Warp warp(double weight, float across, float vertical, float along) { + return new Warp(MAX_HORIZONTAL_SHIFT * Math.tanh(across * 1.6) * weight, + MAX_VERTICAL_SHIFT * Math.tanh(vertical * 1.6) * weight, + MAX_HORIZONTAL_SHIFT * Math.tanh(along * 1.6) * weight); + } + + public static float carve(float rock, float nativeCaves, int y) { + if (rock <= 0) return rock; + double weight = smooth((y - 40) / 28.0) * (1 - smooth((y - 202) / 30.0)); + if (weight <= 0) return rock; + return Math.min(rock, (float) Math.clamp(nativeCaves + (1 - weight) * 2, -1, 1)); + } + private static double smooth(double value) { + double t = Math.clamp(value, 0, 1); + return t * t * (3 - 2 * t); + } +} 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 new file mode 100644 index 0000000..952839c --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavityMonsterRoom28.java @@ -0,0 +1,65 @@ +package fr.koka.sanctuary.worldgen; + +import fr.koka.sanctuary.expansion.ExpansionChunkGenerator; +import java.util.ArrayList; +import java.util.Comparator; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.ChunkGenerator; +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 CavityMonsterRoom28() {} + + public static void decorate(WorldGenLevel level, ChunkGenerator generator, ChunkAccess chunk) { + if (!(generator instanceof ExpansionChunkGenerator host) || host.rootGeneration() != 24 + || !level.getLevel().structureManager().shouldGenerateStructures()) return; + 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}) { + int x = pos.getMinBlockX() + dx, z = pos.getMinBlockZ() + dz; + for (int y = bottom; y <= top; y++) { + BlockPos at = new BlockPos(x, y, z); + if (!level.getBlockState(at.below()).isSolid() || !level.getBlockState(at.above(4)).isSolid()) continue; + boolean caveEdge = false; + for (Direction side : Direction.Plane.HORIZONTAL) { + BlockPos opening = at.relative(side, 4); + if (level.getBlockState(opening).isAir() && level.getBlockState(opening.above()).isAir()) { caveEdge = true; break; } + } + if (caveEdge) candidates.add(at); + } + } + candidates.sort(Comparator.comparingLong(at -> WoodlandGroveGeometry.seed(seed, at.getX(), at.getY(), at.getZ(), 28))); + int attempts = 0; + for (BlockPos at : candidates) { + 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; + } + } + + private static boolean safe(WorldGenLevel level, ExpansionChunkGenerator host, BlockPos at, String owner) { + for (int dx = -4; dx <= 4; dx++) for (int dz = -4; dz <= 4; dz++) for (int dy = -1; dy <= 4; dy++) { + BlockPos block = at.offset(dx, dy, dz); + var destination = host.islandForChunk(block.getX() >> 4, block.getZ() >> 4); + if (destination == null || !destination.id().equals(owner)) return false; + var state = level.getBlockState(block); + if (!state.getFluidState().isEmpty() || state.hasBlockEntity() || state.is(Blocks.BEDROCK)) return false; + if (host instanceof SanctuaryChunkGenerator sanctuary + && (sanctuary.protectsDecoration(block) || sanctuary.protectsCityDecoration(block))) return false; + } + return true; + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/IslandOres28.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/IslandOres28.java new file mode 100644 index 0000000..28c5622 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/IslandOres28.java @@ -0,0 +1,53 @@ +package fr.koka.sanctuary.worldgen; + +import fr.koka.sanctuary.expansion.ExpansionChunkGenerator; +import fr.koka.sanctuary.expansion.ExpansionIsland; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.tags.BlockTags; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.chunk.ChunkAccess; + +/** Native finite ore veins, positioned within the whole floating rock column rather than vanilla sea-level bands. */ +public final class IslandOres28 { + private IslandOres28() {} + private record Deposit(String feature, int attempts, double minDepth, double maxDepth) {} + // Small supplementary populations shared by all climates. Existing biome ores remain in place. + private static final Deposit[] DEPOSITS = { + new Deposit("ore_coal", 8, .05, .65), new Deposit("ore_iron", 7, .12, .88), + new Deposit("ore_copper_small", 6, .10, .66), new Deposit("ore_gold", 2, .52, .90), + new Deposit("ore_redstone", 3, .56, .92), new Deposit("ore_lapis", 2, .30, .82), + new Deposit("ore_diamond_small", 2, .70, .94), new Deposit("ore_emerald", 1, .25, .85) + }; + + public static void place(WorldGenLevel level, ExpansionChunkGenerator generator, ChunkAccess chunk, ExpansionIsland island) { + if (island == null || generator.rootGeneration() != 24) return; + int minX = chunk.getPos().getMinBlockX(), minZ = chunk.getPos().getMinBlockZ(); + // Density columns precede structures and vegetation. Cavities do not reset mineral depth: + // retain the full top-to-bottom envelope rather than using the nearest cave ceiling. + var column = generator.getBaseColumn(minX + 8, minZ + 8, level, generator.randomState(island)); + int top = -1, bottom = -1; + for (int y = 1; y < 383; y++) if (column.getBlock(y).is(BlockTags.BASE_STONE_OVERWORLD)) { + if (bottom < 0) bottom = y; + top = y; + } + if (top - bottom < 24) return; + var registry = level.registryAccess().lookupOrThrow(Registries.FEATURE); + for (int index = 0; index < DEPOSITS.length; index++) { + Deposit deposit = DEPOSITS[index]; + var feature = registry.getValue(Identifier.withDefaultNamespace(deposit.feature())); + if (feature == null) throw new IllegalStateException("Missing native ore " + deposit.feature()); + var random = RandomSource.create(WoodlandGroveGeometry.seed(island.seed(), minX, index, minZ, 0x0280AE5L)); + for (int attempt = 0; attempt < deposit.attempts(); attempt++) { + double depth = deposit.minDepth() + random.nextDouble() * (deposit.maxDepth() - deposit.minDepth()); + int y = (int) Math.round(top - depth * (top - bottom)); + // Largest selected native vein is 17 blocks (radius bound five). Its writes stay + // in the decorating chunk, including where neighbours are generated in another order. + var at = new BlockPos(minX + 5 + random.nextInt(6), y, minZ + 5 + random.nextInt(6)); + if (level.getBlockState(at).is(BlockTags.BASE_STONE_OVERWORLD)) feature.place(level, generator, random, at); + } + } + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationIslandDensity.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationIslandDensity.java index ad9d343..4e83960 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationIslandDensity.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationIslandDensity.java @@ -1,6 +1,7 @@ package fr.koka.sanctuary.worldgen; import com.mojang.serialization.MapCodec; +import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; import fr.koka.sanctuary.SanctuaryMod; import java.util.concurrent.ConcurrentHashMap; @@ -10,7 +11,8 @@ import net.minecraft.world.level.levelgen.densityfunction.*; /** Capacity changes the envelope; all natural noise is still sampled at world coordinates. */ public record PopulationIslandDensity(DensityFunction terrain, DensityFunction distortion, DensityFunction sculpt, DensityFunction detail, DensityFunction underside, - DensityFunction riftWarp, DensityFunction riftDetail, IslandCapacity capacity) implements DensityFunction { + DensityFunction riftWarp, DensityFunction riftDetail, IslandCapacity capacity, + DensityFunction caves28, DensityFunction ridges28, DensityFunction erosion28, boolean geology28) implements DensityFunction { public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group( DensityFunction.CODEC.fieldOf("terrain").forGetter(PopulationIslandDensity::terrain), DensityFunction.CODEC.fieldOf("distortion").forGetter(PopulationIslandDensity::distortion), @@ -19,36 +21,85 @@ public record PopulationIslandDensity(DensityFunction terrain, DensityFunction d DensityFunction.CODEC.fieldOf("underside").forGetter(PopulationIslandDensity::underside), DensityFunction.CODEC.fieldOf("rift_warp").forGetter(PopulationIslandDensity::riftWarp), DensityFunction.CODEC.fieldOf("rift_detail").forGetter(PopulationIslandDensity::riftDetail), - IslandCapacity.CODEC.optionalFieldOf("players", IslandCapacity.TWENTY).forGetter(PopulationIslandDensity::capacity) + IslandCapacity.CODEC.optionalFieldOf("players", IslandCapacity.TWENTY).forGetter(PopulationIslandDensity::capacity), + DensityFunction.CODEC.optionalFieldOf("caves_28", DensityFunctions.zero()).forGetter(PopulationIslandDensity::caves28), + DensityFunction.CODEC.optionalFieldOf("ridges_28", DensityFunctions.zero()).forGetter(PopulationIslandDensity::ridges28), + DensityFunction.CODEC.optionalFieldOf("erosion_28", DensityFunctions.zero()).forGetter(PopulationIslandDensity::erosion28), + Codec.BOOL.optionalFieldOf("geology_28", false).forGetter(PopulationIslandDensity::geology28) ).apply(instance, PopulationIslandDensity::new)); + public PopulationIslandDensity(DensityFunction terrain, DensityFunction distortion, + DensityFunction sculpt, DensityFunction detail, DensityFunction underside, + DensityFunction riftWarp, DensityFunction riftDetail, IslandCapacity capacity) { + this(terrain, distortion, sculpt, detail, underside, riftWarp, riftDetail, capacity, + DensityFunctions.zero(), DensityFunctions.zero(), DensityFunctions.zero(), false); + } + @Override public DensitySampler compileSampler(CompileContext context) { var raw = ExactInterpolationSampler.compileTerrain(terrain, context); var edge = distortion.compileSampler(context); var coarse = sculpt.compileSampler(context); var fine = detail.compileSampler(context); var bottom = underside.compileSampler(context); var warp = riftWarp.compileSampler(context); var fractureDetail = riftDetail.compileSampler(context); + var caves = geology28 ? caves28.compileSampler(context) : null; + var ridges = geology28 ? ridges28.compileSampler(context) : null; + var erosion = geology28 ? erosion28.compileSampler(context) : null; long seed = context.createRandom(SanctuaryMod.id("population_rifts_alpha10")).nextLong(); var layouts = new ConcurrentHashMap(); return new DensitySampler() { + private float terrainAt(SamplerContext sample, double x, double y, double z) { + int ix = (int) Math.floor(x), iy = (int) Math.floor(y), iz = (int) Math.floor(z); + double fx = x - ix, fy = y - iy, fz = z - iz; + double sum = 0; + // Trilinear sampling avoids integer snapping of the displaced native field. + for (int dz = 0; dz <= (fz == 0 ? 0 : 1); dz++) + for (int dy = 0; dy <= (fy == 0 ? 0 : 1); dy++) + for (int dx = 0; dx <= (fx == 0 ? 0 : 1); dx++) { + double w = (dx == 0 ? 1 - fx : fx) * (dy == 0 ? 1 - fy : fy) + * (dz == 0 ? 1 - fz : fz); + sum += w * raw.sampleValue(sample, ix + dx, iy + dy, iz + dz); + } + return (float) sum; + } @Override public float sampleValue(SamplerContext sample, int x, int y, int z) { if (Math.hypot((double) x, z) >= capacity.terrainLimit() || y <= 0 || y >= 384) return -1; + float terrainValue; + double weight = geology28 ? CavesRelief28.deformationWeight(x, y, z, capacity.radius()) : 0; + if (weight > 0) { + var shift = CavesRelief28.warp(weight, + erosion.sampleValue(sample, x + 173, y - 59, z - 89), + ridges.sampleValue(sample, x, y, z), + erosion.sampleValue(sample, x - 211, y + 137, z + 307)); + terrainValue = terrainAt(sample, x - shift.x(), y - 64 - shift.y(), z - shift.z()); + } else terrainValue = raw.sampleValue(sample, x, y - 64, z); + // Keep the island envelope at actual coordinates, independent from displaced rock. int localY = y - 64; - float natural = PopulationIslandShape.raisedDensity(x, y, z, raw.sampleValue(sample, x, localY, z), + float natural = PopulationIslandShape.raisedDensity(x, y, z, terrainValue, edge.sampleValue(sample, x, 0, z), coarse.sampleValue(sample, x, localY, z), fine.sampleValue(sample, x, localY, z), bottom.sampleValue(sample, x, localY, z), 64, 384, capacity); if (natural <= 0) return natural; var region = capacity.regionAt(x, z); var layout = layouts.computeIfAbsent(region, r -> RiftShape.layout(regionSeed(seed, r))); int rx = x - region.originX(), rz = z - region.originZ(); - if (!RiftShape.mayAffect(layout, rx, rz)) return natural; - return RiftShape.density(layout, rx, y, rz, natural, - warp.sampleValue(sample, x, y, z), fractureDetail.sampleValue(sample, x, y, z)); + boolean affects = geology28 ? RiftShape.mayAffect28(layout, rx, rz) : RiftShape.mayAffect(layout, rx, rz); + if (affects) natural = geology28 + ? RiftShape.density28(layout, rx, y, rz, natural, + warp.sampleValue(sample, x, y, z), fractureDetail.sampleValue(sample, x, y, z)) + : RiftShape.density(layout, rx, y, rz, natural, + warp.sampleValue(sample, x, y, z), fractureDetail.sampleValue(sample, x, y, z)); + if (geology28 && natural > 0 && y > 40 && y < 232) + natural = CavesRelief28.carve(natural, caves.sampleValue(sample, x, y - CavesRelief28.CAVE_OFFSET, z), y); + return natural; } @Override public void sampleVolume(SamplerContext sample, DensityBuffer buffer, DensityVolume volume) { DensitySampler.sampleVolumeNaive(sample, buffer, volume, this); } }; } + /** Native diagnostics can compile the exact accepted27 field without new adaptations. */ + public PopulationIslandDensity legacy27() { + return new PopulationIslandDensity(terrain, distortion, sculpt, detail, underside, riftWarp, riftDetail, + capacity, DensityFunctions.zero(), DensityFunctions.zero(), DensityFunctions.zero(), false); + } public static long regionSeed(long seed, IslandCapacity.Region region) { long value = seed ^ ((long) region.x() << 32) ^ (region.z() & 0xffffffffL) ^ 0xA10CA7E1L; value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L; @@ -57,7 +108,8 @@ public record PopulationIslandDensity(DensityFunction terrain, DensityFunction d } @Override public DensityFunction rewriteChildren(DfRewriteRule rule) { return new PopulationIslandDensity(rule.rewrite(terrain), rule.rewrite(distortion), rule.rewrite(sculpt), - rule.rewrite(detail), rule.rewrite(underside), rule.rewrite(riftWarp), rule.rewrite(riftDetail), capacity); + rule.rewrite(detail), rule.rewrite(underside), rule.rewrite(riftWarp), rule.rewrite(riftDetail), capacity, + rule.rewrite(caves28), rule.rewrite(ridges28), rule.rewrite(erosion28), geology28); } @Override public Interval range() { return Interval.of(-1, 1); } @Override public int domainAxes() { return ALL_AXES; } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/RiftShape.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/RiftShape.java index 3c553a1..5ec35f4 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/RiftShape.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/RiftShape.java @@ -91,6 +91,55 @@ public final class RiftShape { return (float) Math.max(-1.0, terrain - erosion); } + /** Alpha28 fractures lean sideways, end within the mass and retain interrupted rock spans. */ + public static boolean mayAffect28(Layout layout, int x, int z) { + double radius = Math.hypot((double) x, z); + if (radius <= INNER_UNTOUCHED_RADIUS || radius >= OUTER_UNTOUCHED_RADIUS) return false; + for (Rift rift : layout.rifts()) { + if (near28(rift.main(), x, z)) return true; + for (Segment branch : rift.branches()) if (near28(branch, x, z)) return true; + } + return false; + } + + public static float density28(Layout layout, int x, int y, int z, float terrain, float warped, float detail) { + if (terrain <= 0 || y < 88 || y > 244 || !mayAffect28(layout, x, z)) return terrain; + double coarse = Math.clamp(warped, -1, 1), fine = Math.clamp(detail, -1, 1), cut = 0; + for (Rift rift : layout.rifts()) { + cut = Math.max(cut, erosion28(rift.main(), rift.id(), x, y, z, coarse, fine)); + for (Segment branch : rift.branches()) + cut = Math.max(cut, erosion28(branch, rift.id(), x, y, z, coarse, fine)); + } + double radius = Math.hypot((double) x, z); + cut *= smoothstep((radius - INNER_UNTOUCHED_RADIUS) / 16) + * smoothstep((OUTER_UNTOUCHED_RADIUS - radius) / 20); + return (float) Math.max(-1, terrain - cut); + } + + private static boolean near28(Segment segment, int x, int z) { + double rx = (double) x - segment.x(), rz = (double) z - segment.z(); + return Math.abs(rx * segment.dx() + rz * segment.dz()) <= segment.halfLength() + 16 + && Math.abs(-rx * segment.dz() + rz * segment.dx()) <= segment.bend() * 1.3 + 20; + } + + private static double erosion28(Segment segment, long id, int x, int y, int z, double coarse, double fine) { + if (!near28(segment, x, z)) return 0; + double rx = x - segment.x(), rz = z - segment.z(); + double along = rx * segment.dx() + rz * segment.dz(); + double end = Math.clamp(along, -segment.halfLength(), segment.halfLength()); + double centerY = 150 + unit(id + 41) * 32 + end * .12 + Math.sin(end / 29 + segment.phase()) * 7; + double halfHeight = 22 + unit(id + 42) * 14; + double vertical = smoothstep((halfHeight - Math.abs(y - centerY)) / 10); + if (vertical <= 0) return 0; + double lean = (y - centerY) * (.13 + unit(id + 43) * .07); + double sideways = -rx * segment.dz() + rz * segment.dx() - curve(segment, end) - lean - coarse * 1.05; + double width = Math.clamp(segment.halfWidth() + fine * .5 + coarse * .45, 2.2, 5.7); + double distance = Math.hypot(sideways, along - end); + double bridgeAt = (unit(id + 44) - .5) * segment.halfLength(); + double bridge = smoothstep((Math.abs(end - bridgeAt) - 3) / 5); + return 2.1 * smoothstep((width + 1.6 - distance) / 2.6) * vertical * bridge; + } + private static boolean near(Segment segment, int x, int z) { double rx = (double) x - segment.x(), rz = (double) z - segment.z(); return Math.abs(rx * segment.dx() + rz * segment.dz()) <= segment.halfLength() + 10 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 a1b95bd..427396c 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 @@ -183,7 +183,12 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { public boolean intersectsExperimentalStructures(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { 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.waterfall() ? 0 : site.minY() - 1)) return true; + && minY <= site.maxY() + 1 && maxY >= site.minY() - 1) return true; + if (site.waterfall()) { + var flow = site.waterfallClaimBounds(); + if (minX <= flow.maxX() && maxX >= flow.minX() && minZ <= flow.maxZ() && maxZ >= flow.minZ() + && minY <= flow.maxY() && maxY >= flow.minY()) return true; + } } return (generation == 23 || generation == 24) && mineralSanctuary23.intersectsProtection(minX, minY, minZ, maxX, maxY, maxZ) || (generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22 || (generation == 23 || generation == 24)) && ruinsPlan17.intersectsProtection(minX, minY, minZ, maxX, maxY, maxZ) @@ -210,6 +215,10 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { case "sanctuary:population_rocky_heath" -> "meadow"; case "sanctuary:population_dark_grove" -> "dark_forest"; case "sanctuary:population_bamboo_grove" -> "bamboo_jungle"; + case "sanctuary:swamp27_caves" -> "swamp"; + case "sanctuary:population_lush_caves" -> "lush_caves"; + case "sanctuary:population_dripstone_caves" -> "dripstone_caves"; + case "sanctuary:population_sulfur_depths" -> "sulfur_caves"; default -> null; }; return equivalent == null ? biome : registries.lookupOrThrow(Registries.BIOME).getOrThrow( diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/Swamp27Volumes.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/Swamp27Volumes.java index 54c7b3f..b940632 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/Swamp27Volumes.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/Swamp27Volumes.java @@ -14,7 +14,7 @@ public final class Swamp27Volumes { // Broad, connected sectors change with height; finer noise softens their borders. double broad = noise(seed, x / 94.0, y / 48.0, z / 94.0); double detail = noise(seed ^ 0x7757A27L, x / 37.0, y / 31.0, z / 37.0); - return broad + detail * .22 > .04 + (1 - verticalEdge) * .7; + return broad + detail * .22 > .25 + (1 - verticalEdge) * .7; } private static double noise(long seed, double x, double y, double z) { 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 2f5f841..8749e5d 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 @@ -15,6 +15,8 @@ import fr.koka.sanctuary.worldgen.aerial.AerialPlan24.Site; public final class AerialPlanner24 { public static final int MAX_ATTEMPTS_PER_SITE = 64; public static final int MAX_COLUMNS = 8192; + public static final int ARRIVAL_RADIUS = 96; + public static final int MIN_ISLET_GAP = 16; private AerialPlanner24() {} public interface Terrain { NoiseColumn column(int x, int z); @@ -56,26 +58,67 @@ public final class AerialPlanner24 { // Keep the four remaining bearings and seeded shapes; the former first sector is simply free. double angle = initialAngle + (ordinal + 1) * (Math.PI * 2 / 5) + (unit(draw) - .5) * .9 + (attempt / 16) * .41; - Shore shore = shore(probe, angle, radius); - if (shore == null) continue; int facing = (int) Math.floorMod(mix(draw), 4); int worldWidth = facing % 2 == 0 ? width : depth, worldDepth = facing % 2 == 0 ? depth : width; - // The full claim remains outside initial terrain ownership, including corner chunks. - double distance = radius + 32 + Math.hypot(worldWidth, worldDepth) / 2 + 64 + (attempt % 4) * 20; - int cx = (int) Math.round(Math.cos(angle) * distance), cz = (int) Math.round(Math.sin(angle) * distance); - if (Math.hypot(cx - shore.x, cz - shore.z) > 500) continue; - int bottom = merchant ? Math.clamp(shore.y - 4 + (int) Math.floorMod(draw, 13), 180, 300) - : Math.clamp(shore.y + 20 - bodyHeight / 2, 210, 320 - bodyHeight); - bottom = Math.min(bottom, 350 - height); + Shore shore; + int cx, cz, bottom; + if (merchant) { + shore = shore(probe, angle, radius); + if (shore == null) continue; + // Start from the real edge, not the nominal island radius. The projected half-box + // puts the complete hull 16–32 blocks out, even when the ship is rotated. + double dx = Math.cos(angle), dz = Math.sin(angle); + double offset = Math.abs(dx) * worldWidth / 2.0 + Math.abs(dz) * worldDepth / 2.0 + + 16 + (attempt % 3) * 8; + cx = shore.x + (int) Math.round(dx * offset); + cz = shore.z + (int) Math.round(dz * offset); + // Native boarding deck is five blocks above the envelope's bottom. + bottom = shore.y - 5 + (int) Math.floorMod(draw, 5); + } else { + // Both mineral islands hover over Sanctuary, away from its natural arrival. + double distance = radius * (.48 + .15 * unit(mix(draw ^ 0x28A1L))); + cx = (int) Math.round(Math.cos(angle) * distance); + cz = (int) Math.round(Math.sin(angle) * distance); + int top = probe.surface(cx, cz); + if (top < 120 || probe.terrain.reserved(cx, cz, top, top + 3)) continue; + shore = new Shore(cx, top + 1, cz); + int highest = top; + for (int ox = -worldWidth / 2; ox <= worldWidth / 2; ox += 4) + for (int oz = -worldDepth / 2; oz <= worldDepth / 2; oz += 4) + highest = Math.max(highest, probe.highest(cx + ox, cz + oz)); + bottom = highest + MIN_ISLET_GAP + 1; + } + if (bottom < 32 || bottom + height > 350) continue; var site = new Site(ids[ordinal], kinds[ordinal], cx - worldWidth / 2, bottom, cz - worldDepth / 2, width, height, depth, facing, siteSeed, shore.x, shore.y, shore.z, ordinal == 2); - if (site.claimedChunks().stream().anyMatch(c -> claimed.contains(c.pack()))) continue; - boolean rootOverlap = site.claimedChunks().stream().anyMatch(c -> { - int nearestX = Math.clamp(0, c.getMinBlockX(), c.getMaxBlockX()); - int nearestZ = Math.clamp(0, c.getMinBlockZ(), c.getMaxBlockZ()); - return Math.hypot(nearestX, nearestZ) <= radius + 32; - }); - if (rootOverlap || !visible(probe, shore, cx, bottom + bodyHeight - 4, cz)) continue; + if (!outsideArrival(site) || site.claimedChunks().stream().anyMatch(c -> claimed.contains(c.pack()))) continue; + if (merchant) { + double gap = shoreDistance(site); + if (gap < 16 || gap > 48 || !clearShip(probe, site)) continue; + } else { + // Certify every physical rock column, not only the coarse samples used to + // choose a height. This may raise the whole unchanged island, never the ground. + int highest = 0, supported = 0, count = 0; + for (int x = 0; x < width; x++) for (int z = 0; z < depth; z++) { + if (!isletColumn(site, x, z)) continue; + int wx = site.worldX(x, z), wz = site.worldZ(x, z); + highest = Math.max(highest, probe.highest(wx, wz)); + if (probe.surface(wx, wz) >= 120) supported++; + count++; + } + if (supported * 4 < count * 3) continue; + if (site.waterfall()) { + var outlet = site.waterfallClaimBounds(); + for (int x = outlet.minX(); x <= outlet.maxX(); x++) for (int z = outlet.minZ(); z <= outlet.maxZ(); z++) + highest = Math.max(highest, probe.highest(x, z)); + } + bottom = Math.max(bottom, highest + MIN_ISLET_GAP + 1); + if (bottom + height > 350) continue; + site = new Site(site.id(), site.kind(), site.minX(), bottom, site.minZ(), width, height, depth, + facing, siteSeed, shore.x, shore.y, shore.z, site.waterfall()); + if (!clearReservations(probe, site)) continue; + } + if (!visible(probe, shore, cx, bottom + bodyHeight - 4, cz)) continue; accepted = site; break; } if (accepted == null) throw new IllegalStateException("aerial_sites_unavailable: could not place " + ids[ordinal] + " within the bounded search"); @@ -89,6 +132,39 @@ public final class AerialPlanner24 { return result; } + /** Horizontal distance from the saved land witness to the nearest point of the hull envelope. */ + public static double shoreDistance(Site site) { + return Math.hypot(site.shoreX() - Math.clamp(site.shoreX(), site.minX(), site.maxX()), + site.shoreZ() - Math.clamp(site.shoreZ(), site.minZ(), site.maxZ())); + } + private static boolean outsideArrival(Site site) { + if (Math.hypot(Math.clamp(0, site.minX(), site.maxX()), Math.clamp(0, site.minZ(), site.maxZ())) < ARRIVAL_RADIUS) return false; + if (site.waterfall()) { + var box = site.waterfallClaimBounds(); + if (Math.hypot(Math.clamp(0, box.minX(), box.maxX()), Math.clamp(0, box.minZ(), box.maxZ())) < ARRIVAL_RADIUS) return false; + } + return true; + } + private static boolean isletColumn(Site site, int x, int z) { + return AerialGeometry24.radial(site, x, z) <= 1 + || site.waterfall() && Math.abs(x - site.width() / 2) <= 2 && z <= 6; + } + private static boolean clearShip(Probe probe, Site site) { + for (int x = site.minX(); x <= site.maxX(); x++) for (int z = site.minZ(); z <= site.maxZ(); z++) + if (probe.highest(x, z) >= site.minY() - 3 || probe.terrain.reserved(x, z, site.minY() - 1, site.maxY() + 1)) return false; + return true; + } + private static boolean clearReservations(Probe probe, Site site) { + for (int x = site.minX(); x <= site.maxX(); x++) for (int z = site.minZ(); z <= site.maxZ(); z++) + if (probe.terrain.reserved(x, z, site.minY() - 1, site.maxY() + 1)) return false; + if (site.waterfall()) { + var box = site.waterfallClaimBounds(); + for (int x = box.minX(); x <= box.maxX(); x++) for (int z = box.minZ(); z <= box.maxZ(); z++) + if (probe.terrain.reserved(x, z, box.minY(), box.maxY())) return false; + } + return true; + } + private static Shore shore(Probe probe, double angle, int radius) { double dx = Math.cos(angle), dz = Math.sin(angle); // An outside-in scan finds the outer shore, never an internal pond. Refine its edge to a block. @@ -138,6 +214,11 @@ public final class AerialPlanner24 { for (int y = 382; y > 0; y--) if (column.getBlock(y).isSolid() && column.getBlock(y).getFluidState().isEmpty()) return y; return 0; } + int highest(int x, int z) { + var column = column(x, z); if (column == null) return 0; + for (int y = 382; y > 0; y--) if (!column.getBlock(y).isAir()) return y; + return 0; + } boolean solid(int x, int y, int z) { var column = column(x, z); return column != null && !column.getBlock(y).isAir(); } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialStore24.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialStore24.java index 4ddc9af..e502745 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialStore24.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialStore24.java @@ -95,13 +95,9 @@ public final class AerialStore24 { private static void validateExtent(AerialPlan24 plan, int diameter) throws IOException { if (diameter != 512 && diameter != 724 && diameter != 1024) throw new IOException("Unsupported aerial root size"); - int radius = (diameter == 724 ? 395 : diameter / 2 + 32) + 32; - for (long packed : plan.claimsChunks()) { - var c = net.minecraft.world.level.ChunkPos.unpack(packed); - int x = Math.clamp(0, c.getMinBlockX(), c.getMaxBlockX()); - int z = Math.clamp(0, c.getMinBlockZ(), c.getMaxBlockZ()); - if (Math.hypot(x, z) <= radius) throw new IOException("Aerial claim overlaps initial terrain ownership"); - } + // Aerial bodies may share X/Z with the initial island. Their vertical clearance is + // certified at creation; the immutable manifest preserves it on subsequent loads. + // Site/Plan constructors still validate finite height, dimensions, count and disjoint claims. } private static JsonObject encode(AerialPlan24 plan, long seed, int diameter) { var root = new JsonObject(); 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 53a074d..89d4162 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 @@ -22,7 +22,7 @@ import net.minecraft.world.level.levelgen.structure.StructureType; import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceType; import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; -/** Native starts for four immutable initial sites. Their void chunks need no terrain island owner. */ +/** Native starts for four immutable initial sites, in the void or above initial terrain. */ public final class AerialStructures24 { public static final ResourceKey MERCHANT_SHIP = key("aerial_merchant_ship"); public static final ResourceKey MINERAL_ISLET = key("aerial_mineral_islet"); @@ -67,7 +67,10 @@ public final class AerialStructures24 { if (previous != null && previous.isValid()) continue; var start = structure.generate(holder, level, registries, generator, generator.getBiomeSource(), sampler, random, templates, origin.seed(), chunk.getPos(), previous == null ? 0 : previous.getReferences(), - chunk, structure.biomes()::contains); + // Only this explicit saved departure bypasses biome selection. Its complete + // geometry and clearance were certified by the aerial planner; the biome below + // may be forest, marsh or void. findGenerationPoint still requires this exact plan. + chunk, ignoredBiome -> sites.stream().anyMatch(site -> key(site).equals(key))); if (start.isValid()) manager.setStartForStructure(structure, start, chunk); } } diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/density_function/caves28_native.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/density_function/caves28_native.json new file mode 100644 index 0000000..06ad370 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/density_function/caves28_native.json @@ -0,0 +1,58 @@ +{ + "type": "minecraft:min", + "left": { + "type": "minecraft:max", + "left": { + "type": "minecraft:min", + "left": { + "type": "minecraft:min", + "left": { + "type": "minecraft:add", + "left": { + "type": "minecraft:mul", + "left": { + "type": "minecraft:square", + "input": { + "type": "minecraft:noise", + "noise": "minecraft:cave_layer", + "xz_scale": 1.0, + "y_scale": 8.0 + } + }, + "right": 4.0 + }, + "right": { + "type": "minecraft:clamp", + "input": { + "type": "minecraft:add", + "left": { + "type": "minecraft:noise", + "noise": "minecraft:cave_cheese", + "xz_scale": 1.0, + "y_scale": 0.6666666666666666 + }, + "right": 0.27 + }, + "max": 1.0, + "min": -1.0 + } + }, + "right": "minecraft:overworld/caves/entrances" + }, + "right": { + "type": "minecraft:add", + "left": "minecraft:overworld/caves/spaghetti_2d", + "right": "minecraft:overworld/caves/spaghetti_roughness_function" + } + }, + "right": { + "type": "minecraft:range_choice", + "input": "minecraft:overworld/caves/pillars", + "max_exclusive": 0.03, + "min_inclusive": -1000000.0, + "when_in_range": -1000000.0, + "when_out_of_range": "minecraft:overworld/caves/pillars" + } + }, + "right": "minecraft:overworld/caves/noodle" +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/density_function/population_10.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/density_function/population_10.json index c55eaa5..72f95ab 100644 --- a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/density_function/population_10.json +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/density_function/population_10.json @@ -49,5 +49,19 @@ "noise": "sanctuary:rift_detail", "xz_scale": 1.0, "y_scale": 1.0 + }, + "geology_28": true, + "caves_28": "sanctuary:caves28_native", + "ridges_28": { + "type": "minecraft:noise", + "noise": "minecraft:ridge", + "xz_scale": 1.0, + "y_scale": 1.0 + }, + "erosion_28": { + "type": "minecraft:noise", + "noise": "minecraft:erosion", + "xz_scale": 2.0, + "y_scale": 2.0 } } diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/density_function/population_20.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/density_function/population_20.json index 6509c86..8fbe293 100644 --- a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/density_function/population_20.json +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/density_function/population_20.json @@ -49,5 +49,19 @@ "noise": "sanctuary:rift_detail", "xz_scale": 1.0, "y_scale": 1.0 + }, + "geology_28": true, + "caves_28": "sanctuary:caves28_native", + "ridges_28": { + "type": "minecraft:noise", + "noise": "minecraft:ridge", + "xz_scale": 1.0, + "y_scale": 1.0 + }, + "erosion_28": { + "type": "minecraft:noise", + "noise": "minecraft:erosion", + "xz_scale": 2.0, + "y_scale": 2.0 } } diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/density_function/population_5.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/density_function/population_5.json index 28c5743..ba61d76 100644 --- a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/density_function/population_5.json +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/density_function/population_5.json @@ -49,5 +49,19 @@ "noise": "sanctuary:rift_detail", "xz_scale": 1.0, "y_scale": 1.0 + }, + "geology_28": true, + "caves_28": "sanctuary:caves28_native", + "ridges_28": { + "type": "minecraft:noise", + "noise": "minecraft:ridge", + "xz_scale": 1.0, + "y_scale": 1.0 + }, + "erosion_28": { + "type": "minecraft:noise", + "noise": "minecraft:erosion", + "xz_scale": 2.0, + "y_scale": 2.0 } } diff --git a/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/CavesRelief28Smoke.java b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/CavesRelief28Smoke.java new file mode 100644 index 0000000..4b9c882 --- /dev/null +++ b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/CavesRelief28Smoke.java @@ -0,0 +1,54 @@ +package fr.koka.sanctuary.worldgen; + +/** Adapters and finite fractures only. Native noise presence is checked separately in Minecraft. */ +public final class CavesRelief28Smoke { + public static void main(String[] args) { + for (int y = 0; y < 384; y++) { + require(CavesRelief28.deformationWeight(0, y, 0, 256) == 0, "arrival must not deform"); + require(CavesRelief28.deformationWeight(256, y, 0, 256) == 0, "outer coast must not deform"); + if (y <= 180) require(CavesRelief28.deformationWeight(128, y, 0, 256) == 0, + "accepted lower mass must not deform"); + } + require(CavesRelief28.deformationWeight(128, 220, 0, 256) + < CavesRelief28.deformationWeight(128, 228, 0, 256), "3D deformation must have a smooth vertical envelope"); + for (float v : new float[] {-10, -1, -.5F, 0, .5F, 1, 10}) { + var warp = CavesRelief28.warp(1, v, v, v); + var opposite = CavesRelief28.warp(1, -v, -v, -v); + require(Math.abs(warp.x()) <= 16 && Math.abs(warp.z()) <= 16 && Math.abs(warp.y()) <= 40, + "deformation must stay bounded in all three axes"); + require(warp.x() == -opposite.x() && warp.y() == -opposite.y() && warp.z() == -opposite.z(), + "noise must permit equivalent uplift and subsidence, without positive bias"); + var fixed = CavesRelief28.warp(0, v, v, v); + require(fixed.x() == 0 && fixed.y() == 0 && fixed.z() == 0, "zero envelope leaves coordinates unchanged"); + } + require(CavesRelief28.warp(1, 0, .5F, 0).y() < CavesRelief28.warp(1, 0, .6F, 0).y(), + "deformation must not saturate into broad flat plateaus"); + for (int y = 0; y < 384; y++) for (float cave : new float[] {-1, -.1F, .1F, 1, 64}) { + require(CavesRelief28.carve(-.3F, cave, y) == -.3F, "caves must never fill existing void"); + require(CavesRelief28.carve(.7F, cave, y) <= .7F, "caves must only remove rock"); + if (y <= 40 || y >= 232) require(CavesRelief28.carve(.7F, cave, y) == .7F, "bounded cave heights"); + } + require(CavesRelief28.carve(.7F, -.1F, 140) < 0, "negative native cave must open solid interior"); + for (long seed : new long[] {0, 42, 2026}) { + var layout = RiftShape.layout(seed); + int removed = 0, retained = 0; + for (int x = -216; x <= 216; x += 4) for (int z = -216; z <= 216; z += 4) { + require(RiftShape.density28(layout, x, 60, z, .7F, 0, 0) == .7F, "rift cannot cut lower boundary"); + require(RiftShape.density28(layout, x, 260, z, .7F, 0, 0) == .7F, "rift cannot traverse upper surface"); + int columnCut = 0; + for (int y = 88; y <= 244; y += 4) { + float value = RiftShape.density28(layout, x, y, z, .7F, .2F, -.3F); + require(value <= .7F, "rift cannot add material"); + if (value < 0) { removed++; columnCut++; } else retained++; + } + require(columnCut < 22, "fracture cannot cut the entire vertical island mass"); + } + require(removed > 0 && retained > removed, "finite ravines must open passages and retain rock"); + System.out.println("caves28 rifts seed=" + seed + " removed=" + removed + " retained=" + retained); + } + System.out.println("CavesRelief28Smoke: signed 3D deformation, native cut adapter and bounded ravines passed"); + } + private static void require(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } +} diff --git a/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/aerial/Aerial24Smoke.java b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/aerial/Aerial24Smoke.java index db0e1ce..6cb72a4 100644 --- a/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/aerial/Aerial24Smoke.java +++ b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/aerial/Aerial24Smoke.java @@ -48,12 +48,24 @@ public final class Aerial24Smoke { && site.height() == site.rockHeight() + AerialPlan24.VEGETATION_HEADROOM, "Vegetation adds eight blocks of headroom without enlarging the stone body"); double distance = Math.hypot(site.locator().getX() - site.shoreX(), site.locator().getZ() - site.shoreZ()); - require(distance <= 512, "Every site has a nearby shore witness"); + require(distance <= 512, "Every site has a nearby ground witness"); + if (site.merchant()) { + double gap = AerialPlanner24.shoreDistance(site); + require(gap >= 16 && gap <= 48, "Complete ship envelope is 16–48 blocks from its actual shore"); + require(Math.abs(AerialGeometry24.entry(site).getY() - site.shoreY()) <= 4, + "Boarding deck remains near the shore's walking height"); + } else { + require(root.ownsChunk(site.startChunkX(), site.startChunkZ()), "Mineral islets hover over the initial island"); + require(site.minY() - 240 - 1 >= AerialPlanner24.MIN_ISLET_GAP, + "The unchanged rocky body has at least sixteen air blocks above the terrain"); + } + require(Math.hypot(Math.clamp(0, site.minX(), site.maxX()), Math.clamp(0, site.minZ(), site.maxZ())) >= AerialPlanner24.ARRIVAL_RADIUS, + "Aerial bodies preserve the natural arrival zone"); require(plan.atStartChunk(site.startChunkX(), site.startChunkZ()).contains(site), "Native departure belongs to its saved site"); for (var chunk : site.claimedChunks()) { require(allClaims.add(chunk.pack()), "Site claims do not overlap"); require(site.ownsChunk(chunk.x(), chunk.z()) && plan.ownsChunk(chunk.x(), chunk.z()), "Claim query agrees with the immutable mask"); - require(!root.ownsChunk(chunk.x(), chunk.z()), "Claim does not replace initial island ownership"); + } var overlapping = new ExpansionIsland("overlap", "sanctuary", "east", "temperate", 64, site.startChunkX() * 16, site.startChunkZ() * 16, 42, "natural", "reserved", 24); @@ -109,7 +121,7 @@ public final class Aerial24Smoke { require(columns[0] <= AerialPlanner24.MAX_COLUMNS, "Reservations preserve the bounded terrain sampling budget"); for (var site : replacement.sites()) require(!terrain.reserved(site.shoreX(), site.shoreZ(), site.shoreY() - 1, site.shoreY() + 2), - "Every accepted shore stays outside planned root structures"); + "Every accepted ground witness stays outside planned root structures"); } } private static AerialPlan24.Site rotate(AerialPlan24.Site site, int facing) { diff --git a/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/aerial/AerialPersistence24Smoke.java b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/aerial/AerialPersistence24Smoke.java index c5f775e..9689e57 100644 --- a/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/aerial/AerialPersistence24Smoke.java +++ b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/aerial/AerialPersistence24Smoke.java @@ -51,6 +51,9 @@ public final class AerialPersistence24Smoke { require(draws.get() == 1 && stored.plan().sites().size() == 4, "One complete initial plan is recorded"); var journal = new ExpansionJournal(directory, SEED, 724, 24, stored.token()); var root = ExpansionIsland.origin(724, SEED, 24); + require(stored.plan().sites().stream().filter(site -> site.kind() == Kind.MINERAL_ISLET) + .allMatch(site -> root.ownsChunk(site.startChunkX(), site.startChunkZ())), + "An immutable elevated islet may share initial terrain chunks without replacing root ownership"); Path journalFile = directory.resolve(ExpansionJournal.FILE_NAME); byte[] pristineJournal = Files.readAllBytes(journalFile); require(JsonParser.parseString(Files.readString(journalFile)).getAsJsonObject().get("schema").getAsInt() == 2, @@ -143,8 +146,8 @@ public final class AerialPersistence24Smoke { return new AerialPlan24(List.of( new Site("merchant_provisions", Kind.MERCHANT_PROVISIONS, 0, 235, 660, 11, 18, 28, 0, 2, 0, 240, 390, false), new Site("merchant_tools", Kind.MERCHANT_TOOLS, -740, 242, 0, 11, 18, 28, 0, 3, -390, 240, 0, false), - new Site("mineral_islet_water", Kind.MINERAL_ISLET, -450, 255, -650, 48, 36, 48, 0, 4, -200, 240, -330, true), - new Site("mineral_islet_dry", Kind.MINERAL_ISLET, 460, 260, -600, 56, 40, 56, 0, 5, 250, 240, -300, false))); + new Site("mineral_islet_water", Kind.MINERAL_ISLET, -180, 270, -160, 48, 36, 48, 0, 4, -156, 240, -136, true), + new Site("mineral_islet_dry", Kind.MINERAL_ISLET, 125, 270, -170, 56, 40, 56, 0, 5, 153, 240, -142, false))); } private static String digest(byte[] bytes) throws Exception { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); } private static void require(boolean value, String message) { if (!value) throw new AssertionError(message); } diff --git a/packwiz/pack.toml b/packwiz/pack.toml index f05eb08..ff805f2 100644 --- a/packwiz/pack.toml +++ b/packwiz/pack.toml @@ -1,6 +1,6 @@ name = "Sanctuary" author = "KOKA99CAB" -version = "0.1.0-alpha.27" +version = "0.1.0-alpha.28" pack-format = "packwiz:1.1.0" [index]