diff --git a/internal/world/biome_lookup.go b/internal/world/biome_lookup.go index 736f4b3..3eddccf 100644 --- a/internal/world/biome_lookup.go +++ b/internal/world/biome_lookup.go @@ -62,6 +62,7 @@ func depthRange(v any) (worldgen.ClimateRange, bool) { var ( biomeTable *worldgen.ParameterTable biomeTableOnce sync.Once + biomeOrder []string ) // loadBiomeTable parses the embedded biome parameters once and returns the full @@ -76,18 +77,28 @@ func loadBiomeTable() *worldgen.ParameterTable { panic(fmt.Sprintf("world: parsing embedded biome_parameters.json: %v", err)) } params := make([]worldgen.BiomeParameter, 0, len(raw.Biomes)) + seen := make(map[string]bool) for _, e := range raw.Biomes { dp, ok := depthRange(e.Param.Depth) if !ok { continue // malformed depth; skip defensively } params = append(params, makeBiomeParameter(e, dp)) + if !seen[e.Biome] { + seen[e.Biome] = true + biomeOrder = append(biomeOrder, e.Biome) + } } biomeTable = worldgen.NewParameterTable(params) }) return biomeTable } +func possibleBiomeOrder() []string { + loadBiomeTable() + return biomeOrder +} + // makeBiomeParameter converts a raw JSON entry into a BiomeParameter, mapping // the [min,max] ranges to quantized ClimateRanges. depth is a ClimateRange // (exact range for scalar depths, explicit range for cave biomes). diff --git a/internal/world/feature_sorter_test.go b/internal/world/feature_sorter_test.go new file mode 100644 index 0000000..4feb09f --- /dev/null +++ b/internal/world/feature_sorter_test.go @@ -0,0 +1,28 @@ +package world + +import ( + "slices" + "testing" + + "regionio/internal/worldgen" +) + +func TestOverworldFeatureStepsBuildWithoutCycles(t *testing.T) { + set, err := worldgen.LoadFeatureSet() + if err != nil { + t.Fatal(err) + } + steps, err := set.FeatureSteps(possibleBiomeOrder()) + if err != nil { + t.Fatal(err) + } + if len(steps) != 11 { + t.Fatalf("feature steps = %d, want 11", len(steps)) + } + ores := steps[undergroundOresStage] + for _, name := range []string{"minecraft:ore_clay", "minecraft:ore_diamond", "minecraft:ore_tuff"} { + if !slices.Contains(ores, name) { + t.Errorf("underground ores missing %s", name) + } + } +} diff --git a/internal/worldgen/features.go b/internal/worldgen/features.go index a0b3dde..7dc4d5c 100644 --- a/internal/worldgen/features.go +++ b/internal/worldgen/features.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "path" + "sort" "strings" "sync" ) @@ -22,6 +23,11 @@ type FeatureSet struct { BlockTags map[string][]string } +type IndexedFeature struct { + Name string + Index int +} + type ConfiguredFeature struct { Type string Config json.RawMessage @@ -210,6 +216,105 @@ func LoadFeatureSet() (*FeatureSet, error) { return featureSet, featureSetErr } +// FeatureSteps mirrors FeatureSorter.buildFeaturesPerStep. biomeOrder must be +// BiomeSource.possibleBiomes encounter order because it assigns the stable +// identity used to break otherwise unconstrained graph ties. +func (s *FeatureSet) FeatureSteps(biomeOrder []string) ([][]string, error) { + type node struct { + stage, identity int + name string + } + identities := make(map[string]int) + nodes := make(map[[2]int]node) + edges := make(map[[2]int]map[[2]int]bool) + maxStages := 0 + for _, biomeName := range biomeOrder { + biome, ok := s.Biomes[biomeName] + if !ok { + continue + } + if len(biome.Features) > maxStages { + maxStages = len(biome.Features) + } + var previous [2]int + hasPrevious := false + for stage, names := range biome.Features { + for _, name := range names { + identity, ok := identities[name] + if !ok { + identity = len(identities) + identities[name] = identity + } + key := [2]int{stage, identity} + nodes[key] = node{stage: stage, identity: identity, name: name} + if hasPrevious { + if edges[previous] == nil { + edges[previous] = make(map[[2]int]bool) + } + edges[previous][key] = true + } + previous, hasPrevious = key, true + } + } + } + less := func(a, b [2]int) bool { + return a[0] < b[0] || a[0] == b[0] && a[1] < b[1] + } + keys := make([][2]int, 0, len(nodes)) + for key := range nodes { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { return less(keys[i], keys[j]) }) + visited := make(map[[2]int]bool) + visiting := make(map[[2]int]bool) + ordered := make([][2]int, 0, len(keys)) + var visit func([2]int) error + visit = func(key [2]int) error { + if visited[key] { + return nil + } + if visiting[key] { + return fmt.Errorf("worldgen: feature order cycle") + } + visiting[key] = true + children := make([][2]int, 0, len(edges[key])) + for child := range edges[key] { + children = append(children, child) + } + sort.Slice(children, func(i, j int) bool { return less(children[i], children[j]) }) + for _, child := range children { + if err := visit(child); err != nil { + return err + } + } + delete(visiting, key) + visited[key] = true + ordered = append(ordered, key) + return nil + } + for _, key := range keys { + if err := visit(key); err != nil { + return nil, err + } + } + steps := make([][]string, maxStages) + for i := len(ordered) - 1; i >= 0; i-- { + n := nodes[ordered[i]] + steps[n.stage] = append(steps[n.stage], n.name) + } + return steps, nil +} + +func IndexedFeatures(step []string, wanted map[string]bool) []IndexedFeature { + result := make([]IndexedFeature, 0, len(wanted)) + for index, name := range step { + if wanted[name] { + result = append(result, IndexedFeature{Name: name, Index: index}) + } + } + return result +} + func (s *FeatureSet) Ore(name string) (OreFeatureConfig, error) { configured, ok := s.Configured[name] if !ok || configured.Type != "minecraft:ore" { diff --git a/internal/worldgen/features_test.go b/internal/worldgen/features_test.go index 20286a2..e7832ff 100644 --- a/internal/worldgen/features_test.go +++ b/internal/worldgen/features_test.go @@ -1,6 +1,9 @@ package worldgen -import "testing" +import ( + "reflect" + "testing" +) func TestFeatureDatapackLoadsAndLinks(t *testing.T) { set, err := LoadFeatureSet() @@ -42,3 +45,38 @@ func TestFeatureDatapackLoadsAndLinks(t *testing.T) { t.Fatalf("spring lava placement = %+v, err=%v", lavaPlan, err) } } + +func TestFeatureStepsAgainstVanillaRuntimeVectors(t *testing.T) { + set := &FeatureSet{Biomes: map[string]BiomeGeneration{ + "a": {Features: [][]string{{"f1"}, {"f3", "f4"}}}, + "b": {Features: [][]string{{"f2", "f1"}, {"f5", "f4"}}}, + }} + for _, test := range []struct { + name string + order []string + want [][]string + }{ + {"ab", []string{"a", "b"}, [][]string{{"f2", "f1"}, {"f5", "f3", "f4"}}}, + {"ba", []string{"b", "a"}, [][]string{{"f2", "f1"}, {"f3", "f5", "f4"}}}, + } { + t.Run(test.name, func(t *testing.T) { + got, err := set.FeatureSteps(test.order) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, test.want) { + t.Fatalf("steps = %v, want %v", got, test.want) + } + }) + } +} + +func TestFeatureStepsRejectsCycles(t *testing.T) { + set := &FeatureSet{Biomes: map[string]BiomeGeneration{ + "a": {Features: [][]string{{"f1", "f2"}}}, + "b": {Features: [][]string{{"f2", "f1"}}}, + }} + if _, err := set.FeatureSteps([]string{"a", "b"}); err == nil { + t.Fatal("cyclic feature order succeeded") + } +} diff --git a/tools/VanillaFeatureSorterVectors.java b/tools/VanillaFeatureSorterVectors.java new file mode 100644 index 0000000..e501924 --- /dev/null +++ b/tools/VanillaFeatureSorterVectors.java @@ -0,0 +1,81 @@ +import java.lang.reflect.Constructor; +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import net.minecraft.SharedConstants; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderSet; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.level.biome.BiomeGenerationSettings; +import net.minecraft.world.level.biome.FeatureSorter; +import net.minecraft.world.level.levelgen.feature.ConfiguredFeature; +import net.minecraft.world.level.levelgen.feature.Feature; +import net.minecraft.world.level.levelgen.feature.configurations.NoneFeatureConfiguration; +import net.minecraft.world.level.levelgen.placement.CountPlacement; +import net.minecraft.world.level.levelgen.placement.PlacedFeature; + +// Emits FeatureSorter vectors from the official 26.1.2 runtime. The graph is +// synthetic so this helper verifies ordering and identity without booting a +// world or depending on registry datapack loading. +public final class VanillaFeatureSorterVectors { + private static final Map NAMES = new IdentityHashMap<>(); + + public static void main(String[] args) throws Exception { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + + PlacedFeature f1 = feature("f1", 1); + PlacedFeature f2 = feature("f2", 2); + PlacedFeature f3 = feature("f3", 3); + PlacedFeature f4 = feature("f4", 4); + PlacedFeature f5 = feature("f5", 5); + + BiomeGenerationSettings a = settings( + List.of(List.of(f1), List.of(f3, f4))); + BiomeGenerationSettings b = settings( + List.of(List.of(f2, f1), List.of(f5, f4))); + + dump("ab", List.of(a, b)); + dump("ba", List.of(b, a)); + } + + private static PlacedFeature feature(String name, int count) { + ConfiguredFeature> configured = + new ConfiguredFeature<>(Feature.NO_OP, NoneFeatureConfiguration.INSTANCE); + PlacedFeature placed = new PlacedFeature(Holder.direct(configured), List.of(CountPlacement.of(count))); + NAMES.put(placed, name); + return placed; + } + + @SuppressWarnings("unchecked") + private static BiomeGenerationSettings settings(List> stages) throws Exception { + List> holders = new ArrayList<>(); + for (List stage : stages) { + Holder[] values = stage.stream().map(Holder::direct).toArray(Holder[]::new); + holders.add(HolderSet.direct(values)); + } + Constructor constructor = BiomeGenerationSettings.class + .getDeclaredConstructor(HolderSet.class, List.class); + constructor.setAccessible(true); + return constructor.newInstance(HolderSet.empty(), holders); + } + + private static void dump(String label, List biomes) { + Function>> stages = + BiomeGenerationSettings::features; + List steps = + FeatureSorter.buildFeaturesPerStep(biomes, stages, true); + for (int stage = 0; stage < steps.size(); stage++) { + FeatureSorter.StepFeatureData step = steps.get(stage); + List values = new ArrayList<>(); + for (int index = 0; index < step.features().size(); index++) { + PlacedFeature feature = step.features().get(index); + values.add(NAMES.get(feature) + "@" + step.indexMapping().applyAsInt(feature)); + } + System.out.println(label + ".stage" + stage + "=" + String.join(",", values)); + } + } +}