fix(worldgen): restore curved rivers and global relief in alpha30.1
Build Sanctuary / build (push) Canceled after 0s

This commit is contained in:
koka
2026-09-12 11:47:00 +02:00
parent 071a8c062d
commit cccb89150e
15 changed files with 283 additions and 208 deletions
+7
View File
@@ -1,5 +1,12 @@
# Changelog # Changelog
## 0.1.0-alpha.30.1 — 2026-09-12
Retrait des rivières sur grille de la 30 ; brèches de cascade sur les anciennes
rivières courbes et leurs bassins. Retour du relief global de la 28, sauf une
bande étroite de plateaux naturels pour les accès aux traversées.
Voir [le correctif](docs/generation-alpha30.1.md). Nouveau monde nécessaire.
## 0.1.0-alpha.30 — 2026-09-12 ## 0.1.0-alpha.30 — 2026-09-12
Trois zones de déformation 3D, plateaux naturels entre les massifs, grande rivière Trois zones de déformation 3D, plateaux naturels entre les massifs, grande rivière
+10
View File
@@ -21,6 +21,16 @@ 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 marais souterrains et la restauration de vrais modèles d’épaves vanilla. Voir le
[chantier et ses limites](docs/generation-alpha27.md). [chantier et ses limites](docs/generation-alpha27.md).
## Alpha.30.1 — rivières courbes et relief global
Les rivières angulaires de la 30 sont retirées. Les anciens cours deau et
bassins peuvent déborder par une courte brèche dans leur berge. Le relief 3D
redevient global, avec une seule bande étroite de plateaux naturels pour
faciliter les accès aux traversées.
Créer un **nouveau monde**. Voir [le correctif](docs/generation-alpha30.1.md)
et [ses vérifications](docs/testing-alpha30.1.md).
## Alpha.30 — plateaux, rivières ouvertes et traversées ## Alpha.30 — plateaux, rivières ouvertes et traversées
La déformation 3D se concentre dans trois grandes zones ; les plateaux naturels La déformation 3D se concentre dans trois grandes zones ; les plateaux naturels
+9
View File
@@ -1000,3 +1000,12 @@ Assurer une recherche mieux répartie des traversées, notamment en Grand ;
chercher aussi le sanctuaire minéral sur les flancs en cas de refus intérieur. chercher aussi le sanctuaire minéral sur les flancs en cas de refus intérieur.
Conserver les bateaux proches, les cavités et les générateurs vanilla de la 28/29. Conserver les bateaux proches, les cavités et les générateurs vanilla de la 28/29.
Voir [le périmètre](generation-alpha30.md) et [les preuves](testing-alpha30.md). Voir [le périmètre](generation-alpha30.md) et [les preuves](testing-alpha30.md).
### WG-33 — Rivières courbes et relief global — alpha.30.1
Branche `codex/curved-rivers-alpha30-1`. Retirer la rivière angulaire de la 30,
retrouver la déformation globale de la 28 avec une zone restreinte de plateau,
et ouvrir les berges des anciens cours deau vers les espaces vides proches.
Conserver les recherches de traversées et de sanctuaire améliorées en 30.
Voir [le correctif](generation-alpha30.1.md) et [les essais](testing-alpha30.1.md).
+46
View File
@@ -0,0 +1,46 @@
# Alpha.30.1 — retour aux rivières courbes et au relief global
Correctif demandé après lessai de la 30 : les nouvelles rivières angulaires
évoquaient des canaux construits, et la limitation du relief à trois secteurs
rendait trop de surface plate.
## Changements
- Suppression du générateur de rivière sur grille ajouté en 30 et de son plan
global. Lhydrologie retrouve exclusivement les rivières courbes, bassins,
terrasses et sources déjà présents dans `PopulationHydrology`.
- Retour de la déformation 3D signée de la 28 sur lensemble du terrain, sauf
une bande étroite de plateaux naturels vers Z = rayon/4. Elle suit un axe
dimplantation possible des accès de traversée. La transition est progressive ;
le champ antérieur y est retrouvé sans dalle ajoutée ni remplissage des grottes.
Les tests synthétiques gardent le poids global exact sur plus de 85 % de
lemprise circulaire, et un cœur de plateau sur moins de 8 %.
- Brèches recherchées dans les **berges des anciens cours deau**, avec priorité
aux rivières, puis aux lacs et petits bassins. Les berges sèches déjà garnies
de sédiments ne bloquent plus cette recherche, contrairement à la 30.
- Chaque brèche est longue de cinq blocs maximum et large de trois blocs.
Son fond descend légèrement pour laisser l’écoulement vanilla atteindre la
lèvre. Elle ne crée ni nouvelle source, ni rivière supplémentaire, ni
fondation sous la chute. Aucune vérification dun bassin de réception :
leau peut descendre sur l’île ou dans le vide.
- Jusqu’à six brèches espacées par région, uniquement lorsquune rive est
assez proche dun espace ouvert. Un bassin situé loin du bord reste fermé.
Les tracés courbes ne sont pas redessinés par la passe de brèche. Le retour du
relief global peut toutefois changer leur implantation dune version à lautre,
puisquils recherchent leur place dans la roche disponible.
Les corrections de traversées, le sanctuaire minéral, les mines vanilla,
les navires restaurés et les deux îlots restent présents. La 30.1 conserve
Minecraft **26.3-pre-2**, les tailles **512 / 724 / 1 024**, Moyen par défaut,
et le bouton natif Personnaliser. Aucune nouvelle interface à traduire.
## Essai et distribution
Version mod et pack : **0.1.0-alpha.30.1**, branche
`codex/curved-rivers-alpha30-1`. **Créer un nouveau monde** : le prototype
conserve `sanctuary:island_v24` ; aucune migration ou régénération de sauvegarde
personnelle nest effectuée. Les structures conservent leurs options existantes.
Résultats effectivement vérifiés et coordonnées dans
[les essais](testing-alpha30.1.md). La distribution suit [packwiz](packwiz.md).
+91
View File
@@ -0,0 +1,91 @@
# Alpha.30.1 — vérifications
Essais natifs sur **Mac**, le 12 septembre 2026, en serveur de développement
sans rendu client. Aucun essai Windows natif de ce correctif nest revendiqué.
## Moyen 724, graine 0
Processus **64315** : **24/24 GameTests passent**, en 2 min 30 s pour la commande.
Reçus dans `build/alpha301-evidence/medium-0/`.
Deux brèches sont rattachées à la véritable **ancienne rivière courbe**, et non
à un nouveau tracé. Sources : **13 247 79** et **-105 247 -69** ; sorties :
**13 246 82** et **-105 246 -65**. Elles traversent respectivement trois et
quatre blocs de berge. Le test sous ticks réels observe l’écoulement en dessous.
Une lecture séparée de la sauvegarde confirme des colonnes continues deau aux
sorties, de **Y245 à Y225** et de **Y246 à Y226** à ce stade de simulation.
Aucune source na été ajoutée par la brèche. Cela ne prétend pas avoir attendu
leur propagation jusquau fond du monde. Preuve : `saved-water-columns.json`.
La traversée est réellement générée : **19 modules, 28 chunks FULL, 272 blocs
entre les accès, 478 pas contrôlés et 62 points accessibles**. Entrées :
**90 248 -144** et **90 225 128**. Le sanctuaire possède son départ natif et
un sol central libre au-dessus. Les quatre satellites sont contrôlés.
Une mine vanilla de **143 pièces** est retenue ; le test inspecte un échantillon
de 16 chunks FULL. Il trouve notamment 244 planches de chêne, 200 barrières,
102 rails, 50 toiles et un spawner dans les pièces chargées. Point de visite :
**152 175 -120**. Ce nest pas une visite de la totalité de la mine.
Comparaison avec le relief de la 28 : **6 348 densités inférieures inchangées**,
et seulement **466 / 74 060** signes modifiés dans les échantillons hauts
(**0,63 %**). Ce changement est localisé dans la bande de plateau ; le reste
utilise exactement le poids global de déformation précédent.
## Grand 1 024, graine -7228211907433324401
Le premier passage natif (processus **64637**) vérifie la traversée complète :
**23 modules, 52 chunks FULL, 374 blocs entre les accès, 788 pas contrôlés et
71 points accessibles**. Entrées : **192 238 -195** et **192 277 179**.
Le sanctuaire minéral et les quatre satellites sont également retenus.
La comparaison au relief 28 conserve **8 112 densités inférieures** ;
**940 / 94 640** signes hauts diffèrent (**0,99 %**). La recherche native
échantillonnée observe des portails en ruine et un camp, mais pas de mine dans
ce témoin limité. La preuve de mine de cette livraison est celle du Moyen 0.
La région centrale de cette graine contient des lacs et bassins, sans ancienne
rivière admise. Le premier passage a donc révélé une assertion de test trop
stricte : elle exigeait une rivière sur chaque témoin, contrairement au caractère
conditionnel de lhydrologie dorigine. Le test est corrigé sans forcer une
rivière dans le générateur. Le témoin spécifique de berge de rivière peut être
exigé avec `-PsanctuaryRequireCurvedRiverBank=true` sur une graine adaptée.
Le redémarrage (processus **65123**) passe les **24/24 GameTests** :
`check build` réussit en **4 min 19 s**. Le contrôle deau inspecte 8 chunks
FULL et trouve 7 positions deau sous les deux brèches testées. Sorties :
**-14 236 41** et **-50 237 -80**. La traversée et le sol du sanctuaire sont
revérifiés. Les satellites sont rechargés après pillage, extraction dun
villageois et démolition partielle : les témoins ne sont pas reconstruits et
le villageois nest pas dupliqué. Reçus :
`build/alpha301-evidence/large-user/reload/`.
Commande de vérification :
```sh
./gradlew check build -PsanctuaryTestDiameter=1024 \
-PsanctuaryTestSeed=-7228211907433324401 -PsanctuaryDensityMaps=false \
-PsanctuaryAerial24Tests=true -PsanctuaryAerial24Phase=reload1 \
-PsanctuaryExpansionReload=true -PsanctuaryTransit29Tests=true
```
## Tests ciblés
`river30Smoke` passe : ouverture à travers une berge sèche existante, conservation
exacte des points du tracé et des cellules deau, absence de nouvelle source,
déterminisme et conservation dune berge intérieure éloignée du vide.
Une brèche de cinq blocs possède une légère descente pour l’écoulement vanilla.
Les trois tailles conservent le poids global exact sur plus de 85 % de leur
emprise circulaire ; le cœur de plateau couvre moins de 8 %.
Les brèches restent conditionnelles à la proximité dun espace ouvert. Ces tests
ne justifient pas dannoncer une cascade pour tous les bassins ou toutes les graines.
La taille Petit est couverte par les tests de champ et les tests synthétiques ;
elle na pas fait lobjet dun nouveau passage natif complet pour ce correctif.
`assemblePack` passe après le `check build` réussi ; la tâche `:sanctuary:check`
est exclue de cette seconde commande pour ne pas répéter la suite inchangée.
Le MRpack autonome, le JAR intégré et le ZIP damorçage Prism sont vérifiés
localement : versions, index, empreintes et contenu concordent. Reçus :
`build/alpha301-artifacts.json` et `build/alpha301-delivery-local.json`.
La publication et la synchronisation sont consignées dans [Distribution](packwiz.md). Créer un nouveau monde pour lessai ; aucune sauvegarde personnelle
nest régénérée.
+2 -2
View File
@@ -8,6 +8,6 @@ loader_version=0.19.5
loom_version=1.17.20 loom_version=1.17.20
fabric_api_version=0.160.0+26.3 fabric_api_version=0.160.0+26.3
mod_version=0.1.0-alpha.30 mod_version=0.1.0-alpha.30.1
pack_version=0.1.0-alpha.30 pack_version=0.1.0-alpha.30.1
maven_group=fr.koka.sanctuary maven_group=fr.koka.sanctuary
+1
View File
@@ -59,6 +59,7 @@ tasks.named('runGameTest') {
systemProperty('sanctuary.test.transit29', providers.gradleProperty('sanctuaryTransit29Tests').getOrElse('false')) systemProperty('sanctuary.test.transit29', providers.gradleProperty('sanctuaryTransit29Tests').getOrElse('false'))
systemProperty('sanctuary.test.transit29.planOnly', providers.gradleProperty('sanctuaryTransit29PlanOnly').getOrElse('false')) systemProperty('sanctuary.test.transit29.planOnly', providers.gradleProperty('sanctuaryTransit29PlanOnly').getOrElse('false'))
systemProperty('sanctuary.test.requireMineshaft29', providers.gradleProperty('sanctuaryRequireMineshaft29').getOrElse('false')) systemProperty('sanctuary.test.requireMineshaft29', providers.gradleProperty('sanctuaryRequireMineshaft29').getOrElse('false'))
systemProperty('sanctuary.test.requireCurvedRiverBank', providers.gradleProperty('sanctuaryRequireCurvedRiverBank').getOrElse('false'))
systemProperty('sanctuary.expansion.structureDiagnostics', providers.gradleProperty('sanctuaryStructureDiagnostics').getOrElse('false')) systemProperty('sanctuary.expansion.structureDiagnostics', providers.gradleProperty('sanctuaryStructureDiagnostics').getOrElse('false'))
systemProperty('sanctuary.test.heritage21', providers.gradleProperty('sanctuaryHeritage21Tests').getOrElse('false')) systemProperty('sanctuary.test.heritage21', providers.gradleProperty('sanctuaryHeritage21Tests').getOrElse('false'))
systemProperty('sanctuary.test.lostCity21', providers.gradleProperty('sanctuaryLostCity21Tests').getOrElse('false')) systemProperty('sanctuary.test.lostCity21', providers.gradleProperty('sanctuaryLostCity21Tests').getOrElse('false'))
@@ -15,17 +15,23 @@ public final class River30WorldGameTests {
@GameTest(maxTicks=12000) @GameTest(maxTicks=12000)
public void riverAndOpenBanksUseRealTerrain(GameTestHelper helper) throws Exception { public void riverAndOpenBanksUseRealTerrain(GameTestHelper helper) throws Exception {
var level=helper.getLevel(); var generator=(SanctuaryChunkGenerator)level.getChunkSource().getGenerator(); var level=helper.getLevel(); var generator=(SanctuaryChunkGenerator)level.getChunkSource().getGenerator();
var binding=generator.starterContext();var plan=PopulationHydrologyRuntime.river30(binding.generator(),binding.random()); var binding=generator.starterContext();var plan=PopulationHydrologyRuntime.regionPlan(binding.generator(),binding.random(),new IslandCapacity.Region(0,0)).water();
var report=new LinkedHashMap<String,Object>();report.put("seed",level.getSeed());report.put("diameter",generator.initialDiameter()); var report=new LinkedHashMap<String,Object>();report.put("seed",level.getSeed());report.put("diameter",generator.initialDiameter());
report.put("process_id",ProcessHandle.current().pid());report.put("river_cells",plan.cells().size());report.put("features",plan.features());report.put("spills",plan.spills()); report.put("process_id",ProcessHandle.current().pid());report.put("river_cells",plan.cells().size());report.put("features",plan.features());report.put("spills",plan.spills());
report.put("mineral",generator.mineralSanctuary23()); report.put("mineral",generator.mineralSanctuary23());
helper.assertTrue(plan.features().stream().anyMatch(f->f.kind()==PopulationHydrology.Kind.RIVER),"An island-wide river must be present in the release witness"); Path initial=Path.of("diagnostics/alpha301/river-planned.json"); Files.createDirectories(initial.getParent());
Files.writeString(initial,new GsonBuilder().setPrettyPrinting().create().toJson(report));
report.put("curved_river_count",plan.features().stream().filter(f->f.kind()==PopulationHydrology.Kind.RIVER).count());
if(level.structureManager().shouldGenerateStructures()&&generator.experimentalStructures()) if(level.structureManager().shouldGenerateStructures()&&generator.experimentalStructures())
helper.assertFalse(generator.mineralSanctuary23().isEmpty(),"The mineral sanctuary must be present in the release witness"); helper.assertFalse(generator.mineralSanctuary23().isEmpty(),"The mineral sanctuary must be present in the release witness");
var selected=plan.cells().stream().filter(PopulationHydrology.Cell::hasWater).toList(); var selected=plan.cells().stream().filter(PopulationHydrology.Cell::hasWater).toList();
var witnesses=new ArrayList<PopulationHydrology.Cell>(); var witnesses=new ArrayList<PopulationHydrology.Cell>();
for(int i=0;i<6;i++)witnesses.add(selected.get(i*(selected.size()-1)/5)); for(int i=0;i<6;i++)witnesses.add(selected.get(i*(selected.size()-1)/5));
var falls=plan.spills().stream().limit(2).toList();var chunks=new LinkedHashSet<ChunkPos>(); var falls=plan.spills().stream().filter(f -> plan.features().stream().anyMatch(feature -> feature.kind()==PopulationHydrology.Kind.OUTLET && feature.id()==f.id())).limit(2).toList();
if(Boolean.getBoolean("sanctuary.test.requireCurvedRiverBank"))
helper.assertTrue(falls.stream().anyMatch(f -> plan.features().stream().anyMatch(feature -> feature.id()==f.fromFeatureId() && feature.kind()==PopulationHydrology.Kind.RIVER)),
"The required witness opens a bank of an original curved river");
helper.assertTrue(plan.features().stream().noneMatch(f -> f.id()==0x300000000L),"The rejected grid river is absent");var chunks=new LinkedHashSet<ChunkPos>();
witnesses.forEach(c->chunks.add(new ChunkPos(c.x()>>4,c.z()>>4))); witnesses.forEach(c->chunks.add(new ChunkPos(c.x()>>4,c.z()>>4)));
for(var fall:falls){chunks.add(new ChunkPos(fall.source().x()>>4,fall.source().z()>>4));chunks.add(new ChunkPos(fall.outlet().x()>>4,fall.outlet().z()>>4));} for(var fall:falls){chunks.add(new ChunkPos(fall.source().x()>>4,fall.source().z()>>4));chunks.add(new ChunkPos(fall.outlet().x()>>4,fall.outlet().z()>>4));}
var mineral=generator.mineralSanctuary23().chamber(); var mineral=generator.mineralSanctuary23().chamber();
@@ -48,13 +54,13 @@ public final class River30WorldGameTests {
helper.assertTrue(level.getBlockState(floor).isSolid()&&level.getBlockState(floor.above()).isAir(),"The central mineral floor and guardian space are really generated"); helper.assertTrue(level.getBlockState(floor).isSolid()&&level.getBlockState(floor.above()).isAir(),"The central mineral floor and guardian space are really generated");
report.put("mineral_native_floor",List.of(floor.getX(),floor.getY(),floor.getZ())); report.put("mineral_native_floor",List.of(floor.getX(),floor.getY(),floor.getZ()));
} }
for(var c:witnesses)helper.assertTrue(!level.getFluidState(new BlockPos(c.x(),c.waterY(),c.z())).isEmpty(),"Generated river water remains at its supported local bed"); for(var c:witnesses)helper.assertTrue(level.getFluidState(new BlockPos(c.x(),c.waterY(),c.z())).is(net.minecraft.tags.FluidTags.WATER),"Generated river water remains at its supported local bed");
int flowing=0; int flowing=0;
for(var fall:falls)for(int dx=-1;dx<=1;dx++)for(int dz=-1;dz<=1;dz++) for(var fall:falls)for(int dx=-1;dx<=1;dx++)for(int dz=-1;dz<=1;dz++)
if(!level.getFluidState(new BlockPos(fall.outlet().x()+dx,fall.outlet().y()-3,fall.outlet().z()+dz)).isEmpty()){flowing++;} if(level.getFluidState(new BlockPos(fall.outlet().x()+dx,fall.outlet().y()-3,fall.outlet().z()+dz)).is(net.minecraft.tags.FluidTags.WATER)){flowing++;}
if(!falls.isEmpty())helper.assertTrue(flowing>0,"Vanilla fluid ticks carry water below at least one opened bank"); if(!falls.isEmpty())helper.assertTrue(flowing>0,"Vanilla fluid ticks carry water below at least one opened bank");
report.put("full_chunks",chunks.size());report.put("flowing_below_openings",flowing);report.put("passed",true); report.put("tested_outlets",falls); report.put("full_chunks",chunks.size());report.put("flowing_below_openings",flowing);report.put("passed",true);
Path out=Path.of("diagnostics/alpha30/river.json");Files.createDirectories(out.getParent());Files.writeString(out,new GsonBuilder().setPrettyPrinting().create().toJson(report));helper.succeed(); Path out=Path.of("diagnostics/alpha301/river.json");Files.createDirectories(out.getParent());Files.writeString(out,new GsonBuilder().setPrettyPrinting().create().toJson(report));helper.succeed();
}catch(Exception error){throw new RuntimeException(error);}finally{chunks.forEach(c->level.getChunkSource().removeTicketWithRadius(TicketType.DRAGON,c,2));} }catch(Exception error){throw new RuntimeException(error);}finally{chunks.forEach(c->level.getChunkSource().removeTicketWithRadius(TicketType.DRAGON,c,2));}
}); });
};step[0].run(); };step[0].run();
@@ -22,7 +22,7 @@ public final class Terraces29WorldGameTests {
var field = (PopulationIslandDensity) level.registryAccess().lookupOrThrow(Registries.DENSITY_FUNCTION) var field = (PopulationIslandDensity) level.registryAccess().lookupOrThrow(Registries.DENSITY_FUNCTION)
.getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, .getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION,
SanctuaryMod.id("population_" + generator.capacity().players()))).value(); SanctuaryMod.id("population_" + generator.capacity().players()))).value();
helper.assertTrue(field.relief30() && !field.terraces29(), "Alpha30 localizes deformation and disables periodic micro-ledges"); helper.assertTrue(field.relief30() && !field.terraces29(), "Alpha30.1 restores global deformation except a narrow plateau");
var random = generator.starterContext().random(); var random = generator.starterContext().random();
var current = random.getSampler(field); var previous = random.getSampler(field.legacy28()); var current = random.getSampler(field); var previous = random.getSampler(field.legacy28());
var context = SamplerContext.builder().enableCaches().build(); var context = SamplerContext.builder().enableCaches().build();
@@ -43,7 +43,7 @@ public final class Terraces29WorldGameTests {
if (Math.hypot(x, z) <= 48) helper.assertTrue(old == now, "Arrival silhouette is unchanged"); if (Math.hypot(x, z) <= 48) helper.assertTrue(old == now, "Arrival silhouette is unchanged");
} }
} }
helper.assertTrue(changed > 0 && unchanged > changed * 3L, "Localized relief retains most of the accepted upper mass"); helper.assertTrue(changed > 0 && unchanged > changed * 3L, "A narrow plateau retains most of the accepted global relief");
report.put("unchanged_lower_density_samples", lowerSamples); report.put("unchanged_lower_density_samples", lowerSamples);
report.put("unchanged_upper_samples", unchanged); report.put("changed_upper_samples", changed); report.put("unchanged_upper_samples", unchanged); report.put("changed_upper_samples", changed);
var sections = new LinkedHashMap<String, Object>(); var sections = new LinkedHashMap<String, Object>();
@@ -60,7 +60,7 @@ public final class Terraces29WorldGameTests {
sections.put("z" + z, Map.of("before", before, "after", after, "min_x", -radius, "max_y", 305, "step", 1)); sections.put("z" + z, Map.of("before", before, "after", after, "min_x", -radius, "max_y", 305, "step", 1));
} }
report.put("sections", sections); report.put("passed", true); report.put("sections", sections); report.put("passed", true);
Path path = Path.of("diagnostics/alpha30", "relief-" + generator.initialDiameter() + "-" + level.getSeed() + ".json"); Path path = Path.of("diagnostics/alpha301", "relief-" + generator.initialDiameter() + "-" + level.getSeed() + ".json");
Files.createDirectories(path.getParent()); Files.createDirectories(path.getParent());
Files.writeString(path, new GsonBuilder().setPrettyPrinting().create().toJson(report)); Files.writeString(path, new GsonBuilder().setPrettyPrinting().create().toJson(report));
helper.succeed(); helper.succeed();
@@ -0,0 +1,61 @@
package fr.koka.sanctuary.worldgen;
import fr.koka.sanctuary.worldgen.PopulationHydrology.*;
import java.util.*;
/** Opens existing curved rivers and basins; never draws an additional river. */
public final class BankOutlets30 {
private static final int[][] DIR = {{1,0},{-1,0},{0,1},{0,-1}};
private BankOutlets30() {}
private static long key(int x, int z) { return ((long)x << 32) ^ (z & 0xffffffffL); }
/** Cut only a short bank. Nothing is built beneath a fall and its landing is unrestricted. */
public static Plan openBanks(long seed,Plan water,Sampler sampler,int maximum) {
var cuts=new LinkedHashMap<Long,Cell>();var features=new ArrayList<Feature>();var spills=new ArrayList<Spill>();
var mouths=new ArrayList<Point>();
var kinds = new HashMap<Long,Kind>();
water.features().forEach(f -> kinds.put(f.id(), f.kind()));
var candidates=water.cells().stream().filter(Cell::hasWater)
.filter(c -> Set.of(Kind.RIVER, Kind.LAKE, Kind.POND).contains(kinds.get(c.featureId())))
.sorted(Comparator.<Cell>comparingInt(c -> kinds.get(c.featureId()) == Kind.RIVER ? 0 : 1)
.thenComparingLong(c->mix(seed^key(c.x(),c.z()))).thenComparingInt(Cell::x).thenComparingInt(Cell::z)).toList();
for(var wet:candidates) {
if(mouths.size()>=maximum) break;
if(mouths.stream().anyMatch(p->Math.hypot(p.x()-wet.x(),p.z()-wet.z())<48)) continue;
for(var d:DIR) {
var adjacent=water.cellAt(wet.x()+d[0],wet.z()+d[1]);
if(adjacent!=null && adjacent.hasWater()) continue;
int reach=0;
for(int n=1;n<=5;n++) {
int px=wet.x()+d[0]*n,pz=wet.z()+d[1]*n;
if(sampler.sample(px,wet.waterY(),pz)<=0 && sampler.sample(px,wet.waterY()-3,pz)<=0
&& sampler.sample(px,wet.waterY()-6,pz)<=0) {reach=n;break;}
}
if(reach==0) continue;
long id=0x310000000L+(mix(seed)&0xfffffffL)*16+mouths.size();var local=new LinkedHashMap<Long,Cell>();
boolean conflict=false;
for(int n=1;n<=reach;n++) for(int side=-1;side<=1;side++) {
int px=wet.x()+d[0]*n+d[1]*side,pz=wet.z()+d[1]*n-d[0]*side;
var old=water.cellAt(px,pz);
if(old!=null && old.hasWater()) {conflict=true;continue;}
local.put(key(px,pz),new Cell(px,pz,-1,wet.waterY()-1-n/3,wet.waterY()+2,ShoreMaterial.STONE,id,0));
}
if(conflict) continue;
// Keep writes local to the same regional owner as the existing water.
if(local.values().stream().anyMatch(c -> c.x() < -255 || c.x() > 254 || c.z() < -255 || c.z() > 254)) continue;
cuts.putAll(local);features.add(feature(id,Kind.OUTLET,local.values(),List.of()));mouths.add(new Point(wet.x(),wet.z()));
var source=new Position(wet.x(),wet.waterY(),wet.z());
var outlet=new Position(wet.x()+d[0]*reach,wet.waterY()-reach/3,wet.z()+d[1]*reach);
var bounds=new FlowBounds(Math.min(source.x(),outlet.x())-3,0,Math.min(source.z(),outlet.z())-3,
Math.max(source.x(),outlet.x())+3,wet.waterY()+3,Math.max(source.z(),outlet.z())+3);
spills.add(new Spill(id,wet.featureId(),-1,source,outlet,List.of(source,outlet),bounds));break;
}
}
return water.overlay30(PopulationHydrology.additional30(cuts,features,spills,0,0));
}
private static Feature feature(long id,Kind kind,Collection<Cell> cells,List<Point> path) {
int x0=Integer.MAX_VALUE,z0=x0,x1=Integer.MIN_VALUE,z1=x1,water=-1,count=0;
for(var c:cells){x0=Math.min(x0,c.x());z0=Math.min(z0,c.z());x1=Math.max(x1,c.x());z1=Math.max(z1,c.z());if(c.hasWater()){water=c.waterY();count++;}}
return new Feature(id,kind,water,path,count,new Bounds(x0,z0,x1,z1));
}
private static long mix(long n){n=(n^(n>>>30))*0xbf58476d1ce4e5b9L;n=(n^(n>>>27))*0x94d049bb133111ebL;return n^(n>>>31);}
}
@@ -17,25 +17,12 @@ public final class PopulationHydrologyRuntime {
public record RegionPlan(IslandCapacity.Region region, PopulationHydrology.Plan water, PopulationLavaDeposit.Plan lava) {} public record RegionPlan(IslandCapacity.Region region, PopulationHydrology.Plan water, PopulationLavaDeposit.Plan lava) {}
private static final Map<RandomState, PopulationPlanCache<IslandCapacity.Region, RegionPlan>> WORLDS = private static final Map<RandomState, PopulationPlanCache<IslandCapacity.Region, RegionPlan>> WORLDS =
Collections.synchronizedMap(new WeakHashMap<>()); Collections.synchronizedMap(new WeakHashMap<>());
private static final Map<RandomState, PopulationHydrology.Plan> RIVERS30 = new WeakHashMap<>();
public static boolean current30(NoiseBasedChunkGenerator generator) { public static boolean current30(NoiseBasedChunkGenerator generator) {
var settings = generator.generatorSettings(); var settings = generator.generatorSettings();
return settings.is(fr.koka.sanctuary.SanctuaryMod.id("unified_5")) return settings.is(fr.koka.sanctuary.SanctuaryMod.id("unified_5"))
|| settings.is(fr.koka.sanctuary.SanctuaryMod.id("unified_10")) || settings.is(fr.koka.sanctuary.SanctuaryMod.id("unified_10"))
|| settings.is(fr.koka.sanctuary.SanctuaryMod.id("unified_20")); || settings.is(fr.koka.sanctuary.SanctuaryMod.id("unified_20"));
} }
public static PopulationHydrology.Plan river30(NoiseBasedChunkGenerator generator, RandomState state) {
synchronized (RIVERS30) {
return RIVERS30.computeIfAbsent(state, ignored -> {
var sampler = state.getSampler(generator.generatorSettings().value().noiseRouter().finalDensity());
var plan = River30.create(state.seed(), (int) capacity(generator).radius(),
new SignMemo((x,y,z) -> sampler.sampleValue(SamplerContext.EMPTY_UNCACHED,x,y,z)));
SanctuaryMod.LOGGER.info("River30 seed {}: {} cells, {} outlets, {} path points", state.seed(),
plan.cells().size(), plan.spills().size(), plan.features().stream().mapToInt(f -> f.path().size()).sum());
return plan;
});
}
}
private PopulationHydrologyRuntime() {} private PopulationHydrologyRuntime() {}
public static boolean enabled(NoiseBasedChunkGenerator generator) { return IslandCapacity.forGenerator(generator) != null; } public static boolean enabled(NoiseBasedChunkGenerator generator) { return IslandCapacity.forGenerator(generator) != null; }
public static IslandCapacity capacity(NoiseBasedChunkGenerator generator) { public static IslandCapacity capacity(NoiseBasedChunkGenerator generator) {
@@ -67,8 +54,7 @@ public final class PopulationHydrologyRuntime {
long seed = PopulationIslandDensity.regionSeed(state.seed(), region); long seed = PopulationIslandDensity.regionSeed(state.seed(), region);
var water = PopulationHydrology.create(seed, memo).admitted(); var water = PopulationHydrology.create(seed, memo).admitted();
if (current30(generator)) { if (current30(generator)) {
water = River30.openBanks(seed, water, memo, 1) water = BankOutlets30.openBanks(seed, water, memo, 6);
.overlay30(river30(generator, state).translated(-region.originX(), -region.originZ()));
} }
var lava = PopulationLavaDeposit.create(seed, memo, water).admitted(); var lava = PopulationLavaDeposit.create(seed, memo, water).admitted();
var result = new RegionPlan(region, water.translated(region.originX(), region.originZ()), var result = new RegionPlan(region, water.translated(region.originX(), region.originZ()),
@@ -1,23 +1,15 @@
package fr.koka.sanctuary.worldgen; package fr.koka.sanctuary.worldgen;
/** Three broad areas of signed deformation; the intervening rock keeps the original plateau field. */ /** Alpha30.1 restores the global signed warp, except for one narrow natural plateau area. */
public final class Relief30 { public final class Relief30 {
private Relief30() {} private Relief30() {}
public static double influence(long seed, int x, int z, double radius) { public static double influence(long seed, int x, int z, double radius) {
long mixed = seed ^ 0x30BADC0FFEEL; // The earlier plateau field remains along one candidate transit alignment.
mixed = (mixed ^ (mixed >>> 30)) * 0xbf58476d1ce4e5b9L; // Nothing is filled or levelled: the raw field and all existing caves remain active.
double rotation = (mixed >>> 11) * 0x1.0p-53 * Math.PI * 2; double along = x / (radius * .62);
double strongest = 0; double across = (z + radius * .25) / Math.max(20, radius * .08);
for (int i = 0; i < 3; i++) { double distance = Math.hypot(along, across);
double angle = rotation + i * Math.PI * 2 / 3; double t = Math.clamp((distance - 1) / .45, 0, 1);
double cx = Math.cos(angle) * radius * .52, cz = Math.sin(angle) * radius * .52; return t * t * (3 - 2 * t);
double dx = x - cx, dz = z - cz;
double along = (dx * Math.cos(angle) + dz * Math.sin(angle)) / (radius * .47);
double across = (-dx * Math.sin(angle) + dz * Math.cos(angle)) / (radius * .34);
double distance = Math.hypot(along, across);
double t = Math.clamp((1 - distance) / .70, 0, 1);
strongest = Math.max(strongest, t * t * (3 - 2 * t));
}
return strongest;
} }
} }
@@ -1,146 +0,0 @@
package fr.koka.sanctuary.worldgen;
import fr.koka.sanctuary.worldgen.PopulationHydrology.*;
import java.util.*;
/** One island-scale route follows actual rock, with local water levels and short open banks. */
public final class River30 {
private static final int STEP = 8;
private static final int[][] DIR = {{1,0},{-1,0},{0,1},{0,-1}};
private River30() {}
private static long key(int x, int z) { return ((long)x << 32) ^ (z & 0xffffffffL); }
private static int x(long k) { return (int)(k >> 32); }
private static int z(long k) { return (int)k; }
private record Node(long key, double cost) {}
private static final class Rock {
final Sampler sampler; final Map<Long,Integer> tops = new HashMap<>(); int samples;
Rock(Sampler sampler) { this.sampler = sampler; }
boolean solid(int x,int y,int z) { samples++; return y > 1 && y < 384 && sampler.sample(x,y,z) > 0; }
int top(int x,int z) {
return tops.computeIfAbsent(key(x,z), unused -> {
for (int y=358;y>=140;y--) if (solid(x,y,z)) {
for(int d=1;d<=6;d++) if(!solid(x,y-d,z)) return -1;
return y;
}
return -1;
});
}
}
public static Plan create(long seed, int radius, Sampler sampler) {
var rock = new Rock(sampler); var nodes = new TreeMap<Long,Integer>();
int limit = radius / STEP * STEP;
for(int x=-limit;x<=limit;x+=STEP) for(int z=-limit;z<=limit;z+=STEP) {
if(Math.hypot(x,z)>radius*.95 || Math.hypot(x,z)<52) continue;
int y=rock.top(x,z); if(y>=140) nodes.put(key(x,z),y);
}
// Ignore isolated peripheral rock when selecting opposite banks: route in the largest connected mass.
var adjacency=new HashMap<Long,List<Long>>();
for(long k:nodes.keySet()) {
var neighbours=new ArrayList<Long>();
for(var d:DIR) {
long next=key(x(k)+d[0]*STEP,z(k)+d[1]*STEP);
if(!nodes.containsKey(next)||Math.abs(nodes.get(next)-nodes.get(k))>48)continue;
int gaps=0;for(int n=1;n<STEP;n++)if(rock.top(x(k)+d[0]*n,z(k)+d[1]*n)<140)gaps++;
if(gaps<=2)neighbours.add(next);
}
adjacency.put(k,neighbours);
}
var remaining=new TreeSet<>(nodes.keySet());Set<Long> largest=Set.of();
while(!remaining.isEmpty()) {
var component=new HashSet<Long>();var queue=new ArrayDeque<Long>();queue.add(remaining.first());
while(!queue.isEmpty()){long k=queue.remove();if(!component.add(k))continue;queue.addAll(adjacency.get(k));}
remaining.removeAll(component);if(component.size()>largest.size())largest=component;
}
nodes.keySet().retainAll(largest);
List<Long> best = List.of(); double longest = 0;
// Four deterministic orientations avoid choosing an axis whose shore is entirely broken.
for(int axis=0;axis<4;axis++) {
double angle=(seed & 1023)*Math.PI/512 + axis*Math.PI/4, dx=Math.cos(angle), dz=Math.sin(angle);
var ordered=nodes.keySet().stream().sorted(Comparator.<Long>comparingDouble(k->x(k)*dx+z(k)*dz).thenComparingLong(k->k)).toList();
if(ordered.isEmpty()) break;
long start=ordered.getFirst(); var costs=new HashMap<Long,Double>(); var parent=new HashMap<Long,Long>();
var queue=new PriorityQueue<Node>(Comparator.comparingDouble(Node::cost).thenComparingLong(Node::key));
costs.put(start,0.0);parent.put(start,start);queue.add(new Node(start,0));
while(!queue.isEmpty()) {
var at=queue.remove(); if(at.cost()!=costs.get(at.key())) continue;
for(long next:adjacency.get(at.key())) {
if(!nodes.containsKey(next)) continue;
int difference=Math.abs(nodes.get(next)-nodes.get(at.key()));
double cost=at.cost()+STEP+difference*difference*.18+Math.max(0,nodes.get(next)-215)*.13;
if(cost>=costs.getOrDefault(next,Double.POSITIVE_INFINITY)) continue;
costs.put(next,cost);parent.put(next,at.key());queue.add(new Node(next,cost));
}
}
for(int i=ordered.size()-1;i>=0;i--) {
long end=ordered.get(i); if(!parent.containsKey(end)) continue;
double span=Math.hypot(x(end)-x(start),z(end)-z(start));
if(span>longest) { var path=new ArrayList<Long>();for(long k=end;;k=parent.get(k)){path.add(k);if(k==start)break;}Collections.reverse(path);best=path;longest=span; }
break;
}
}
var cells=new LinkedHashMap<Long,Cell>();var path=new ArrayList<Point>(); long id=0x300000000L;
if(longest>=radius*.7) for(int i=1;i<best.size();i++) {
long a=best.get(i-1),b=best.get(i);int dx=Integer.signum(x(b)-x(a)),dz=Integer.signum(z(b)-z(a));
for(int n=0;n<=STEP;n++) {
int cx=x(a)+dx*n,cz=z(a)+dz*n;path.add(new Point(cx,cz));
int center=rock.top(cx,cz);
for(int side=-2;side<=2;side++) {
int px=cx+dz*side,pz=cz-dx*side,top=rock.top(px,pz);
if(top<140 || Math.abs(top-center)>5) continue;
// The bed stays in existing rock. Higher neighbouring faces become natural banks.
int level=Math.min(top-1,center-1),bed=level-2;boolean support=true;
for(int y=bed-3;y<=bed;y++) if(!rock.solid(px,y,pz)) {support=false;break;}
if(support) cells.put(key(px,pz),new Cell(px,pz,level,bed,top,side==0?ShoreMaterial.GRAVEL:ShoreMaterial.CLAY,id,2));
}
}
}
System.getLogger(River30.class.getName()).log(System.Logger.Level.INFO,"River30 connectedNodes="+nodes.size()+" span="+longest+" columns="+rock.tops.size());
var features=new ArrayList<Feature>();
if(!cells.isEmpty()) features.add(feature(id,Kind.RIVER,cells.values(),path));
var plan=PopulationHydrology.additional30(cells,features,List.of(),rock.tops.size(),rock.samples);
return openBanks(seed,plan,sampler,6);
}
/** Cut only a short bank. Nothing is built beneath a fall and its landing is unrestricted. */
public static Plan openBanks(long seed,Plan water,Sampler sampler,int maximum) {
var cuts=new LinkedHashMap<Long,Cell>();var features=new ArrayList<Feature>();var spills=new ArrayList<Spill>();
var mouths=new ArrayList<Point>();
var candidates=water.cells().stream().filter(Cell::hasWater).sorted(Comparator
.<Cell>comparingLong(c->mix(seed^key(c.x(),c.z()))).thenComparingInt(Cell::x).thenComparingInt(Cell::z)).toList();
for(var wet:candidates) {
if(mouths.size()>=maximum) break;
if(mouths.stream().anyMatch(p->Math.hypot(p.x()-wet.x(),p.z()-wet.z())<48)) continue;
for(var d:DIR) {
if(water.cellAt(wet.x()+d[0],wet.z()+d[1])!=null) continue;
int reach=0;
for(int n=1;n<=5;n++) {
int px=wet.x()+d[0]*n,pz=wet.z()+d[1]*n;
if(sampler.sample(px,wet.waterY(),pz)<=0 && sampler.sample(px,wet.waterY()-3,pz)<=0
&& sampler.sample(px,wet.waterY()-6,pz)<=0) {reach=n;break;}
}
if(reach==0) continue;
long id=0x310000000L+(mix(seed)&0xfffffffL)*16+mouths.size();var local=new LinkedHashMap<Long,Cell>();
boolean conflict=false;
for(int n=1;n<=reach;n++) for(int side=-1;side<=1;side++) {
int px=wet.x()+d[0]*n+d[1]*side,pz=wet.z()+d[1]*n-d[0]*side;
var old=water.cellAt(px,pz);
if(old!=null && old.hasWater()) {conflict=true;continue;}
local.put(key(px,pz),new Cell(px,pz,-1,wet.waterY()-1,wet.waterY()+2,ShoreMaterial.STONE,id,0));
}
if(conflict) continue;
cuts.putAll(local);features.add(feature(id,Kind.OUTLET,local.values(),List.of()));mouths.add(new Point(wet.x(),wet.z()));
var source=new Position(wet.x(),wet.waterY(),wet.z());
var outlet=new Position(wet.x()+d[0]*reach,wet.waterY(),wet.z()+d[1]*reach);
var bounds=new FlowBounds(Math.min(source.x(),outlet.x())-3,0,Math.min(source.z(),outlet.z())-3,
Math.max(source.x(),outlet.x())+3,wet.waterY()+3,Math.max(source.z(),outlet.z())+3);
spills.add(new Spill(id,wet.featureId(),-1,source,outlet,List.of(source,outlet),bounds));break;
}
}
return water.overlay30(PopulationHydrology.additional30(cuts,features,spills,0,0));
}
private static Feature feature(long id,Kind kind,Collection<Cell> cells,List<Point> path) {
int x0=Integer.MAX_VALUE,z0=x0,x1=Integer.MIN_VALUE,z1=x1,water=-1,count=0;
for(var c:cells){x0=Math.min(x0,c.x());z0=Math.min(z0,c.z());x1=Math.max(x1,c.x());z1=Math.max(z1,c.z());if(c.hasWater()){water=c.waterY();count++;}}
return new Feature(id,kind,water,path,count,new Bounds(x0,z0,x1,z1));
}
private static long mix(long n){n=(n^(n>>>30))*0xbf58476d1ce4e5b9L;n=(n^(n>>>27))*0x94d049bb133111ebL;return n^(n>>>31);}
}
@@ -1,27 +1,39 @@
package fr.koka.sanctuary.worldgen; package fr.koka.sanctuary.worldgen;
import java.util.*;
import fr.koka.sanctuary.worldgen.PopulationHydrology.*;
public final class River30Smoke { public final class River30Smoke {
public static void main(String[] args) { public static void main(String[] args) {
for (int radius : new int[]{256,362,512}) { var cells = new LinkedHashMap<Long,Cell>();
PopulationHydrology.Sampler rock=(x,y,z)->Math.hypot(x,z)<radius*.82 && y>=60 && y<=230 ? 1 : -1; var path = List.of(new Point(0,0),new Point(-1,2),new Point(-2,3),new Point(-4,4));
var plan=River30.create(42,radius,rock); cells.put(key(0,0),new Cell(0,0,230,227,233,ShoreMaterial.GRAVEL,77,2));
var river=plan.features().stream().filter(f->f.kind()==PopulationHydrology.Kind.RIVER).findFirst().orElseThrow(); // Existing dry sediment is precisely what alpha30 incorrectly treated as an obstacle.
require(river.bounds().maxX()-river.bounds().minX()>radius || river.bounds().maxZ()-river.bounds().minZ()>radius,"Island-wide route"); for(int x=1;x<=4;x++)for(int z=-2;z<=2;z++)
require(plan.spills().size()>0,"A nearby edge can discharge into native void"); cells.put(key(x,z),new Cell(x,z,-1,230,234,ShoreMaterial.SAND,77,2));
for(var c:plan.cells()) { var original=PopulationHydrology.additional30(cells,
if(plan.isOutletCell(c)) {require(c.sedimentDepth()==0&&!c.hasWater(),"Outlet only removes its bank");continue;} List.of(new Feature(77,Kind.RIVER,230,path,1,new Bounds(0,-2,4,2))),List.of(),0,0);
require(Math.hypot(c.x(),c.z())>=49,"Arrival remains natural"); Sampler coast=(x,y,z)->x<=4 && y<=234 ? 1 : -1;
for(int y=c.bedY()-c.sedimentDepth()-1;y<=c.bedY();y++) require(rock.sample(c.x(),y,c.z())>0,"Bed consists of native rock"); var opened=BankOutlets30.openBanks(42,original,coast,2);
require(opened.spills().size()==1,"One short cut through an existing dry bank reaches the void");
require(opened.features().stream().filter(f->f.kind()==Kind.RIVER).toList().equals(original.features()),"Original curve and river identity unchanged");
require(opened.cells().stream().filter(Cell::hasWater).toList().equals(original.cells().stream().filter(Cell::hasWater).toList()),"No water source or new channel is added");
var fall=opened.spills().getFirst();
require(fall.outlet().x()==5 && fall.outlet().y()<fall.source().y(),"Five-block breach steps down for vanilla flow to reach its lip");
for(var cell:opened.cells())if(opened.isOutletCell(cell))
require(cell.sedimentDepth()==0&&!cell.hasWater(),"The breach only removes rock");
require(opened.cells().equals(BankOutlets30.openBanks(42,original,coast,2).cells()),"Deterministic openings");
require(BankOutlets30.openBanks(42,original,(x,y,z)->1,2).cells().equals(original.cells()),"Inland bank with no void remains intact");
for(int radius:new int[]{256,362,512}) {
int global=0,total=0,plateau=0;
for(int x=-radius;x<=radius;x+=8)for(int z=-radius;z<=radius;z+=8)if(Math.hypot(x,z)<radius) {
double w=Relief30.influence(42,x,z,radius);total++;
require(w>=0&&w<=1,"Weight is bounded");if(w==1)global++;if(w==0)plateau++;
} }
for(var spill:plan.spills()) require(Math.abs(spill.outlet().x()-spill.source().x())+Math.abs(spill.outlet().z()-spill.source().z())<=5,"Bank cut at most five blocks"); require(global>total*.85&&plateau>0&&plateau<total*.08,"Global noise covers most of the island, with one small plateau area");
require(plan.cells().equals(River30.create(42,radius,rock).cells()),"Immutable deterministic plan");
} }
for(long seed:new long[]{0,42,2026,-7228211907433324401L}) { System.out.println("River30.1: original curves and water retained, dry-bank breach, real downward lip, determinism, inland retention and predominantly global relief passed.");
int untouched=0;
for(int x=-350;x<=350;x+=10)for(int z=-350;z<=350;z+=10)if(Math.hypot(x,z)<350 && Relief30.influence(seed,x,z,362)==0)untouched++;
require(untouched>1000,"Wide undeformed regions remain between the three deformation areas");
}
System.out.println("River30: island-scale supported channels, five-block open banks, natural arrival, deterministic plans and separated relief regions passed.");
} }
static long key(int x,int z){return ((long)x<<32)^(z&0xffffffffL);}
static void require(boolean value,String message){if(!value)throw new AssertionError(message);} static void require(boolean value,String message){if(!value)throw new AssertionError(message);}
} }
+1 -1
View File
@@ -1,6 +1,6 @@
name = "Sanctuary" name = "Sanctuary"
author = "KOKA99CAB" author = "KOKA99CAB"
version = "0.1.0-alpha.30" version = "0.1.0-alpha.30.1"
pack-format = "packwiz:1.1.0" pack-format = "packwiz:1.1.0"
[index] [index]