diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/StructureTemplateEncoder.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/StructureTemplateEncoder.java index c5e63379a..89575ac1d 100644 --- a/core/src/main/java/art/arcane/iris/pack/datapack/export/StructureTemplateEncoder.java +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/StructureTemplateEncoder.java @@ -34,25 +34,39 @@ public final class StructureTemplateEncoder { .thenComparingInt(TemplateBlock::z); public static TemplateVolume volume(IrisObject object, String resource, List diagnostics) { + List inside = new ArrayList<>(); + int outside = 0; + String firstOutside = null; + for (TemplateBlock block : blocks(object, resource, diagnostics)) { + if (block.x() < 0 || block.y() < 0 || block.z() < 0 + || block.x() >= object.getW() || block.y() >= object.getH() || block.z() >= object.getD()) { + outside++; + if (firstOutside == null) { + firstOutside = block.x() + "," + block.y() + "," + block.z(); + } + continue; + } + inside.add(block); + } + if (outside > 0) { + diagnostics.add(new ExportDiagnostic(ExportDiagnostic.Severity.WARNING, ExportLimit.OBJECT_BOUNDS, resource, + outside + " blocks outside the " + object.getW() + "x" + object.getH() + "x" + object.getD() + + " object size are left out of the template, first at " + firstOutside)); + } + return new TemplateVolume(object.getW(), object.getH(), object.getD(), inside); + } + + public static List blocks(IrisObject object, String resource, List diagnostics) { List blocks = new ArrayList<>(); Vector3i center = object.getCenter(); int droppedTiles = 0; String firstDropped = null; - int outside = 0; - String firstOutside = null; Set customBlocks = new TreeSet<>(); for (Map.Entry entry : object.getBlocks()) { IrisBlockVector signed = entry.getKey(); int x = signed.getBlockX() + center.getX(); int y = signed.getBlockY() + center.getY(); int z = signed.getBlockZ() + center.getZ(); - if (x < 0 || y < 0 || z < 0 || x >= object.getW() || y >= object.getH() || z >= object.getD()) { - outside++; - if (firstOutside == null) { - firstOutside = x + "," + y + "," + z; - } - continue; - } VanillaBlockState state; try { state = VanillaBlockState.parse(entry.getValue().key()); @@ -79,17 +93,12 @@ public final class StructureTemplateEncoder { diagnostics.add(new ExportDiagnostic(ExportDiagnostic.Severity.WARNING, ExportLimit.CUSTOM_BLOCK, resource, "Blocks outside Minecraft are left out of the template: " + String.join(", ", customBlocks))); } - if (outside > 0) { - diagnostics.add(new ExportDiagnostic(ExportDiagnostic.Severity.WARNING, ExportLimit.OBJECT_BOUNDS, resource, - outside + " blocks outside the " + object.getW() + "x" + object.getH() + "x" + object.getD() - + " object size are left out of the template, first at " + firstOutside)); - } if (droppedTiles > 0) { diagnostics.add(new ExportDiagnostic(ExportDiagnostic.Severity.WARNING, ExportLimit.TILE_ENTITY, resource, droppedTiles + " tile entities have no vanilla block entity form and export as plain blocks, first " + firstDropped)); } - return new TemplateVolume(object.getW(), object.getH(), object.getD(), blocks); + return blocks; } public byte[] encode(IrisObject object, List extraBlocks, DataVersion target) throws IOException { diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/VanillaBlockState.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/VanillaBlockState.java index c691a16a1..d23de747b 100644 --- a/core/src/main/java/art/arcane/iris/pack/datapack/export/VanillaBlockState.java +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/VanillaBlockState.java @@ -58,6 +58,17 @@ public record VanillaBlockState(String name, Map properties) { return new VanillaBlockState(name, properties); } + public VanillaBlockState waterlogged(boolean submerged) { + String value = Boolean.toString(submerged); + String current = properties.get("waterlogged"); + if (current == null || current.equals(value)) { + return this; + } + Map changed = new LinkedHashMap<>(properties); + changed.put("waterlogged", value); + return new VanillaBlockState(name, changed); + } + public String canonical() { if (properties.isEmpty()) { return name; diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/AquiferCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/AquiferCompiler.java index 4fc680b7a..90a057ed9 100644 --- a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/AquiferCompiler.java +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/AquiferCompiler.java @@ -18,6 +18,12 @@ public final class AquiferCompiler { private static final int FLUID_BAND_CENTER = 20; private static final double SPREAD_LEVEL_SCALE = 10.0; private static final double LEVEL_ROUNDING_MARGIN = 0.5; + private static final int OPEN_WATER_DEPTH = 8; + private static final int OPEN_WATER_HEADROOM = 8; + private static final int OPEN_WATER_REACH = 32; + private static final int OPEN_WATER_SHORE = 8; + private static final int OPEN_WATER_ESTIMATE_SLACK = 40; + private static final Field OPEN_WATER_LIFT = new Field.Const(2.0); private static final Field DRY = new Field.Const(-1.0); private static final Field PARTIAL = new Field.Const(0.6); private static final Field LOW_BARRIER = new Field.Const(-1.0); @@ -49,11 +55,21 @@ public final class AquiferCompiler { } boolean wet = any(selection, IrisCaveProfile::isAllowFluid); if (!lavaExported && !wet) { - return dry; + return new AquiferDef(dry.barrier(), openWater(dry.floodedness()), dry.spread(), dry.lava(), dry.exclusion(), dry.surfaceLevel()); } Field spread = lavaExported ? lavaSpread(wet, dry.spread()) : dry.spread(); Field lava = lavaExported ? lavaBands() : NO_LAVA; - return new AquiferDef(LOW_BARRIER, floodedness(selection, lavaExported), spread, lava, NO_EXCLUSION, inputs.aquiferSurface()); + return new AquiferDef(LOW_BARRIER, openWater(floodedness(selection, lavaExported)), spread, lava, NO_EXCLUSION, inputs.aquiferSurface()); + } + + private Field openWater(Field caveFlood) { + Field surface = inputs.surface(); + Field nearFluid = new Field.RangeIf(blockY, inputs.fluidHeight() - OPEN_WATER_REACH, inputs.fluidHeight() + OPEN_WATER_HEADROOM + 1.0, INSIDE_BAND, DRY); + Field lowEstimate = new Field.Binary(Field.BinaryOp.SUB, new Field.Const(inputs.fluidHeight() + OPEN_WATER_ESTIMATE_SLACK), inputs.aquiferSurface()); + Field aboveFloor = new Field.Binary(Field.BinaryOp.ADD, new Field.Binary(Field.BinaryOp.SUB, blockY, surface), new Field.Const(OPEN_WATER_DEPTH)); + Field lowColumn = new Field.Binary(Field.BinaryOp.SUB, new Field.Const(inputs.fluidHeight() + OPEN_WATER_SHORE + LEVEL_ROUNDING_MARGIN), surface); + Field lift = new Field.Gate(aboveFloor, new Field.Gate(lowColumn, OPEN_WATER_LIFT)); + return new Field.Binary(Field.BinaryOp.ADD, caveFlood, new Field.Gate(nearFluid, new Field.Gate(lowEstimate, lift))); } private Field floodedness(CaveSelection selection, boolean lavaExported) { diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/DecoratorCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/DecoratorCompiler.java index 70952a305..7f051f2cd 100644 --- a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/DecoratorCompiler.java +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/DecoratorCompiler.java @@ -6,7 +6,6 @@ import art.arcane.iris.generation.decoration.IrisDecorationPart; import art.arcane.iris.generation.decoration.IrisDecorator; import art.arcane.iris.generation.noise.IrisGeneratorStyle; import art.arcane.iris.generation.noise.NoiseStyle; -import art.arcane.iris.generation.terrain.IrisSlopeClip; import art.arcane.iris.pack.datapack.export.VanillaBlockState; import art.arcane.iris.pack.datapack.export.compile.BlockStateResolver; import art.arcane.iris.pack.datapack.export.compile.ExportedBiomes; @@ -22,10 +21,12 @@ import com.google.gson.JsonObject; import java.util.ArrayList; import java.util.EnumMap; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.TreeMap; import java.util.TreeSet; public final class DecoratorCompiler { @@ -33,9 +34,14 @@ public final class DecoratorCompiler { private static final double CAVE_AIR_SHARE = 0.08; private static final int CAVE_SCAN_STEPS = 12; private static final int MAX_ATTEMPTS = 256; - private static final double SLOPE_CEILING = 10.0; + private static final int MAX_PATTERN_STATES = 64; + private static final VanillaBlockState AIR_STATE = new VanillaBlockState("minecraft:air", Map.of()); private static final Set AIR = Set.of("minecraft:air", "minecraft:cave_air", "minecraft:void_air"); - private static final String STYLE_MESSAGE = "Decorators gated or varied by a noise style place at the style's average density without its pattern"; + private static final Set AQUATIC = Set.of("minecraft:seagrass", "minecraft:tall_seagrass", "minecraft:kelp", + "minecraft:kelp_plant", "minecraft:sea_pickle"); + private static final String STYLE_MESSAGE = "Decorators gated by a noise style keep the style's coverage, with its pattern fitted to Minecraft noise"; + private static final String STYLE_STACK_MESSAGE = "Stacked, forced, underwater, and ceiling decorators gated by a noise style keep the style's coverage without its pattern"; + private static final String VARIANCE_MESSAGE = "Decorator palettes varied by a noise style pick their blocks at random"; private static final String STACK_MESSAGE = "Decorator stacks scaled to cave height use their fixed stack range"; private static final String CAVE_MESSAGE = "Cave ceiling decorators place at an estimated density because features cannot enumerate every cave ceiling"; @@ -44,6 +50,8 @@ public final class DecoratorCompiler { private final BlockStateResolver blocks; private final FeatureCatalog catalog; private final WorldgenSyntax syntax; + private final Map coverages = new HashMap<>(); + private final Map patterns = new HashMap<>(); public DecoratorCompiler(PackCompileContext context, ExportedBiomes biomes, BlockStateResolver blocks, FeatureCatalog catalog) { this.context = context; @@ -115,7 +123,7 @@ public final class DecoratorCompiler { List candidates = bucket.getValue(); double[] chances = new double[candidates.size()]; for (int index = 0; index < chances.length; index++) { - chances[index] = Math.max(0.0, Math.min(1.0, candidates.get(index).decorator().getChance())); + chances[index] = candidates.get(index).coverage().share(); } double[] shares = columnShares(chances); for (int index = 0; index < shares.length; index++) { @@ -129,9 +137,12 @@ public final class DecoratorCompiler { } if (bucket.getKey() == IrisDecorationPart.CEILING) { report(resource, ExportLimit.CAVE_DECORATOR, CAVE_MESSAGE); + if (candidate.coverage().patterned()) { + report(resource, ExportLimit.DECORATOR_STYLE, STYLE_STACK_MESSAGE); + } context.features().add(biomeId, GenerationStep.UNDERGROUND_DECORATION, ceiling(candidate, shares[index])); } else { - context.features().add(biomeId, GenerationStep.VEGETAL_DECORATION, column(bucket.getKey(), candidate, sequential[index])); + context.features().add(biomeId, GenerationStep.VEGETAL_DECORATION, column(bucket.getKey(), candidate, sequential[index], resource)); } } } @@ -141,24 +152,19 @@ public final class DecoratorCompiler { List entries = new ArrayList<>(); int airWeight = 0; int total = 0; - for (IrisBlockData block : decorator.getPalette()) { - Optional state = blocks.resolve(block, resource); - if (state.isEmpty()) { - continue; - } - int weight = Math.max(1, block.getWeight()); - total += weight; - if (AIR.contains(state.get().name())) { - airWeight += weight; + for (WeightedState entry : weighted(decorator.getPalette(), decorator.getVariance(), resource)) { + total += entry.weight(); + if (AIR.contains(entry.state().name())) { + airWeight += entry.weight(); } else { - entries.add(new WeightedState(state.get(), weight)); + entries.add(entry); } } if (total == 0) { return Optional.empty(); } - if (!isStatic(decorator.getStyle()) || (entries.size() > 1 && !isStatic(decorator.getVariance()))) { - report(resource, ExportLimit.DECORATOR_STYLE, STYLE_MESSAGE); + if (entries.size() > 1 && !isStatic(decorator.getVariance())) { + report(resource, ExportLimit.DECORATOR_STYLE, VARIANCE_MESSAGE); } if (decorator.isScaleStack()) { report(resource, ExportLimit.SCALE_STACK, STACK_MESSAGE); @@ -167,35 +173,64 @@ public final class DecoratorCompiler { report(resource, ExportLimit.SLOPE, RepaintFeatureCompiler.SLOPE_MESSAGE); } List top = new ArrayList<>(); - for (IrisBlockData block : decorator.getTopPalette()) { - Optional state = blocks.resolve(block, resource); - if (state.isPresent() && !AIR.contains(state.get().name())) { - top.add(new WeightedState(state.get(), Math.max(1, block.getWeight()))); + for (WeightedState entry : weighted(decorator.getTopPalette(), decorator.getVariance(), resource)) { + if (!AIR.contains(entry.state().name())) { + top.add(entry); } } VanillaBlockState force = decorator.getForceBlock() == null ? null : blocks.resolve(decorator.getForceBlock(), resource).orElse(null); - return Optional.of(new Candidate(decorator, entries, top, (double) airWeight / total, force, names(decorator.getWhitelist(), resource), - names(decorator.getBlacklist(), resource))); + return Optional.of(new Candidate(decorator, entries, top, (double) airWeight / total, force, aquatic(entries, top), + names(decorator.getWhitelist(), resource), names(decorator.getBlacklist(), resource), coverage(decorator))); } - private String column(IrisDecorationPart part, Candidate candidate, double sequential) { - String featureId = feature(candidate, false); + private List weighted(List palette, IrisGeneratorStyle variance, String resource) { + List resolved = new ArrayList<>(palette.size()); + int count = 0; + for (IrisBlockData block : palette) { + Optional state = blocks.resolve(block, resource); + if (state.isPresent()) { + int weight = Math.max(1, block.getWeight()); + resolved.add(new WeightedState(state.get(), weight)); + count += weight; + } + } + if (count <= 1 || !isStatic(variance)) { + return resolved; + } + List rounded = new ArrayList<>(resolved.size()); + int start = 0; + for (WeightedState entry : resolved) { + int end = start + entry.weight(); + rounded.add(new WeightedState(entry.state(), 2 * entry.weight() - (start == 0 ? 1 : 0) - (end == count ? 1 : 0))); + start = end; + } + return rounded; + } + + private String column(IrisDecorationPart part, Candidate candidate, double sequential, String resource) { + IrisDecorationPart target = candidate.aquatic() && part == IrisDecorationPart.NONE ? IrisDecorationPart.SEA_FLOOR : part; + boolean underwater = target == IrisDecorationPart.SEA_FLOOR; + boolean patterned = candidate.coverage().patterned() && !underwater && single(candidate); + if (candidate.coverage().patterned()) { + report(resource, ExportLimit.DECORATOR_STYLE, patterned ? STYLE_MESSAGE : STYLE_STACK_MESSAGE); + } + String featureId = patterned ? patterned(candidate) : feature(candidate, false, underwater); + double chance = patterned ? Math.min(1.0, sequential / candidate.coverage().share()) : sequential; JsonArray placement = new JsonArray(); - String heightmap = part == IrisDecorationPart.SEA_FLOOR ? Placements.OCEAN_FLOOR : Placements.WORLD_SURFACE; for (JsonObject modifier : Placements.everyColumn()) { placement.add(modifier); } - placement.add(Placements.heightmap(heightmap)); - if (sequential < 1.0) { - placement.add(Placements.randomChance(sequential)); + placement.add(Placements.heightmap(underwater ? Placements.OCEAN_FLOOR : Placements.WORLD_SURFACE)); + if (chance < 1.0) { + placement.add(Placements.randomChance(chance)); } placement.add(Placements.biome()); - placement.add(Placements.filter(Placements.allOf(predicates(part, candidate)))); + placement.add(Placements.filter(Placements.allOf(predicates(target, candidate)))); return catalog.placed(name(candidate), featureId, placement); } private String ceiling(Candidate candidate, double share) { - String featureId = feature(candidate, true); + String featureId = feature(candidate, true, false); JsonArray placement = new JsonArray(); placement.add(Placements.count(Placements.meanCount(Math.min(MAX_ATTEMPTS, COLUMNS * share / CAVE_AIR_SHARE)))); placement.add(Placements.inSquare()); @@ -210,13 +245,19 @@ public final class DecoratorCompiler { private List predicates(IrisDecorationPart part, Candidate candidate) { List predicates = new ArrayList<>(); switch (part) { - case SEA_FLOOR -> predicates.add(Placements.water(0, 0, 0)); + case SEA_FLOOR -> { + predicates.add(Placements.water(0, 0, 0)); + if (tall(candidate)) { + predicates.add(Placements.water(0, 1, 0)); + } + } case SEA_SURFACE -> { predicates.add(Placements.air(0, 0, 0)); predicates.add(Placements.water(0, -1, 0)); } case SHORE_LINE -> { predicates.add(Placements.air(0, 0, 0)); + predicates.add(Placements.solid(0, -1, 0)); predicates.add(Placements.anyOf(List.of(Placements.water(1, -1, 0), Placements.water(-1, -1, 0), Placements.water(0, -1, 1), Placements.water(0, -1, -1)))); } @@ -231,40 +272,35 @@ public final class DecoratorCompiler { if (!candidate.blacklist().isEmpty()) { predicates.add(Placements.not(Placements.matchingBlocks(candidate.blacklist(), 0, -1, 0))); } - IrisSlopeClip slope = candidate.decorator().getSlopeCondition(); - if (slope != null && !slope.isDefault()) { - if (slope.getMinimumSlope() > 0) { - predicates.add(Placements.steeperThan(rise(slope.getMinimumSlope()), -1)); - } - if (slope.getMaximumSlope() < SLOPE_CEILING) { - predicates.add(Placements.not(Placements.steeperThan(rise(slope.getMaximumSlope()) + 1, -1))); - } - } + predicates.addAll(Placements.slopeLimits(candidate.decorator().getSlopeCondition())); return predicates; } - private String feature(Candidate candidate, boolean hanging) { + private String feature(Candidate candidate, boolean hanging, boolean underwater) { IrisDecorator decorator = candidate.decorator(); - int stackMax = Math.max(1, Math.min(decorator.getStackMax(), Math.max(1, decorator.getAbsoluteMaxStack()))); + int stackMax = stackMax(decorator); int stackMin = Math.max(1, Math.min(decorator.getStackMin(), stackMax)); String name = name(candidate); + List entries = waterlogged(candidate.entries(), underwater); + List top = waterlogged(candidate.top(), underwater); String main; - if (!hanging && stackMax == 1 && candidate.top().isEmpty()) { + boolean tall = !hanging && tall(candidate); + if (tall || !hanging && stackMax == 1 && top.isEmpty()) { JsonObject config = new JsonObject(); - config.add("to_place", syntax.provider(candidate.entries())); + config.add("to_place", syntax.provider(tall ? lowerHalves(entries) : entries)); main = catalog.feature(name, "minecraft:simple_block", config); } else { JsonArray layers = new JsonArray(2); - if (candidate.top().isEmpty()) { - layers.add(layer(Placements.intRange(stackMin, stackMax), candidate.entries())); + if (top.isEmpty()) { + layers.add(layer(Placements.intRange(stackMin, stackMax), entries)); } else { - layers.add(layer(Placements.intRange(stackMin - 1, stackMax - 1), candidate.entries())); - layers.add(layer(Placements.intRange(1, 1), candidate.top())); + layers.add(layer(Placements.intRange(stackMin - 1, stackMax - 1), entries)); + layers.add(layer(Placements.intRange(1, 1), top)); } JsonObject config = new JsonObject(); config.add("layers", layers); config.addProperty("direction", hanging ? "down" : "up"); - config.add("allowed_placement", Placements.air(0, 0, 0)); + config.add("allowed_placement", underwater ? Placements.water(0, 0, 0) : Placements.air(0, 0, 0)); config.addProperty("prioritize_tip", true); main = catalog.feature(name, "minecraft:block_column", config); } @@ -284,6 +320,112 @@ public final class DecoratorCompiler { return catalog.feature(name + "_forced", "minecraft:sequence", sequence); } + private String patterned(Candidate candidate) { + IrisDecorator decorator = candidate.decorator(); + DecoratorCoverage.Pattern pattern = patterns.computeIfAbsent(candidate.coverage(), coverage -> DecoratorCoverage.pattern(coverage, syntax.target())); + String salt = name(candidate) + "|" + decorator.getStyle().getStyle() + "|" + decorator.getStyle().getZoom() + "|" + decorator.getChance(); + JsonObject provider = new JsonObject(); + provider.addProperty("type", "minecraft:noise_threshold"); + provider.addProperty("seed", (long) salt.hashCode()); + provider.add("noise", patternNoise()); + provider.addProperty("scale", (float) pattern.scale()); + provider.addProperty("threshold", (float) pattern.threshold()); + provider.addProperty("high_chance", 0.0f); + provider.add("default_state", syntax.blockState(AIR_STATE)); + provider.add("low_states", expanded(waterlogged(candidate.entries(), false))); + JsonArray high = new JsonArray(1); + high.add(syntax.blockState(AIR_STATE)); + provider.add("high_states", high); + JsonObject config = new JsonObject(); + config.add("to_place", provider); + return catalog.feature(name(candidate), "minecraft:simple_block", config); + } + + private JsonArray expanded(List entries) { + int divisor = 0; + int total = 0; + for (WeightedState entry : entries) { + divisor = gcd(divisor, entry.weight()); + total += entry.weight(); + } + double shrink = Math.min(1.0, (double) MAX_PATTERN_STATES * divisor / total); + JsonArray states = new JsonArray(); + for (WeightedState entry : entries) { + long copies = Math.max(1L, Math.round(entry.weight() / (double) divisor * shrink)); + for (long copy = 0; copy < copies; copy++) { + states.add(syntax.blockState(entry.state())); + } + } + return states; + } + + private static JsonObject patternNoise() { + JsonArray amplitudes = new JsonArray(DecoratorCoverage.PATTERN_AMPLITUDES.size()); + for (double amplitude : DecoratorCoverage.PATTERN_AMPLITUDES) { + amplitudes.add(amplitude); + } + JsonObject noise = new JsonObject(); + noise.addProperty("base_octave", DecoratorCoverage.PATTERN_OCTAVE); + noise.addProperty("octave_count", DecoratorCoverage.PATTERN_AMPLITUDES.size()); + noise.add("amplitude_modifiers", amplitudes); + noise.addProperty("base_amplitude", 1.0); + noise.addProperty("normalize", false); + return noise; + } + + private DecoratorCoverage.Coverage coverage(IrisDecorator decorator) { + double chance = Math.max(0.0, Math.min(1.0, decorator.getChance())); + if (isStatic(decorator.getStyle())) { + return new DecoratorCoverage.Coverage(chance, 0.0); + } + return coverages.computeIfAbsent(new CoverageKey(decorator.getStyle(), chance), + key -> DecoratorCoverage.measure(key.style(), key.chance(), context.data())); + } + + private static boolean single(Candidate candidate) { + IrisDecorator decorator = candidate.decorator(); + return Math.min(decorator.getStackMax(), decorator.getAbsoluteMaxStack()) <= 1 && candidate.top().isEmpty() && candidate.force() == null; + } + + private static int gcd(int first, int second) { + return second == 0 ? first : gcd(second, first % second); + } + + private static List waterlogged(List entries, boolean underwater) { + List placed = new ArrayList<>(entries.size()); + for (WeightedState entry : entries) { + placed.add(new WeightedState(entry.state().waterlogged(underwater), entry.weight())); + } + return placed; + } + + private static int stackMax(IrisDecorator decorator) { + return Math.max(1, Math.min(decorator.getStackMax(), Math.max(1, decorator.getAbsoluteMaxStack()))); + } + + private static boolean tall(Candidate candidate) { + if (stackMax(candidate.decorator()) != 1) { + return false; + } + for (WeightedState entry : candidate.entries()) { + String half = entry.state().properties().get("half"); + if (!"lower".equals(half) && !"upper".equals(half)) { + return false; + } + } + return true; + } + + private static List lowerHalves(List entries) { + List lower = new ArrayList<>(entries.size()); + for (WeightedState entry : entries) { + Map properties = new TreeMap<>(entry.state().properties()); + properties.put("half", "lower"); + lower.add(new WeightedState(new VanillaBlockState(entry.state().name(), properties), entry.weight())); + } + return lower; + } + private JsonObject layer(JsonElement height, List entries) { JsonObject layer = new JsonObject(); layer.add("height", height); @@ -321,11 +463,36 @@ public final class DecoratorCompiler { return style == null || style.getStyle() == null || style.getStyle() == NoiseStyle.STATIC; } - private static int rise(double slope) { - return Math.max(1, (int) Math.round(slope)); + private static boolean aquatic(List entries, List top) { + if (entries.isEmpty()) { + return false; + } + for (WeightedState entry : entries) { + if (!aquatic(entry.state())) { + return false; + } + } + for (WeightedState entry : top) { + if (!aquatic(entry.state())) { + return false; + } + } + return true; + } + + private static boolean aquatic(VanillaBlockState state) { + String name = state.name(); + if ("true".equals(state.properties().get("waterlogged")) || AQUATIC.contains(name)) { + return true; + } + return name.startsWith("minecraft:") && name.contains("_coral") && !name.startsWith("minecraft:dead_"); } private record Candidate(IrisDecorator decorator, List entries, List top, double airShare, - VanillaBlockState force, List whitelist, List blacklist) { + VanillaBlockState force, boolean aquatic, List whitelist, List blacklist, + DecoratorCoverage.Coverage coverage) { + } + + private record CoverageKey(IrisGeneratorStyle style, double chance) { } } diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/DecoratorCoverage.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/DecoratorCoverage.java new file mode 100644 index 000000000..76174827b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/DecoratorCoverage.java @@ -0,0 +1,113 @@ +package art.arcane.iris.pack.datapack.export.compile.decoration; + +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.compile.noise.VanillaNoiseModel; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.noise.CNG; + +import java.util.Arrays; +import java.util.List; + +final class DecoratorCoverage { + static final int PATTERN_OCTAVE = 0; + static final List PATTERN_AMPLITUDES = List.of(1.0); + private static final long STYLE_SEED = 0x0DEC_0A7EL; + private static final String PATTERN_NOISE = "iris_export:decorator_pattern"; + private static final int GRID = 128; + private static final double STEP_X = 211.0; + private static final double STEP_Z = 157.0; + private static final int[] LAGS = {1, 2, 4, 8, 16, 32, 64, 128}; + private static final double HALF_EXCESS = 0.5; + private static final double MIN_PATCH = 2.0; + private static final int MIN_HITS = 64; + private static final double VANILLA_SCALE = 1.0 / 64.0; + private static final double VANILLA_Y = 64.0; + private static final double MAX_THRESHOLD = 1.0; + + private DecoratorCoverage() { + } + + static Coverage measure(IrisGeneratorStyle style, double chance, IrisData data) { + CNG generator = style.createNoCache(new RNG(STYLE_SEED), data); + double zoom = style.getZoom(); + return measure((x, z) -> generator.fitDouble(0.0, 1.0, x / zoom, z / zoom) <= chance); + } + + static Pattern pattern(Coverage coverage, DataVersion target) { + VanillaNoiseModel model = VanillaNoiseModel.create(PATTERN_NOISE, PATTERN_OCTAVE, PATTERN_AMPLITUDES, 0L, target); + double[] sorted = new double[GRID * GRID]; + int index = 0; + for (int i = 0; i < GRID; i++) { + for (int j = 0; j < GRID; j++) { + sorted[index++] = model.sample(i * STEP_X * VANILLA_SCALE, VANILLA_Y * VANILLA_SCALE, j * STEP_Z * VANILLA_SCALE); + } + } + Arrays.sort(sorted); + double position = coverage.share() * (sorted.length - 1); + int lower = (int) Math.floor(position); + int upper = Math.min(sorted.length - 1, lower + 1); + double threshold = sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower); + double clamped = Math.max(-MAX_THRESHOLD, Math.min(MAX_THRESHOLD, threshold)); + Coverage vanilla = measure((x, z) -> model.sample(x * VANILLA_SCALE, VANILLA_Y * VANILLA_SCALE, z * VANILLA_SCALE) < clamped); + return new Pattern(clamped, VANILLA_SCALE * vanilla.patch() / coverage.patch()); + } + + private static Coverage measure(Gate gate) { + int hits = 0; + int[] both = new int[LAGS.length]; + for (int i = 0; i < GRID; i++) { + for (int j = 0; j < GRID; j++) { + double x = i * STEP_X; + double z = j * STEP_Z; + if (!gate.passes(x, z)) { + continue; + } + hits++; + for (int lag = 0; lag < LAGS.length; lag++) { + both[lag] += gate.passes(x + LAGS[lag], z) ? 1 : 0; + both[lag] += gate.passes(x, z + LAGS[lag]) ? 1 : 0; + } + } + } + double share = (double) hits / (GRID * GRID); + if (hits < MIN_HITS || share >= 1.0) { + return new Coverage(share, 0.0); + } + return new Coverage(share, patch(both, hits, share)); + } + + private static double patch(int[] both, int hits, double share) { + double previousLag = 0.0; + double previousExcess = 1.0; + for (int lag = 0; lag < LAGS.length; lag++) { + double excess = ((double) both[lag] / (2.0 * hits) - share) / (1.0 - share); + if (excess < HALF_EXCESS) { + double fraction = (previousExcess - HALF_EXCESS) / (previousExcess - excess); + if (previousLag == 0.0) { + return LAGS[lag] * fraction; + } + double low = Math.log(previousLag); + return Math.exp(low + (Math.log(LAGS[lag]) - low) * fraction); + } + previousLag = LAGS[lag]; + previousExcess = excess; + } + return LAGS[LAGS.length - 1]; + } + + record Coverage(double share, double patch) { + boolean patterned() { + return share > 0.0 && share < 1.0 && patch >= MIN_PATCH; + } + } + + record Pattern(double threshold, double scale) { + } + + @FunctionalInterface + private interface Gate { + boolean passes(double x, double z); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/DepositCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/DepositCompiler.java index ce6aad6a3..960f9b5fe 100644 --- a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/DepositCompiler.java +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/DepositCompiler.java @@ -23,6 +23,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.TreeMap; import java.util.TreeSet; @@ -33,6 +34,14 @@ public final class DepositCompiler { private static final int MAX_ANCHOR = 2031; private static final String HOST_TAG = "deposit_host"; private static final List HOST_TAG_BASE = List.of("#minecraft:base_stone_overworld", "#minecraft:dirt", "#minecraft:sand"); + private static final String DEEPSLATE = "minecraft:deepslate"; + private static final String DEEPSLATE_PREFIX = "minecraft:deepslate_"; + private static final Set DEEPSLATE_ORES = Set.of("coal", "emerald", "diamond", "copper", "gold", "iron", "lapis", "redstone"); + private static final Set DEEPSLATE_HOSTS = Set.of(DEEPSLATE, "minecraft:deepslate_bricks", "minecraft:deepslate_brick_slab", + "minecraft:deepslate_brick_stairs", "minecraft:deepslate_brick_wall", "minecraft:deepslate_tiles", "minecraft:deepslate_tile_slab", + "minecraft:deepslate_tile_stairs", "minecraft:deepslate_tile_wall", "minecraft:cracked_deepslate_tiles", "minecraft:deepslate_coal_ore", + "minecraft:deepslate_iron_ore", "minecraft:deepslate_copper_ore", "minecraft:deepslate_diamond_ore", "minecraft:deepslate_emerald_ore", + "minecraft:deepslate_gold_ore", "minecraft:deepslate_lapis_ore", "minecraft:deepslate_redstone_ore"); private final PackCompileContext context; private final ExportedBiomes biomes; @@ -110,12 +119,17 @@ public final class DepositCompiler { List hosts = hosts(deposit, resource); List ores = new ArrayList<>(states.size()); for (VanillaBlockState state : states) { - JsonArray targets = new JsonArray(hosts.size()); + JsonArray targets = new JsonArray(hosts.size() + 1); + VanillaBlockState deepslateOre = stoneVariant(state, true); + if (hosts.size() == 1 && "minecraft:tag_match".equals(hosts.getFirst().get("predicate_type").getAsString()) && !deepslateOre.equals(state)) { + JsonObject deepslate = new JsonObject(); + deepslate.addProperty("predicate_type", "minecraft:block_match"); + deepslate.addProperty("block", DEEPSLATE); + targets.add(target(deepslate, deepslateOre)); + } for (JsonObject host : hosts) { - JsonObject target = new JsonObject(); - target.add("target", host.deepCopy()); - target.add("state", syntax.blockState(state)); - targets.add(target); + boolean deepslateHost = host.has("block") && DEEPSLATE_HOSTS.contains(host.get("block").getAsString()); + targets.add(target(host, stoneVariant(state, deepslateHost))); } JsonObject config = new JsonObject(); config.addProperty("size", Math.max(0, size)); @@ -135,6 +149,23 @@ public final class DepositCompiler { return catalog.feature("deposit/" + path(states.getFirst()) + "_mix", "minecraft:simple_random_selector", selector); } + private JsonObject target(JsonObject host, VanillaBlockState state) { + JsonObject target = new JsonObject(); + target.add("target", host.deepCopy()); + target.add("state", syntax.blockState(state)); + return target; + } + + private static VanillaBlockState stoneVariant(VanillaBlockState ore, boolean deepslate) { + String name = ore.name(); + boolean deep = name.startsWith(DEEPSLATE_PREFIX); + String base = deep ? name.substring(DEEPSLATE_PREFIX.length()) : name.substring(name.indexOf(':') + 1); + if (deep == deepslate || !base.endsWith("_ore") || !DEEPSLATE_ORES.contains(base.substring(0, base.length() - "_ore".length()))) { + return ore; + } + return new VanillaBlockState(deepslate ? DEEPSLATE_PREFIX + base : "minecraft:" + base, ore.properties()); + } + private JsonArray placement(IrisDepositGenerator deposit, List biomeIds, String resource) { int minCount = Math.max(0, Math.min(deposit.getMinPerChunk(), deposit.getMaxPerChunk())); int maxCount = Math.max(minCount, deposit.getMaxPerChunk()); diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/Placements.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/Placements.java index 913e3187c..09bc08766 100644 --- a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/Placements.java +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/Placements.java @@ -1,10 +1,12 @@ package art.arcane.iris.pack.datapack.export.compile.decoration; +import art.arcane.iris.generation.terrain.IrisSlopeClip; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; +import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -14,6 +16,7 @@ public final class Placements { private static final int CHUNK_EDGE = 15; private static final int WEIGHT_SCALE = 1000; private static final int[][] CARDINALS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + private static final double SLOPE_CEILING = 10.0; private Placements() { } @@ -147,17 +150,26 @@ public final class Placements { } public static JsonObject air(int x, int y, int z) { + return blockTag("minecraft:air", x, y, z); + } + + public static JsonObject blockTag(String tag, int x, int y, int z) { JsonObject json = typed("minecraft:matching_block_tag"); - json.addProperty("tag", "minecraft:air"); + json.addProperty("tag", tag); return offset(json, x, y, z); } public static JsonObject water(int x, int y, int z) { - JsonArray fluids = new JsonArray(2); - fluids.add("minecraft:water"); - fluids.add("minecraft:flowing_water"); + return fluids(List.of("minecraft:water", "minecraft:flowing_water"), x, y, z); + } + + public static JsonObject fluids(Collection fluids, int x, int y, int z) { + JsonArray list = new JsonArray(fluids.size()); + for (String fluid : fluids) { + list.add(fluid); + } JsonObject json = typed("minecraft:matching_fluids"); - json.add("fluids", fluids); + json.add("fluids", list); return offset(json, x, y, z); } @@ -215,6 +227,20 @@ public final class Placements { return anyOf(List.of(directions)); } + public static List slopeLimits(IrisSlopeClip clip) { + List limits = new ArrayList<>(2); + if (clip == null || clip.isDefault()) { + return limits; + } + if (clip.getMinimumSlope() > 0) { + limits.add(steeperThan(slopeRise(clip.getMinimumSlope()), -1)); + } + if (clip.getMaximumSlope() < SLOPE_CEILING) { + limits.add(not(steeperThan(slopeRise(clip.getMaximumSlope()) + 1, -1))); + } + return limits; + } + public static JsonObject dropAtLeast(int drop) { JsonObject[] directions = new JsonObject[CARDINALS.length]; for (int index = 0; index < CARDINALS.length; index++) { @@ -280,4 +306,8 @@ public final class Placements { json.addProperty("weight", weight); return json; } + + private static int slopeRise(double slope) { + return Math.max(1, (int) Math.round(slope)); + } } diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/BakedVariant.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/BakedVariant.java index 2bd2c8775..45b34b58a 100644 --- a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/BakedVariant.java +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/BakedVariant.java @@ -5,10 +5,11 @@ import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; import java.util.Objects; -public record BakedVariant(String id, TemplateVolume volume, int baseY) { +public record BakedVariant(String id, TemplateVolume volume, int baseY, TreeRoots roots) { public BakedVariant { VanillaResourceIdentifier.requireIdentifier(id, "template id"); Objects.requireNonNull(volume, "Template volume"); + Objects.requireNonNull(roots, "Template roots"); if (baseY > 0) { throw new IllegalArgumentException("Template base offset must not be positive, got " + baseY); } diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/HeightAnchor.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/HeightAnchor.java new file mode 100644 index 000000000..916f3121c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/HeightAnchor.java @@ -0,0 +1,124 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import art.arcane.iris.pack.datapack.export.TemplateVolume; +import art.arcane.iris.pack.datapack.export.compile.decoration.Placements; +import art.arcane.iris.structure.object.ObjectPlaceMode; +import com.google.gson.JsonObject; + +import java.util.ArrayList; +import java.util.List; + +final class HeightAnchor { + private static final int SCAN_STEPS = 32; + private static final int PROBE_LIMIT = 16; + + private HeightAnchor() { + } + + static Sampling sampling(ObjectPlaceMode mode) { + return switch (mode) { + case MIN_HEIGHT, FAST_MIN_HEIGHT, MIN_STILT, FAST_MIN_STILT -> Sampling.MIN; + case MAX_HEIGHT, FAST_MAX_HEIGHT, STILT, FAST_STILT, ERODE_STILT -> Sampling.MAX; + default -> Sampling.CENTER; + }; + } + + static boolean fast(ObjectPlaceMode mode) { + return switch (mode) { + case FAST_MIN_HEIGHT, FAST_MAX_HEIGHT, FAST_STILT, FAST_MIN_STILT -> true; + default -> false; + }; + } + + static JsonObject scan(Sampling sampling, boolean fast, boolean underwater, TemplateVolume volume, List quarters, + TemplateColumn shift) { + boolean lowest = sampling == Sampling.MIN; + List boxes = new ArrayList<>(); + for (int quarter : quarters) { + Box box = Box.of(volume, quarter, shift); + if (fast) { + boxes.addAll(box.fastSamples()); + } else { + boxes.add(box); + } + } + JsonObject top = top(underwater); + JsonObject match = lowest ? top : Placements.not(top); + int probeY = lowest ? -1 : 0; + List targets = new ArrayList<>(boxes.size()); + for (Box box : outermost(boxes)) { + targets.add(box.matching(probeY, match.deepCopy())); + } + JsonObject scan = Placements.typed("minecraft:environment_scan"); + scan.addProperty("direction_of_search", lowest ? "down" : "up"); + scan.addProperty("max_steps", SCAN_STEPS); + scan.add("target_condition", Placements.allOf(targets)); + return scan; + } + + private static JsonObject top(boolean underwater) { + JsonObject terrain = Placements.allOf(List.of(Placements.solid(0, 0, 0), + Placements.not(Placements.blockTag("minecraft:leaves", 0, 0, 0)), + Placements.not(Placements.blockTag("minecraft:logs", 0, 0, 0)))); + return underwater ? terrain : Placements.anyOf(List.of(terrain, Placements.fluids(PlacementCompiler.FLUIDS, 0, 0, 0))); + } + + private static List outermost(List boxes) { + List kept = new ArrayList<>(boxes.size()); + for (int index = 0; index < boxes.size(); index++) { + Box box = boxes.get(index); + boolean covered = false; + for (int other = 0; other < boxes.size() && !covered; other++) { + Box candidate = boxes.get(other); + covered = other != index && candidate.contains(box) && (!box.contains(candidate) || other < index); + } + if (!covered) { + kept.add(box); + } + } + return kept; + } + + private record Box(int minX, int maxX, int minZ, int maxZ) { + static Box of(TemplateVolume volume, int quarter, TemplateColumn shift) { + TemplateColumn first = TemplateColumn.of(volume, 0, 0, quarter); + TemplateColumn last = TemplateColumn.of(volume, volume.sizeX() - 1, volume.sizeZ() - 1, quarter); + return new Box(clamp(Math.min(first.x(), last.x()) + shift.x()), clamp(Math.max(first.x(), last.x()) + shift.x()), + clamp(Math.min(first.z(), last.z()) + shift.z()), clamp(Math.max(first.z(), last.z()) + shift.z())); + } + + List fastSamples() { + int stepX = (maxX - minX) / 2 + 1; + int stepZ = (maxZ - minZ) / 2 + 1; + List samples = new ArrayList<>(4); + for (int x = minX; x <= maxX; x += stepX) { + for (int z = minZ; z <= maxZ; z += stepZ) { + samples.add(new Box(x, x, z, z)); + } + } + return samples; + } + + boolean contains(Box other) { + return minX <= other.minX && maxX >= other.maxX && minZ <= other.minZ && maxZ >= other.maxZ; + } + + JsonObject matching(int y, JsonObject match) { + JsonObject volume = Placements.typed("minecraft:volume_match"); + volume.add("min", FeatureJson.vector(minX, y, minZ)); + volume.add("max", FeatureJson.vector(maxX, y, maxZ)); + volume.add("match", match); + return volume; + } + + private static int clamp(int value) { + return Math.max(-PROBE_LIMIT, Math.min(PROBE_LIMIT, value)); + } + } + + enum Sampling { + CENTER, + MIN, + MAX + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectSource.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectSource.java new file mode 100644 index 000000000..3aa6c01af --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectSource.java @@ -0,0 +1,88 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import art.arcane.iris.generation.decoration.tree.TreeGrounding; +import art.arcane.iris.pack.datapack.export.StructureTemplateEncoder; +import art.arcane.iris.pack.datapack.export.TemplateBlock; +import art.arcane.iris.pack.datapack.export.TemplateVolume; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.structure.object.IrisObject; +import art.arcane.volmlib.util.math.Vector3i; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.ToIntFunction; + +public record ObjectSource(TemplateVolume volume, int baseY, TreeRoots roots) { + public ObjectSource { + Objects.requireNonNull(volume, "Object volume"); + Objects.requireNonNull(roots, "Object roots"); + if (baseY > 0) { + throw new IllegalArgumentException("Object base offset must not be positive, got " + baseY); + } + } + + public static ObjectSource of(IrisObject object, String resource, List diagnostics) { + List blocks = StructureTemplateEncoder.blocks(object, resource, diagnostics); + Vector3i center = object.getCenter(); + Axis x = Axis.of(blocks, TemplateBlock::x, object.getW(), center.getX()); + Axis z = Axis.of(blocks, TemplateBlock::z, object.getD(), center.getZ()); + int lowestY = 0; + int highestY = object.getH() - 1; + for (TemplateBlock block : blocks) { + lowestY = Math.min(lowestY, block.y()); + highestY = Math.max(highestY, block.y()); + } + List placed = new ArrayList<>(blocks.size()); + Map byPosition = new HashMap<>(blocks.size() * 2); + for (TemplateBlock block : blocks) { + TemplateBlock shifted = new TemplateBlock(x.shift(block.x()), block.y() - lowestY, z.shift(block.z()), + block.state().waterlogged(false), block.nbt()); + placed.add(shifted); + byPosition.put(position(shifted.x(), shifted.y(), shifted.z()), shifted); + } + TreeRoots roots = roots(object.getTreeGrounding(), center, x, z, lowestY, byPosition); + return new ObjectSource(new TemplateVolume(x.size(), highestY - lowestY + 1, z.size(), placed), lowestY, roots); + } + + private static TreeRoots roots(TreeGrounding grounding, Vector3i center, Axis x, Axis z, int lowestY, Map blocks) { + if (grounding == null) { + return TreeRoots.NONE; + } + List cells = new ArrayList<>(grounding.roots().size()); + for (TreeGrounding.Root root : grounding.roots()) { + TemplateBlock block = blocks.get(position(x.shift(root.x() + center.getX()), root.y() + center.getY() - lowestY, + z.shift(root.z() + center.getZ()))); + if (block != null) { + cells.add(block); + } + } + return new TreeRoots(cells, grounding.maxExtension()); + } + + private static long position(int x, int y, int z) { + return ((long) (x & 0x1FFFFF) << 42) | ((long) (y & 0x1FFFFF) << 21) | (z & 0x1FFFFF); + } + + private record Axis(int size, int center, int nominalCenter) { + static Axis of(List blocks, ToIntFunction coordinate, int size, int center) { + int low = 0; + int high = size - 1; + for (TemplateBlock block : blocks) { + low = Math.min(low, coordinate.applyAsInt(block)); + high = Math.max(high, coordinate.applyAsInt(block)); + } + if (low == 0 && high == size - 1) { + return new Axis(size, center, center); + } + int reach = Math.max(center - low, high - center); + return new Axis(reach * 2 + 1, reach, center); + } + + int shift(int value) { + return value - nominalCenter + center; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectStage.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectStage.java index dca059034..b0162f8f0 100644 --- a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectStage.java +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectStage.java @@ -2,12 +2,14 @@ package art.arcane.iris.pack.datapack.export.compile.object; import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.decoration.IrisProceduralObjects; +import art.arcane.iris.generation.decoration.IrisProceduralPlacement; +import art.arcane.iris.generation.decoration.tree.IrisProceduralTree; import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.generation.terrain.IrisRegion; import art.arcane.iris.pack.datapack.DataVersion; import art.arcane.iris.pack.datapack.export.StructureTemplateEncoder; import art.arcane.iris.pack.datapack.export.TemplateBlock; -import art.arcane.iris.pack.datapack.export.TemplateVolume; import art.arcane.iris.pack.datapack.export.VanillaBlockState; import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; import art.arcane.iris.pack.datapack.export.compile.CompileStage; @@ -21,6 +23,7 @@ import art.arcane.iris.pack.datapack.export.ir.JsonResource; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.structure.object.IrisObject; import art.arcane.iris.structure.object.IrisObjectPlacement; +import art.arcane.volmlib.util.collection.KList; import com.google.gson.JsonArray; import com.google.gson.JsonObject; @@ -56,6 +59,7 @@ public final class ObjectStage implements CompileStage { private static final class Run { private static final int MAX_STILT_BASES = 16; private static final String TREE_FOLDER = "trees/"; + private static final String PROCEDURAL_FOLDER = "procedural/"; private static final String CAVE_SUFFIX = "_cave"; private final PackCompileContext context; @@ -70,7 +74,8 @@ public final class ObjectStage implements CompileStage { private final LootCompiler loot; private final WideObjectStructures wide; private final StructureTemplateEncoder encoder = new StructureTemplateEncoder(); - private final Map volumes = new HashMap<>(); + private final Map sources = new HashMap<>(); + private final Map proceduralPlacements = new IdentityHashMap<>(); private final Map baked = new HashMap<>(); private final Map plans = new IdentityHashMap<>(); private final Map> templates = new IdentityHashMap<>(); @@ -114,8 +119,10 @@ public final class ObjectStage implements CompileStage { IrisRegion region = regions.get(entry.getValue()); LootCompiler.LootScope scope = new LootCompiler.LootScope(dimension, region, biome); List placements = new ArrayList<>(biome.getObjects()); + placements.addAll(procedural(biome.getProceduralObjects())); if (region != null) { placements.addAll(region.getObjects()); + placements.addAll(procedural(region.getProceduralObjects())); } for (IrisObjectPlacement placement : placements) { if (placement == null || placement.getPlace().isEmpty()) { @@ -136,6 +143,32 @@ public final class ObjectStage implements CompileStage { emit(); } + private List procedural(IrisProceduralObjects objects) { + if (objects == null || objects.isEmpty()) { + return List.of(); + } + List placements = new ArrayList<>(); + for (IrisProceduralPlacement placement : objects.getAllPlacements()) { + placements.add(proceduralPlacements.computeIfAbsent(placement, this::procedural)); + } + return placements; + } + + private IrisObjectPlacement procedural(IrisProceduralPlacement source) { + IrisObjectPlacement placement = source.asPlacement(); + String folder = (source instanceof IrisProceduralTree ? TREE_FOLDER : "") + PROCEDURAL_FOLDER + source.getName() + "-"; + KList keys = new KList<>(); + for (IrisObject variant : source.getVariantObjects(data)) { + ObjectSource object = ObjectSource.of(variant, variant.getLoadKey(), diagnostics); + String key = folder + ContentIds.hash(object.volume() + ";base=" + object.baseY() + + (object.roots().present() ? ";roots=" + object.roots() : "")); + sources.putIfAbsent(key, object); + keys.add(key); + } + placement.setPlace(keys); + return placement; + } + private void place(String biomeId, IrisObjectPlacement placement, PlacementInput.Anchor anchor, LootCompiler.LootScope scope) { String name = name(placement, anchor); VariantPlanner.Plan plan = plans.computeIfAbsent(placement, ignored -> planner.plan(placement, name, diagnostics)); @@ -191,8 +224,8 @@ public final class ObjectStage implements CompileStage { List> choices = new ArrayList<>(); List all = new ArrayList<>(); for (String objectKey : placement.getPlace()) { - TemplateVolume volume = volume(objectKey); - if (volume == null) { + ObjectSource source = source(objectKey); + if (source == null) { continue; } String objectPath = VanillaResourceIdentifier.sanitizePath(objectKey); @@ -200,7 +233,7 @@ public final class ObjectStage implements CompileStage { for (VariantPlanner.PlannedVariant planned : plan.variants()) { String id = ObjectVariantBaker.templateId(namespace, objectPath, planned.spec(), target); BakedVariant variant = baked.computeIfAbsent(id, - ignored -> baker.bake(id, volume, planned.spec(), "objects/" + objectKey, diagnostics)); + ignored -> baker.bake(id, source, planned.spec())); objectChoices.add(new TemplateChoice(variant, planned.quarters(), planned.weight())); all.add(variant); } @@ -223,14 +256,14 @@ public final class ObjectStage implements CompileStage { return objects; } - private TemplateVolume volume(String objectKey) { - if (volumes.containsKey(objectKey)) { - return volumes.get(objectKey); + private ObjectSource source(String objectKey) { + if (sources.containsKey(objectKey)) { + return sources.get(objectKey); } IrisObject object = data.getObjectLoader().load(objectKey, false); - TemplateVolume volume = object == null ? null : StructureTemplateEncoder.volume(object, "objects/" + objectKey, diagnostics); - volumes.put(objectKey, volume); - return volume; + ObjectSource source = object == null ? null : ObjectSource.of(object, "objects/" + objectKey, diagnostics); + sources.put(objectKey, source); + return source; } private Map bindings(IrisObjectPlacement placement, List objects, LootCompiler.LootScope scope) { diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectVariantBaker.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectVariantBaker.java index 46f840b8f..708445736 100644 --- a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectVariantBaker.java +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectVariantBaker.java @@ -6,11 +6,11 @@ import art.arcane.iris.pack.datapack.export.TemplateVolume; import art.arcane.iris.pack.datapack.export.VanillaBlockState; import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; import art.arcane.iris.pack.datapack.export.compile.WeightedState; -import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; import art.arcane.volmlib.util.nbt.tag.CompoundTag; import java.util.ArrayList; import java.util.Comparator; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -19,6 +19,11 @@ import java.util.TreeMap; public final class ObjectVariantBaker { private static final Set AIR = Set.of("minecraft:air", "minecraft:cave_air", "minecraft:void_air"); + private static final String TALL_SEAGRASS = "minecraft:tall_seagrass"; + private static final Set SEA_PLANTS = Set.of("minecraft:sea_pickle", "minecraft:seagrass", TALL_SEAGRASS); + private static final String CORAL = "_coral"; + private static final String CORAL_FAN = "_coral_fan"; + private static final String WALL_FAN = "_coral_wall_fan"; private static final String SNOW = "minecraft:snow"; private static final int SNOW_LAYER_STEPS = 7; private static final int WARP_SPACING_X = 4099; @@ -36,15 +41,18 @@ public final class ObjectVariantBaker { return namespace + ":object/" + objectPath + "/" + ContentIds.hash(spec.descriptor() + ";target=" + target.name()); } - public BakedVariant bake(String templateId, TemplateVolume source, VariantSpec spec, String resource, List diagnostics) { + public BakedVariant bake(String templateId, ObjectSource source, VariantSpec spec) { long seed = ContentIds.seed(spec.descriptor()); - List voxels = prepare(source, spec, seed); + TemplateVolume template = source.volume(); + List voxels = prepare(template, spec, seed, rootCells(source.roots())); if (!spec.reshapes()) { - return new BakedVariant(templateId, volume(source.sizeX(), source.sizeY(), source.sizeZ(), voxels), 0); + List kept = supported(voxels); + return new BakedVariant(templateId, volume(template.sizeX(), template.sizeY(), template.sizeZ(), kept), source.baseY(), + roots(kept, source.roots().reach())); } - int sizeX = source.sizeX(); - int sizeY = source.sizeY(); - int sizeZ = source.sizeZ(); + int sizeX = template.sizeX(); + int sizeY = template.sizeY(); + int sizeZ = template.sizeZ(); if (spec.scale() != 1) { sizeX = scaledSize(sizeX, spec.scale()); sizeY = scaledSize(sizeY, spec.scale()); @@ -58,7 +66,7 @@ public final class ObjectVariantBaker { if (spec.snow() > 0) { voxels = snow(voxels, spec.snow(), seed); } - return reshaped(templateId, voxels, resource, diagnostics); + return reshaped(templateId, supported(voxels), source.baseY(), (int) Math.ceil(source.roots().reach() * spec.scale())); } public static List alignBases(List variants) { @@ -79,12 +87,12 @@ public final class ObjectVariantBaker { blocks.add(new TemplateBlock(block.x(), block.y() + lift, block.z(), block.state(), block.nbt())); } aligned.add(new BakedVariant(variant.id() + "_lift" + lift, - new TemplateVolume(volume.sizeX(), volume.sizeY() + lift, volume.sizeZ(), blocks), lowest)); + new TemplateVolume(volume.sizeX(), volume.sizeY() + lift, volume.sizeZ(), blocks), lowest, variant.roots().lift(lift))); } return aligned; } - private List prepare(TemplateVolume source, VariantSpec spec, long seed) { + private List prepare(TemplateVolume source, VariantSpec spec, long seed, Set roots) { List voxels = new ArrayList<>(source.blocks().size()); for (TemplateBlock block : source.blocks()) { if (AIR.contains(block.state().name())) { @@ -114,11 +122,29 @@ public final class ObjectVariantBaker { if (spec.preventLeafDecay()) { state = persistentLeaves(state); } - voxels.add(new Voxel(block.x(), block.y(), block.z(), state, nbt)); + voxels.add(new Voxel(block.x(), block.y(), block.z(), state, nbt, roots.contains(key(block.x(), block.y(), block.z())))); } return voxels; } + private static Set rootCells(TreeRoots roots) { + Set cells = new HashSet<>(roots.cells().size() * 2); + for (TemplateBlock cell : roots.cells()) { + cells.add(key(cell.x(), cell.y(), cell.z())); + } + return cells; + } + + private static TreeRoots roots(List voxels, int reach) { + List cells = new ArrayList<>(); + for (Voxel voxel : voxels) { + if (voxel.root()) { + cells.add(new TemplateBlock(voxel.x(), voxel.y(), voxel.z(), voxel.state(), null)); + } + } + return cells.isEmpty() ? TreeRoots.NONE : new TreeRoots(cells, reach); + } + private static VanillaBlockState pick(ObjectEdit edit, double roll) { double cursor = roll * edit.totalWeight(); for (WeightedState state : edit.replace()) { @@ -146,6 +172,51 @@ public final class ObjectVariantBaker { return new VanillaBlockState(state.name(), persistent); } + private static List supported(List voxels) { + Map occupied = new LinkedHashMap<>(); + int lowest = Integer.MAX_VALUE; + for (Voxel voxel : voxels) { + occupied.put(key(voxel.x(), voxel.y(), voxel.z()), voxel); + lowest = Math.min(lowest, voxel.y()); + } + List kept = new ArrayList<>(voxels.size()); + for (Voxel voxel : voxels) { + if (anchored(voxel, occupied, lowest)) { + kept.add(voxel); + } + } + return kept; + } + + private static boolean anchored(Voxel voxel, Map occupied, int lowest) { + String name = voxel.state().name(); + if (name.endsWith(WALL_FAN)) { + int[] behind = switch (voxel.state().properties().getOrDefault("facing", "north")) { + case "south" -> new int[]{0, -1}; + case "east" -> new int[]{-1, 0}; + case "west" -> new int[]{1, 0}; + default -> new int[]{0, 1}; + }; + return support(occupied.get(key(voxel.x() + behind[0], voxel.y(), voxel.z() + behind[1]))); + } + if (!SEA_PLANTS.contains(name) && !name.endsWith(CORAL) && !name.endsWith(CORAL_FAN)) { + return true; + } + Voxel below = occupied.get(key(voxel.x(), voxel.y() - 1, voxel.z())); + if (name.equals(TALL_SEAGRASS) && "upper".equals(voxel.state().properties().get("half"))) { + return below != null && below.state().name().equals(TALL_SEAGRASS); + } + return voxel.y() == lowest || support(below); + } + + private static boolean support(Voxel voxel) { + if (voxel == null) { + return false; + } + String name = voxel.state().name(); + return !SEA_PLANTS.contains(name) && !name.endsWith(CORAL) && !name.endsWith(CORAL_FAN) && !name.endsWith(WALL_FAN); + } + private static int scaledSize(int size, double scale) { return Math.max(1, (int) Math.ceil(size * scale)); } @@ -164,7 +235,7 @@ public final class ObjectVariantBaker { for (int y = minY; y <= maxY; y++) { for (int x = minX; x <= maxX; x++) { for (int z = minZ; z <= maxZ; z++) { - scaled.put(key(x, y, z), new Voxel(x, y, z, voxel.state(), voxel.nbt())); + scaled.put(key(x, y, z), new Voxel(x, y, z, voxel.state(), voxel.nbt(), voxel.root())); } } } @@ -184,7 +255,7 @@ public final class ObjectVariantBaker { int x = (int) Math.floor(rotated[0]); int y = (int) Math.floor(rotated[1]) + rotatedCenterY; int z = (int) Math.floor(rotated[2]); - anchored.put(key(x, y, z), new Voxel(x, y, z, states.apply(voxel.state()), voxel.nbt())); + anchored.put(key(x, y, z), new Voxel(x, y, z, states.apply(voxel.state()), voxel.nbt(), voxel.root())); } return new ArrayList<>(anchored.values()); } @@ -241,7 +312,7 @@ public final class ObjectVariantBaker { double worldZ = voxel.z() + originZ; int x = (int) (voxel.x() + warp.field().offset(worldX, voxel.y(), worldZ)); int z = (int) (voxel.z() + warp.field().offset(worldZ, voxel.y(), worldX)); - warped.put(key(x, voxel.y(), z), new Voxel(x, voxel.y(), z, voxel.state(), voxel.nbt())); + warped.put(key(x, voxel.y(), z), new Voxel(x, voxel.y(), z, voxel.state(), voxel.nbt(), voxel.root())); } return new ArrayList<>(warped.values()); } @@ -266,14 +337,14 @@ public final class ObjectVariantBaker { int index = steps <= 0 ? 0 : (int) Math.floor(unit(seed, top.x(), top.y(), top.z(), 0, SNOW_SALT) * steps); int layers = Math.max(0, Math.min(index, SNOW_LAYER_STEPS)) + 1; covered.add(new Voxel(top.x(), top.y() + 1, top.z(), - new VanillaBlockState(SNOW, Map.of("layers", Integer.toString(layers))), null)); + new VanillaBlockState(SNOW, Map.of("layers", Integer.toString(layers))), null, false)); } return covered; } - private BakedVariant reshaped(String templateId, List voxels, String resource, List diagnostics) { + private BakedVariant reshaped(String templateId, List voxels, int baseY, int reach) { if (voxels.isEmpty()) { - return new BakedVariant(templateId, new TemplateVolume(1, 1, 1, List.of()), 0); + return new BakedVariant(templateId, new TemplateVolume(1, 1, 1, List.of()), 0, TreeRoots.NONE); } int extentX = 0; int extentZ = 0; @@ -287,10 +358,10 @@ public final class ObjectVariantBaker { } List local = new ArrayList<>(voxels.size()); for (Voxel voxel : voxels) { - local.add(new Voxel(voxel.x() + extentX, voxel.y() - minY, voxel.z() + extentZ, voxel.state(), voxel.nbt())); + local.add(new Voxel(voxel.x() + extentX, voxel.y() - minY, voxel.z() + extentZ, voxel.state(), voxel.nbt(), voxel.root())); } TemplateVolume volume = volume(extentX * 2 + 1, maxY - minY + 1, extentZ * 2 + 1, local); - return new BakedVariant(templateId, volume, minY); + return new BakedVariant(templateId, volume, minY + baseY, roots(local, reach)); } private static TemplateVolume volume(int sizeX, int sizeY, int sizeZ, List voxels) { @@ -323,6 +394,6 @@ public final class ObjectVariantBaker { return mixed ^ (mixed >>> 31); } - private record Voxel(int x, int y, int z, VanillaBlockState state, CompoundTag nbt) { + private record Voxel(int x, int y, int z, VanillaBlockState state, CompoundTag nbt, boolean root) { } } diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/PlacementCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/PlacementCompiler.java index 8db44665c..6bd41245e 100644 --- a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/PlacementCompiler.java +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/PlacementCompiler.java @@ -1,5 +1,7 @@ package art.arcane.iris.pack.datapack.export.compile.object; +import art.arcane.iris.pack.datapack.export.TemplateBlock; +import art.arcane.iris.pack.datapack.export.TemplateVolume; import art.arcane.iris.pack.datapack.export.VanillaBlockState; import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; import art.arcane.iris.pack.datapack.export.compile.WeightedState; @@ -18,20 +20,26 @@ import com.google.gson.JsonObject; import java.math.BigInteger; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.TreeMap; public final class PlacementCompiler { public static final int MAX_FEATURE_WIDTH = 32; private static final int TILE_WIDTH = 17; private static final int WINDOW_REACH = 16; + private static final int MAX_SUPPORT_DROP = 16; private static final int CAVE_SCAN_STEPS = 32; private static final int CAVE_FLOOR_MARGIN = 8; private static final int MIN_ANCHOR = -2032; private static final int MAX_ANCHOR = 2031; + private static final int DEFAULT_FLUID_HEIGHT = 63; private static final long WEIGHT_CEILING = 1_000_000L; + private static final long ROTATION_SHARES = 12L; private static final List QUARTER_NAMES = List.of("none", "counterclockwise_90", "180", "clockwise_90"); - private static final List FLUIDS = List.of("minecraft:water", "minecraft:flowing_water", "minecraft:lava", "minecraft:flowing_lava"); + static final List FLUIDS = List.of("minecraft:water", "minecraft:flowing_water", "minecraft:lava", "minecraft:flowing_lava"); private static final Set STILT_MODES = Set.of(ObjectPlaceMode.STILT, ObjectPlaceMode.FAST_STILT, ObjectPlaceMode.MIN_STILT, ObjectPlaceMode.FAST_MIN_STILT, ObjectPlaceMode.CENTER_STILT, ObjectPlaceMode.ERODE_STILT, ObjectPlaceMode.ORGANIC_STILT); @@ -42,12 +50,14 @@ public final class PlacementCompiler { private final FeatureJson json; private final DimensionDef dimension; private final WideObjectStructures wide; + private final RootExtension roots; public PlacementCompiler(String namespace, ExportCapabilities capabilities, DimensionDef dimension) { this.namespace = VanillaResourceIdentifier.requireNamespace(namespace, "placement namespace"); json = new FeatureJson(capabilities); this.dimension = dimension; wide = new WideObjectStructures(namespace); + roots = new RootExtension(this.namespace, json); } public static boolean stilted(ObjectPlaceMode mode) { @@ -83,8 +93,12 @@ public final class PlacementCompiler { diagnostics.add(new ExportDiagnostic(ExportDiagnostic.Severity.INFO, ExportLimit.OBJECT_VACUUM, input.name(), "Vacuum terrain shaping is not exported; the object places at its center height")); } - if (!narrow.isEmpty()) { - compileNarrow(input, narrow, chance, density, (double) narrow.size() / total, resources, placed); + for (List group : depthGroups(input, narrow)) { + compileNarrow(input, group, chance, density, (double) group.size() / total, resources, placed); + } + if (!narrow.isEmpty() && slopeLimited(input)) { + diagnostics.add(new ExportDiagnostic(ExportDiagnostic.Severity.INFO, ExportLimit.SLOPE, input.name(), + "Object slope conditions measure slope over 3 blocks in the four cardinal directions")); } if (!wideObjects.isEmpty()) { List templates = weighted(wideObjects); @@ -108,22 +122,32 @@ public final class PlacementCompiler { if (paint) { processors.add(gravity(placement)); } - JsonObject feature = template(templates, processors); - if (stilted(placement.getMode())) { - feature = stilts(feature, templates, input.stiltBases()); + HeightAnchor.Sampling sampling = input.anchor() == PlacementInput.Anchor.SURFACE && !paint + && placement.getMode() != ObjectPlaceMode.FLOATING ? HeightAnchor.sampling(placement.getMode()) : HeightAnchor.Sampling.CENTER; + boolean grounded = grounds(input) && rooted(templates); + boolean branched = grounded || sampling != HeightAnchor.Sampling.CENTER; + JsonObject feature; + if (branched) { + feature = branches(input, templates, processors, sampling, grounded, resources); + } else { + feature = template(templates, processors); + if (stilted(placement.getMode())) { + feature = stilts(feature, templates, input.stiltBases()); + } } String name = input.name(); String featurePath = "object/" + name + "_" + ContentIds.hash(feature); resources.add(new JsonResource("data/" + namespace + "/" + json.syntax().featureFolder() + "/" + featurePath + ".json", feature)); JsonObject placedFeature = new JsonObject(); placedFeature.addProperty("feature", namespace + ":" + featurePath); - placedFeature.add("placement", modifiers(input, templates, chance, density, share, paint)); + placedFeature.add("placement", modifiers(input, templates, chance, density, share, paint, branched)); String placedPath = "object/" + name + "_" + ContentIds.hash(placedFeature); resources.add(new JsonResource("data/" + namespace + "/worldgen/placed_feature/" + placedPath + ".json", placedFeature)); placed.add(namespace + ":" + placedPath); } - private JsonArray modifiers(PlacementInput input, List templates, double chance, int density, double share, boolean paint) { + private JsonArray modifiers(PlacementInput input, List templates, double chance, int density, double share, boolean paint, + boolean branched) { IrisObjectPlacement placement = input.placement(); int translateX = placement.getTranslate().getX(); int translateY = placement.getTranslate().getY(); @@ -143,18 +167,32 @@ public final class PlacementCompiler { } else if (placement.getMode() == ObjectPlaceMode.FLOATING) { modifiers.add(fixedHeight(translateY + base + spreadLow, translateY + base + spreadHigh)); } else { - modifiers.add(Placements.heightmap(placement.isUnderwater() ? "OCEAN_FLOOR_WG" : "WORLD_SURFACE_WG")); - if (!placement.isForcePlace() && placement.isRequireSurfaceSupport()) { - modifiers.add(Placements.filter(support(templates, placement))); + boolean underwater = placement.isUnderwater(); + boolean onwater = onwater(placement); + modifiers.add(Placements.heightmap(onwater ? Placements.WORLD_SURFACE : Placements.OCEAN_FLOOR)); + modifiers.add(Placements.filter(onwater ? Placements.water(0, -1, 0) : ground(underwater))); + if (underwater) { + modifiers.add(Placements.filter(belowSurface(fluidCeiling(templates, translateY)))); + } + if (!underwater && !onwater && !placement.isForcePlace() && placement.isRequireSurfaceSupport() && !stilted(placement.getMode())) { + JsonObject support = support(templates, placement); + if (support != null) { + modifiers.add(Placements.filter(support)); + } + } + if (slopeLimited(input)) { + for (JsonObject limit : Placements.slopeLimits(placement.getSlopeCondition())) { + modifiers.add(Placements.filter(limit)); + } } if (paint) { modifiers.addAll(json.offset(translateX, 0, 0, translateZ)); - } else { - modifiers.addAll(json.offset(translateX, translateY - 1 + base + spreadLow, translateY - 1 + base + spreadHigh, translateZ)); + } else if (!branched) { + modifiers.addAll(surfaceOffset(placement, base)); } } JsonObject clamp = clamp(placement.getClamp()); - if (clamp != null) { + if (clamp != null && !branched) { modifiers.add(Placements.filter(clamp)); } modifiers.add(Placements.typed("minecraft:biome")); @@ -165,6 +203,128 @@ public final class PlacementCompiler { return array; } + private List surfaceOffset(IrisObjectPlacement placement, int base) { + int yRandom = placement.getTranslate().getYRandom(); + int spreadLow = yRandom < 0 ? yRandom : 0; + int spreadHigh = yRandom > 0 ? yRandom - 1 : yRandom < 0 ? -1 : 0; + int lift = placement.getTranslate().getY() - 1 + base; + return json.offset(placement.getTranslate().getX(), lift + spreadLow, lift + spreadHigh, placement.getTranslate().getZ()); + } + + private JsonObject branches(PlacementInput input, List templates, List processors, + HeightAnchor.Sampling sampling, boolean grounded, List resources) { + IrisObjectPlacement placement = input.placement(); + Map shared = new TreeMap<>(); + Branching branching = new Branching(input, processors, sampling, grounded, surfaceOffset(placement, lowestBase(templates)), + clamp(placement.getClamp()), shared); + List branches = new ArrayList<>(); + List weights = new ArrayList<>(); + for (TemplateChoice choice : templates) { + long weight = grounded ? choice.weight() * (ROTATION_SHARES / choice.quarters().size()) : choice.weight(); + List> groups = new ArrayList<>(choice.quarters().size()); + if (grounded) { + for (int quarter : choice.quarters()) { + groups.add(List.of(quarter)); + } + } else { + groups.add(choice.quarters()); + } + for (List quarters : groups) { + branches.add(branch(branching, choice.variant(), quarters)); + weights.add(weight); + } + } + resources.addAll(shared.values()); + long divisor = 0; + for (long weight : weights) { + divisor = BigInteger.valueOf(divisor).gcd(BigInteger.valueOf(weight)).longValue(); + } + JsonArray entries = new JsonArray(branches.size()); + for (int index = 0; index < branches.size(); index++) { + JsonObject entry = new JsonObject(); + entry.add("data", branches.get(index)); + entry.addProperty("weight", weights.get(index) / divisor); + entries.add(entry); + } + JsonObject fields = new JsonObject(); + fields.add("features", entries); + return json.syntax().feature("minecraft:weighted_random_selector", fields); + } + + private JsonObject branch(Branching branching, BakedVariant variant, List quarters) { + IrisObjectPlacement placement = branching.input().placement(); + TemplateChoice single = new TemplateChoice(variant, quarters, 1); + JsonObject feature = template(List.of(single), branching.processors()); + List modifiers = new ArrayList<>(); + if (branching.sampling() != HeightAnchor.Sampling.CENTER) { + modifiers.add(HeightAnchor.scan(branching.sampling(), HeightAnchor.fast(placement.getMode()), placement.isUnderwater(), + variant.volume(), quarters, new TemplateColumn(placement.getTranslate().getX(), placement.getTranslate().getZ()))); + } + modifiers.addAll(branching.offset()); + if (branching.grounded() && variant.roots().present()) { + int quarter = quarters.getFirst(); + feature = overlay(feature, roots.pillars(variant.roots(), variant.volume(), quarter, branching.shared())); + JsonObject gap = RootExtension.gap(variant.roots(), variant.volume(), quarter); + if (gap != null) { + modifiers.add(Placements.filter(gap)); + } + } else if (stilted(placement.getMode())) { + feature = stilts(feature, List.of(single), branching.input().stiltBases()); + } + if (branching.clamp() != null) { + modifiers.add(Placements.filter(branching.clamp().deepCopy())); + } + JsonObject branch = new JsonObject(); + branch.add("feature", feature); + branch.add("placement", array(modifiers)); + return branch; + } + + private JsonObject overlay(JsonObject template, List parts) { + JsonArray features = new JsonArray(parts.size() + 1); + JsonObject anchored = new JsonObject(); + anchored.add("feature", template); + anchored.add("placement", new JsonArray()); + features.add(anchored); + for (JsonObject part : parts) { + features.add(part); + } + JsonObject fields = new JsonObject(); + fields.add("features", features); + return json.syntax().feature("minecraft:overlay", fields); + } + + private static boolean grounds(PlacementInput input) { + IrisObjectPlacement placement = input.placement(); + ObjectPlaceMode mode = placement.getMode(); + return input.anchor() == PlacementInput.Anchor.SURFACE && !placement.isForcePlace() && !placement.isFromBottom() + && mode != ObjectPlaceMode.PAINT && mode != ObjectPlaceMode.FLOATING && mode != ObjectPlaceMode.CEILING_HANG + && mode != ObjectPlaceMode.STRUCTURE_PIECE; + } + + private static boolean rooted(List templates) { + for (TemplateChoice template : templates) { + if (template.variant().roots().present()) { + return true; + } + } + return false; + } + + private static JsonArray array(List values) { + JsonArray array = new JsonArray(values.size()); + for (JsonObject value : values) { + array.add(value); + } + return array; + } + + private static boolean slopeLimited(PlacementInput input) { + IrisObjectPlacement placement = input.placement(); + return input.anchor() == PlacementInput.Anchor.SURFACE && placement.getMode() != ObjectPlaceMode.FLOATING + && !placement.isForcePlace() && !Placements.slopeLimits(placement.getSlopeCondition()).isEmpty(); + } + private JsonObject template(List templates, List processors) { JsonArray entries = new JsonArray(templates.size()); for (TemplateChoice template : templates) { @@ -262,20 +422,66 @@ public final class PlacementCompiler { return json.syntax().feature("minecraft:single_block_pillar", fields); } - private static JsonObject canReplace() { - JsonObject fluids = Placements.typed("minecraft:matching_fluids"); - JsonArray list = new JsonArray(FLUIDS.size()); - for (String fluid : FLUIDS) { - list.add(fluid); + static JsonObject canReplace() { + return Placements.anyOf(List.of(Placements.air(0, 0, 0), Placements.fluids(FLUIDS, 0, 0, 0), Placements.typed("minecraft:replaceable"))); + } + + private static JsonObject ground(boolean underwater) { + List rules = new ArrayList<>(4); + rules.add(Placements.solid(0, -1, 0)); + rules.add(Placements.not(Placements.blockTag("minecraft:leaves", 0, -1, 0))); + rules.add(Placements.not(Placements.blockTag("minecraft:logs", 0, -1, 0))); + rules.add(underwater ? Placements.water(0, 0, 0) : Placements.not(Placements.fluids(FLUIDS, 0, 0, 0))); + return Placements.allOf(rules); + } + + private static boolean onwater(IrisObjectPlacement placement) { + return placement.isOnwater() && !placement.isUnderwater(); + } + + private List> depthGroups(PlacementInput input, List narrow) { + IrisObjectPlacement placement = input.placement(); + if (narrow.isEmpty() || !placement.isUnderwater() || input.anchor() == PlacementInput.Anchor.CAVE + || placement.getMode() == ObjectPlaceMode.FLOATING) { + return narrow.isEmpty() ? List.of() : List.of(narrow); } - fluids.add("fluids", list); - return Placements.anyOf(List.of(Placements.air(0, 0, 0), fluids, Placements.typed("minecraft:replaceable"))); + Map> groups = new TreeMap<>(); + for (ObjectTemplates object : narrow) { + groups.computeIfAbsent(fluidCeiling(object.choices(), placement.getTranslate().getY()), ignored -> new ArrayList<>()).add(object); + } + return new ArrayList<>(groups.values()); + } + + private int fluidCeiling(List templates, int translateY) { + int reach = 0; + for (TemplateChoice template : templates) { + reach = Math.max(reach, 3 * (rows(template.variant().volume()) / 2)); + } + return (dimension == null ? DEFAULT_FLUID_HEIGHT : dimension.fluidHeight()) - reach - translateY; + } + + private JsonObject belowSurface(int ceiling) { + int bottom = dimension == null ? MIN_ANCHOR : dimension.minY(); + JsonObject predicate = Placements.typed("minecraft:height_range"); + predicate.add("max_inclusive", FeatureJson.absolute(Math.max(MIN_ANCHOR, Math.min(MAX_ANCHOR, ceiling)))); + predicate.add("min_inclusive", FeatureJson.absolute(Math.max(MIN_ANCHOR, Math.min(bottom, ceiling)))); + return predicate; + } + + private static int rows(TemplateVolume volume) { + int low = Integer.MAX_VALUE; + int high = Integer.MIN_VALUE; + for (TemplateBlock block : volume.blocks()) { + low = Math.min(low, block.y()); + high = Math.max(high, block.y()); + } + return low > high ? 1 : high - low + 1; } private static JsonObject gravity(IrisObjectPlacement placement) { JsonObject gravity = new JsonObject(); gravity.addProperty("processor_type", "minecraft:gravity"); - gravity.addProperty("heightmap", placement.isUnderwater() ? "OCEAN_FLOOR_WG" : "WORLD_SURFACE_WG"); + gravity.addProperty("heightmap", onwater(placement) ? Placements.WORLD_SURFACE : Placements.OCEAN_FLOOR); gravity.addProperty("offset", placement.getTranslate().getY() - 1); return gravity; } @@ -291,17 +497,88 @@ public final class PlacementCompiler { } private static JsonObject support(List templates, IrisObjectPlacement placement) { - int half = Integer.MAX_VALUE; + int minX = 0; + int maxX = 0; + int minZ = 0; + int maxZ = 0; for (TemplateChoice template : templates) { - half = Math.min(half, Math.min(template.variant().volume().sizeX(), template.variant().volume().sizeZ()) / 2); + TemplateVolume volume = template.variant().volume(); + int base = baseRow(volume); + for (TemplateBlock block : volume.blocks()) { + if (block.y() != base || foliage(block.state())) { + continue; + } + int x = block.x() - volume.sizeX() / 2; + int z = block.z() - volume.sizeZ() / 2; + for (int quarter : template.quarters()) { + int rotatedX = switch (quarter) { + case 1 -> z; + case 2 -> -x; + case 3 -> -z; + default -> x; + }; + int rotatedZ = switch (quarter) { + case 1 -> -x; + case 2 -> -z; + case 3 -> x; + default -> z; + }; + minX = Math.min(minX, rotatedX); + maxX = Math.max(maxX, rotatedX); + minZ = Math.min(minZ, rotatedZ); + maxZ = Math.max(maxZ, rotatedZ); + } + } } - int reach = Math.min(WINDOW_REACH, Math.max(0, half + Math.max(0, placement.getSurfaceSupportBuffer()))); - int depth = Math.max(1, placement.getSurfaceSupportDepth()); - JsonObject predicate = Placements.typed("minecraft:volume_match"); - predicate.add("match", Placements.typed("minecraft:solid")); - predicate.add("max", FeatureJson.vector(reach, -1, reach)); - predicate.add("min", FeatureJson.vector(-reach, -depth, -reach)); - return predicate; + int buffer = Math.max(0, placement.getSurfaceSupportBuffer()); + int thickness = Math.max(1, Math.min(MAX_SUPPORT_DROP, placement.getSurfaceSupportDepth())); + int[] xs = {reach(minX - buffer), reach(maxX + buffer)}; + int[] zs = {reach(minZ - buffer), reach(maxZ + buffer)}; + Set seen = new LinkedHashSet<>(); + List corners = new ArrayList<>(4); + for (int x : xs) { + for (int z : zs) { + if ((x == 0 && z == 0) || !seen.add(((long) x << 32) | (z & 0xFFFFFFFFL))) { + continue; + } + corners.add(groundBelow(x, z, thickness)); + } + } + return corners.isEmpty() ? null : Placements.allOf(corners); + } + + private static JsonObject groundBelow(int x, int z, int thickness) { + List tops = new ArrayList<>(MAX_SUPPORT_DROP - thickness + 1); + for (int top = 1; top + thickness - 1 <= MAX_SUPPORT_DROP; top++) { + if (thickness == 1) { + tops.add(Placements.solid(x, -top, z)); + continue; + } + List layers = new ArrayList<>(thickness); + for (int layer = 0; layer < thickness; layer++) { + layers.add(Placements.solid(x, -(top + layer), z)); + } + tops.add(Placements.allOf(layers)); + } + return Placements.anyOf(tops); + } + + private static int baseRow(TemplateVolume volume) { + int base = Integer.MAX_VALUE; + for (TemplateBlock block : volume.blocks()) { + if (!foliage(block.state())) { + base = Math.min(base, block.y()); + } + } + return base; + } + + private static boolean foliage(VanillaBlockState state) { + return state.name().endsWith("_leaves"); + } + + private static int reach(int offset) { + return Math.max(-WINDOW_REACH, Math.min(WINDOW_REACH, offset)); } private JsonObject clamp(IrisObjectLimit limit) { @@ -323,7 +600,7 @@ public final class PlacementCompiler { private JsonObject caveRange() { int floor = dimension == null ? 0 : dimension.minY() + CAVE_FLOOR_MARGIN; - int ceiling = dimension == null ? 63 : dimension.fluidHeight(); + int ceiling = dimension == null ? DEFAULT_FLUID_HEIGHT : dimension.fluidHeight(); JsonObject height = new JsonObject(); height.addProperty("type", "minecraft:uniform"); height.add("max_inclusive", FeatureJson.absolute(Math.max(floor, ceiling))); @@ -393,4 +670,8 @@ public final class PlacementCompiler { } return tallest; } + + private record Branching(PlacementInput input, List processors, HeightAnchor.Sampling sampling, boolean grounded, + List offset, JsonObject clamp, Map shared) { + } } diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/RootExtension.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/RootExtension.java new file mode 100644 index 000000000..9f6af038f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/RootExtension.java @@ -0,0 +1,92 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import art.arcane.iris.pack.datapack.export.TemplateBlock; +import art.arcane.iris.pack.datapack.export.TemplateVolume; +import art.arcane.iris.pack.datapack.export.VanillaBlockState; +import art.arcane.iris.pack.datapack.export.compile.WeightedState; +import art.arcane.iris.pack.datapack.export.compile.decoration.Placements; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +final class RootExtension { + private static final int PROBE_LIMIT = 16; + private static final int QUARTER_DEGREES = 90; + + private final String namespace; + private final FeatureJson json; + + RootExtension(String namespace, FeatureJson json) { + this.namespace = namespace; + this.json = json; + } + + static JsonObject gap(TreeRoots roots, TemplateVolume volume, int quarter) { + List columns = new ArrayList<>(roots.cells().size()); + for (TemplateBlock cell : roots.cells()) { + TemplateColumn column = TemplateColumn.of(volume, cell.x(), cell.z(), quarter); + int top = Math.min(PROBE_LIMIT, cell.y() - 1); + int bottom = Math.max(-PROBE_LIMIT, cell.y() - 1 - roots.reach()); + if (bottom > top || Math.abs(column.x()) > PROBE_LIMIT || Math.abs(column.z()) > PROBE_LIMIT) { + continue; + } + JsonObject open = Placements.typed("minecraft:volume_match"); + open.add("min", FeatureJson.vector(column.x(), bottom, column.z())); + open.add("max", FeatureJson.vector(column.x(), top, column.z())); + open.add("match", Placements.not(Placements.solid(0, 0, 0))); + columns.add(Placements.not(open)); + } + return columns.isEmpty() ? null : Placements.allOf(columns); + } + + List pillars(TreeRoots roots, TemplateVolume volume, int quarter, Map shared) { + BlockStateRotation rotation = BlockStateRotation.of(0, quarter * QUARTER_DEGREES, 0); + List pillars = new ArrayList<>(roots.cells().size()); + for (TemplateBlock cell : roots.cells()) { + TemplateColumn column = TemplateColumn.of(volume, cell.x(), cell.z(), quarter); + JsonObject entry = new JsonObject(); + entry.addProperty("feature", pillar(rotation.apply(cell.state()), shared)); + JsonArray placement = new JsonArray(); + for (JsonObject offset : json.offset(column.x(), cell.y() - 1, cell.y() - 1, column.z())) { + placement.add(offset); + } + entry.add("placement", placement); + pillars.add(entry); + } + return pillars; + } + + private String pillar(VanillaBlockState state, Map shared) { + JsonObject block = json.syntax().provider(List.of(new WeightedState(state, 1))); + JsonObject cap = new JsonObject(); + JsonObject capFields = new JsonObject(); + capFields.add("to_place", block); + cap.add("feature", json.syntax().feature("minecraft:simple_block", capFields)); + JsonArray capPlacement = new JsonArray(1); + capPlacement.add(Placements.verticalOffset(-1)); + cap.add("placement", capPlacement); + JsonObject canReplace = PlacementCompiler.canReplace(); + JsonObject columnFields = new JsonObject(); + columnFields.add("block", block); + columnFields.add("can_replace", canReplace); + columnFields.addProperty("direction", "down"); + columnFields.add("cap_feature", cap); + JsonObject gated = new JsonObject(); + gated.add("feature", json.syntax().feature("minecraft:single_block_pillar", columnFields)); + JsonArray gate = new JsonArray(1); + gate.add(Placements.filter(canReplace)); + gated.add("placement", gate); + JsonArray features = new JsonArray(1); + features.add(gated); + JsonObject overlayFields = new JsonObject(); + overlayFields.add("features", features); + JsonObject feature = json.syntax().feature("minecraft:overlay", overlayFields); + String path = "object/root/" + ContentIds.hash(feature); + shared.putIfAbsent(path, new JsonResource("data/" + namespace + "/" + json.syntax().featureFolder() + "/" + path + ".json", feature)); + return namespace + ":" + path; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/TemplateColumn.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/TemplateColumn.java new file mode 100644 index 000000000..fcce7a6da --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/TemplateColumn.java @@ -0,0 +1,18 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import art.arcane.iris.pack.datapack.export.TemplateVolume; + +record TemplateColumn(int x, int z) { + static TemplateColumn of(TemplateVolume volume, int templateX, int templateZ, int quarter) { + return new TemplateColumn(templateX - volume.sizeX() / 2, templateZ - volume.sizeZ() / 2).turn(quarter); + } + + TemplateColumn turn(int quarter) { + return switch (quarter) { + case 1 -> new TemplateColumn(z, -x); + case 2 -> new TemplateColumn(-x, -z); + case 3 -> new TemplateColumn(-z, x); + default -> this; + }; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/TreeRoots.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/TreeRoots.java new file mode 100644 index 000000000..cfd4c7122 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/TreeRoots.java @@ -0,0 +1,51 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import art.arcane.iris.pack.datapack.export.TemplateBlock; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public record TreeRoots(List cells, int reach) { + private static final int MAX_REACH = 16; + private static final Comparator COLUMN_ORDER = Comparator + .comparingInt(TemplateBlock::x) + .thenComparingInt(TemplateBlock::z); + public static final TreeRoots NONE = new TreeRoots(List.of(), 0); + + public TreeRoots { + cells = lowestPerColumn(cells); + reach = Math.max(0, Math.min(MAX_REACH, reach)); + } + + public boolean present() { + return !cells.isEmpty(); + } + + public TreeRoots lift(int dy) { + if (dy == 0 || cells.isEmpty()) { + return this; + } + List lifted = new ArrayList<>(cells.size()); + for (TemplateBlock cell : cells) { + lifted.add(new TemplateBlock(cell.x(), cell.y() + dy, cell.z(), cell.state(), null)); + } + return new TreeRoots(lifted, reach); + } + + private static List lowestPerColumn(List cells) { + Map columns = new HashMap<>(); + for (TemplateBlock cell : cells) { + long key = ((long) cell.x() << 32) | (cell.z() & 0xFFFFFFFFL); + TemplateBlock previous = columns.get(key); + if (previous == null || cell.y() < previous.y()) { + columns.put(key, new TemplateBlock(cell.x(), cell.y(), cell.z(), cell.state(), null)); + } + } + List lowest = new ArrayList<>(columns.values()); + lowest.sort(COLUMN_ORDER); + return List.copyOf(lowest); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/BaseWorldgenJsonWriter.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/BaseWorldgenJsonWriter.java index b5f24c5aa..d1b707aad 100644 --- a/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/BaseWorldgenJsonWriter.java +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/BaseWorldgenJsonWriter.java @@ -132,7 +132,7 @@ abstract class BaseWorldgenJsonWriter implements WorldgenJsonWriter { private JsonObject packMetadata(ExportIr ir) { JsonObject pack = new JsonObject(); - pack.addProperty("description", "Iris worldgen export " + ir.namespace() + ":" + ir.dimension().name()); + pack.addProperty("description", "Iris worldgen export " + ir.namespace() + ":" + ir.dimension().name() + ". Keep Default selected"); pack.addProperty("min_format", target.getPackFormat()); pack.addProperty("max_format", target.getPackFormat()); JsonObject metadata = new JsonObject(); diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/AquiferCompilerTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/AquiferCompilerTest.java index 1fd373ac4..a278421d1 100644 --- a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/AquiferCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/AquiferCompilerTest.java @@ -4,6 +4,7 @@ import art.arcane.iris.generation.cave.IrisCaveProfile; import art.arcane.iris.pack.datapack.export.ExportTestPacks; import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; import art.arcane.iris.pack.datapack.export.ir.AquiferDef; +import art.arcane.iris.pack.datapack.export.ir.Axis; import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; import art.arcane.iris.pack.datapack.export.ir.ExportLimit; import art.arcane.iris.pack.datapack.export.ir.Field; @@ -26,6 +27,16 @@ import static org.junit.Assert.assertTrue; public class AquiferCompilerTest { private static final Field SURFACE = new Field.Const(120.0); + private static final Field SEA_FLOOR_SLOPE = new Field.Coord(Axis.X, Field.Tiling.CLAMP, -1000, 1000, -100.0, 300.0); + private static final Field HIGH_ESTIMATE = new Field.Const(120.0); + private static final int SEA_X = -275; + private static final int LAND_X = 400; + private static final int DEEP_SEA_X = -800; + private static final int SHORE_X = -165; + private static final int HILL_X = -125; + private static final int SEA_FLOOR = 45; + private static final int FLUID_HEIGHT = 63; + private static final double FULLY_FLOODED = 0.8; private static final double DRY = -1.0; private static final double PARTIAL = 0.6; private static final int FLUID_BAND_HEIGHT = 40; @@ -45,12 +56,67 @@ public class AquiferCompilerTest { PackCompileContext context = compile(CaveTestPacks.dimension(20), CaveTestPacks.constantProfile(-0.1)); AquiferDef aquifer = context.aquifer(); - assertEquals(AquiferDef.dry(SURFACE), aquifer); + AquiferDef vanilla = AquiferDef.dry(SURFACE); + assertEquals(vanilla.barrier(), aquifer.barrier()); + assertEquals(vanilla.spread(), aquifer.spread()); + assertEquals(vanilla.lava(), aquifer.lava()); + assertEquals(vanilla.exclusion(), aquifer.exclusion()); + assertEquals(vanilla.surfaceLevel(), aquifer.surfaceLevel()); for (int y = CaveTestPacks.MIN_Y; y < CaveTestPacks.MAX_Y; y += 8) { assertEquals(DRY, CaveTestPacks.sample(aquifer.floodedness(), context.noises(), 17, y, -9), 0.0); } } + @Test + public void openWaterAboveTheTerrainFloodsToSeaLevel() throws Exception { + PackCompileContext context = compile(CaveTestPacks.dimension(20), CaveTestPacks.constantProfile(-0.1), SEA_FLOOR_SLOPE, SEA_FLOOR_SLOPE); + + Field floodedness = context.aquifer().floodedness(); + assertTrue(CaveTestPacks.sample(floodedness, context.noises(), SEA_X, SEA_FLOOR + 1, 4) > FULLY_FLOODED); + assertTrue(CaveTestPacks.sample(floodedness, context.noises(), SEA_X, FLUID_HEIGHT, 4) > FULLY_FLOODED); + assertTrue(CaveTestPacks.sample(floodedness, context.noises(), SEA_X, FLUID_HEIGHT + 6, 4) > FULLY_FLOODED); + assertTrue(CaveTestPacks.sample(floodedness, context.noises(), SEA_X, SEA_FLOOR - 4, 4) > FULLY_FLOODED); + assertEquals(DRY, CaveTestPacks.sample(floodedness, context.noises(), SEA_X, SEA_FLOOR - 12, 4), 0.0); + assertEquals(DRY, CaveTestPacks.sample(floodedness, context.noises(), LAND_X, FLUID_HEIGHT - 10, 4), 0.0); + assertEquals(DRY, CaveTestPacks.sample(floodedness, context.noises(), LAND_X, SEA_FLOOR + 1, 4), 0.0); + } + + @Test + public void openWaterLiftStaysNearSeaLevel() throws Exception { + PackCompileContext context = compile(CaveTestPacks.dimension(20), CaveTestPacks.constantProfile(-0.1), SEA_FLOOR_SLOPE, SEA_FLOOR_SLOPE); + + Field floodedness = context.aquifer().floodedness(); + assertEquals(DRY, CaveTestPacks.sample(floodedness, context.noises(), DEEP_SEA_X, FLUID_HEIGHT - 40, 4), 0.0); + assertTrue(CaveTestPacks.sample(floodedness, context.noises(), DEEP_SEA_X, FLUID_HEIGHT - 20, 4) > FULLY_FLOODED); + assertEquals(DRY, CaveTestPacks.sample(floodedness, context.noises(), SEA_X, FLUID_HEIGHT + 12, 4), 0.0); + } + + @Test + public void openWaterLiftStopsAtTheShore() throws Exception { + PackCompileContext context = compile(CaveTestPacks.dimension(20), CaveTestPacks.constantProfile(-0.1), SEA_FLOOR_SLOPE, SEA_FLOOR_SLOPE); + + Field floodedness = context.aquifer().floodedness(); + assertTrue(CaveTestPacks.sample(floodedness, context.noises(), SHORE_X, FLUID_HEIGHT, 4) > FULLY_FLOODED); + assertEquals(DRY, CaveTestPacks.sample(floodedness, context.noises(), HILL_X, FLUID_HEIGHT + 7, 4), 0.0); + } + + @Test + public void openWaterLiftSkipsColumnsWithAHighSurfaceEstimate() throws Exception { + PackCompileContext context = compile(CaveTestPacks.dimension(20), CaveTestPacks.constantProfile(-0.1), SEA_FLOOR_SLOPE, HIGH_ESTIMATE); + + assertEquals(DRY, CaveTestPacks.sample(context.aquifer().floodedness(), context.noises(), SEA_X, SEA_FLOOR + 1, 4), 0.0); + } + + @Test + public void wetCavesBelowTheTerrainKeepPartialFlooding() throws Exception { + PackCompileContext context = compile(CaveTestPacks.dimension(20), CaveTestPacks.constantProfile(-0.1).setAllowFluid(true), SEA_FLOOR_SLOPE, SEA_FLOOR_SLOPE); + + Field floodedness = context.aquifer().floodedness(); + assertTrue(CaveTestPacks.sample(floodedness, context.noises(), SEA_X, SEA_FLOOR + 6, 4) > FULLY_FLOODED); + assertEquals(PARTIAL, CaveTestPacks.sample(floodedness, context.noises(), SEA_X, SEA_FLOOR - 24, 4), 1.0e-6); + assertEquals(PARTIAL, CaveTestPacks.sample(floodedness, context.noises(), LAND_X, FLUID_HEIGHT - 10, 4), 1.0e-6); + } + @Test public void lavaAboveMinus10ReportsLimit() throws Exception { PackCompileContext context = compile(CaveTestPacks.dimension(60), CaveTestPacks.constantProfile(-0.1).setAllowLava(true)); @@ -115,11 +181,17 @@ public class AquiferCompilerTest { } private PackCompileContext compile(JsonObject dimension, IrisCaveProfile profile) throws Exception { + return compile(dimension, profile, SURFACE, SURFACE); + } + + private PackCompileContext compile(JsonObject dimension, IrisCaveProfile profile, Field height, Field estimate) throws Exception { File pack = CaveTestPacks.pack(temporaryFolder.newFolder("pack"), dimension, CaveTestPacks.region(List.of("cave-only"), null), Map.of("cave-only", profile)); IrisData data = IrisData.openRuntime(pack); try { PackCompileContext context = context(data, "cave-only"); + context.setHeight(height); + context.setSurfaceEstimate(estimate); new CaveStage().apply(context); return context; } finally { diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveStageTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveStageTest.java index 4bf331c74..2e4072068 100644 --- a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveStageTest.java +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveStageTest.java @@ -133,7 +133,14 @@ public class CaveStageTest { new CaveStage().apply(context); assertEquals(new Field.Const(1.0e6), context.caves()); - assertEquals(AquiferDef.dry(SURFACE), context.aquifer()); + AquiferDef aquifer = context.aquifer(); + AquiferDef vanilla = AquiferDef.dry(SURFACE); + assertEquals(vanilla.barrier(), aquifer.barrier()); + assertEquals(vanilla.spread(), aquifer.spread()); + assertEquals(vanilla.lava(), aquifer.lava()); + assertEquals(vanilla.exclusion(), aquifer.exclusion()); + assertEquals(vanilla.surfaceLevel(), aquifer.surfaceLevel()); + assertEquals(-1.0, CaveTestPacks.sample(aquifer.floodedness(), context.noises(), 4, 40, 4), 0.0); assertTrue(context.diagnostics().isEmpty()); } finally { data.close(); diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/decoration/DecorationStageTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/decoration/DecorationStageTest.java index 65bf00935..640a81189 100644 --- a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/decoration/DecorationStageTest.java +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/decoration/DecorationStageTest.java @@ -1,9 +1,11 @@ package art.arcane.iris.pack.datapack.export.compile.decoration; +import art.arcane.iris.generation.noise.IrisGeneratorStyle; import art.arcane.iris.pack.datapack.DataVersion; import art.arcane.iris.pack.datapack.export.ExportTestPacks; import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; import art.arcane.iris.pack.datapack.export.compile.StageTestPacks; +import art.arcane.iris.pack.datapack.export.compile.noise.VanillaNoiseModel; import art.arcane.iris.pack.datapack.export.compile.surface.MaterialProgramCompiler; import art.arcane.iris.pack.datapack.export.compile.surface.SurfaceStage; import art.arcane.iris.pack.datapack.export.ir.BiomeDef; @@ -13,6 +15,9 @@ import art.arcane.iris.pack.datapack.export.ir.Field; import art.arcane.iris.pack.datapack.export.ir.JsonResource; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.noise.CNG; +import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; @@ -27,6 +32,7 @@ import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.function.BiPredicate; import java.util.function.Consumer; import static org.junit.Assert.assertArrayEquals; @@ -132,6 +138,167 @@ public class DecorationStageTest { modern.under("worldgen/feature/decorator/").getFirst().json().toString()); } + @Test + public void aquaticDecoratorsPlaceOnlyUnderwater() throws Exception { + Map biomes = new LinkedHashMap<>(); + biomes.put("reef", "{\"decorators\":[" + decorator("seagrass", 0.2) + "," + + "{\"palette\":[{\"block\":\"kelp_plant\"}],\"chance\":0.02,\"stackMin\":2,\"stackMax\":6}," + + "{\"palette\":[{\"block\":\"kelp_plant\"}],\"chance\":0.03,\"stackMin\":2,\"stackMax\":6,\"partOf\":\"SEA_FLOOR\"}]}"); + Result result = decorate(biomes, DataVersion.V26_3, context -> { + }); + + List placed = result.under("worldgen/placed_feature/decorator/"); + assertEquals(3, placed.size()); + for (JsonResource resource : placed) { + JsonArray placement = resource.json().getAsJsonObject().getAsJsonArray("placement"); + assertEquals(resource.path(), Placements.OCEAN_FLOOR, + StageTestPacks.ofType(placement, "minecraft:heightmap").getFirst().get("heightmap").getAsString()); + JsonObject filter = StageTestPacks.ofType(placement, "minecraft:block_predicate_filter").getLast(); + assertFalse(resource.path(), StageTestPacks.ofType(filter, "minecraft:matching_fluids").isEmpty()); + assertTrue(resource.path(), StageTestPacks.ofType(filter, "minecraft:matching_block_tag").isEmpty()); + } + for (JsonResource resource : result.under("worldgen/feature/decorator/")) { + JsonObject feature = resource.json().getAsJsonObject(); + if (feature.has("allowed_placement")) { + assertEquals(resource.path(), "minecraft:matching_fluids", + feature.getAsJsonObject("allowed_placement").get("type").getAsString()); + } + } + } + + @Test + public void shoreDecoratorsGrowOnSolidGroundBesideWater() throws Exception { + Map biomes = new LinkedHashMap<>(); + biomes.put("beach", "{\"decorators\":[{\"palette\":[{\"block\":\"sugar_cane\"}],\"partOf\":\"SHORE_LINE\",\"chance\":0.5," + + "\"stackMin\":1,\"stackMax\":4}]}"); + Result result = decorate(biomes, DataVersion.V26_3, context -> { + }); + + JsonArray placement = result.under("worldgen/placed_feature/decorator/").getFirst().json().getAsJsonObject().getAsJsonArray("placement"); + JsonObject filter = StageTestPacks.ofType(placement, "minecraft:block_predicate_filter").getLast(); + List supports = new ArrayList<>(); + for (JsonObject solid : StageTestPacks.ofType(filter, "minecraft:solid")) { + supports.add(solid.get("offset").toString()); + } + assertEquals(List.of("[0,-1,0]"), supports); + assertEquals(4, StageTestPacks.ofType(filter, "minecraft:matching_fluids").size()); + } + + @Test + public void tallPlantsPlaceBothHalves() throws Exception { + Map biomes = new LinkedHashMap<>(); + biomes.put("reef", "{\"decorators\":[{\"palette\":[{\"block\":\"tall_seagrass\",\"data\":{\"half\":\"lower\"}}]," + + "\"topPalette\":[{\"block\":\"tall_seagrass\",\"data\":{\"half\":\"upper\"}}],\"partOf\":\"SEA_FLOOR\",\"chance\":0.2}]}"); + Result result = decorate(biomes, DataVersion.V26_3, context -> { + }); + + JsonObject feature = result.under("worldgen/feature/decorator/").getFirst().json().getAsJsonObject(); + assertEquals(feature.toString(), "minecraft:simple_block", feature.get("type").getAsString()); + assertEquals("{\"id\":\"minecraft:tall_seagrass\",\"properties\":{\"half\":\"lower\"}}", feature.get("to_place").toString()); + JsonArray placement = result.under("worldgen/placed_feature/decorator/").getFirst().json().getAsJsonObject().getAsJsonArray("placement"); + List water = new ArrayList<>(); + for (JsonObject fluid : StageTestPacks.ofType(placement, "minecraft:matching_fluids")) { + water.add(fluid.has("offset") ? fluid.get("offset").toString() : "[0,0,0]"); + } + assertEquals(List.of("[0,0,0]", "[0,1,0]"), water); + } + + @Test + public void waterlogFollowsWhereTheDecoratorPlaces() throws Exception { + Map biomes = new LinkedHashMap<>(); + biomes.put("reef", "{\"decorators\":[" + decorator("sea_pickle[pickles=2,waterlogged=false]", 0.2) + "," + + "{\"palette\":[{\"block\":\"oak_stairs[waterlogged=true]\"},{\"block\":\"short_grass\"}],\"chance\":0.1}]}"); + Result result = decorate(biomes, DataVersion.V26_3, context -> { + }); + + String pickles = result.resource("data/iris_export/worldgen/feature/decorator/sea_pickle.json").toString(); + String stairs = result.resource("data/iris_export/worldgen/feature/decorator/oak_stairs.json").toString(); + assertTrue(pickles, pickles.contains("\"waterlogged\":\"true\"")); + assertTrue(stairs, stairs.contains("\"waterlogged\":\"false\"")); + } + + @Test + public void paletteEndsCarryHalfWeightLikeIrisRounding() throws Exception { + Map biomes = new LinkedHashMap<>(); + biomes.put("meadow", "{\"decorators\":[{\"palette\":[{\"block\":\"bush\"},{\"block\":\"air\",\"weight\":4}],\"chance\":0.5}," + + "{\"palette\":[{\"block\":\"fern\",\"weight\":2},{\"block\":\"short_grass\",\"weight\":3}],\"chance\":0.2,\"partOf\":\"SHORE_LINE\"}]}"); + Result result = decorate(biomes, DataVersion.V26_3, context -> { + }); + + JsonArray placement = placement(result, "data/iris_export/worldgen/placed_feature/decorator/bush.json"); + assertEquals(0.5 / 8.0, StageTestPacks.ofType(placement, "minecraft:random_chance").getFirst().get("chance").getAsDouble(), 1.0e-6); + JsonArray weighted = result.resource("data/iris_export/worldgen/feature/decorator/fern.json").getAsJsonObject() + .getAsJsonObject("to_place").getAsJsonArray("entries"); + assertEquals(3, weighted.get(0).getAsJsonObject().get("weight").getAsInt()); + assertEquals(5, weighted.get(1).getAsJsonObject().get("weight").getAsInt()); + } + + @Test + public void styledDecoratorPlacesAtStyleCoverage() throws Exception { + String style = "{\"style\":\"SIMPLEX\",\"zoom\":0.2,\"fracture\":{\"style\":\"STATIC\",\"multiplier\":8}}"; + Map biomes = new LinkedHashMap<>(); + biomes.put("meadow", "{\"decorators\":[{\"palette\":[{\"block\":\"poppy\"}],\"chance\":0.2,\"style\":" + style + "}]}"); + Result result = decorate(biomes, DataVersion.V26_3, context -> { + }); + + JsonArray placement = placement(result, "data/iris_export/worldgen/placed_feature/decorator/poppy.json"); + double exported = StageTestPacks.ofType(placement, "minecraft:random_chance").getFirst().get("chance").getAsDouble(); + double coverage = irisCoverage(style, 0.2); + assertTrue("coverage " + coverage, coverage < 0.1); + assertEquals(coverage, exported, coverage * 0.2); + assertEquals("{\"type\":\"minecraft:simple_block\",\"to_place\":{\"id\":\"minecraft:poppy\"}}", + result.resource("data/iris_export/worldgen/feature/decorator/poppy.json").toString()); + } + + @Test + public void decoratorWhoseStyleNeverPassesIsLeftOut() throws Exception { + Map biomes = new LinkedHashMap<>(); + biomes.put("meadow", "{\"decorators\":[{\"palette\":[{\"block\":\"poppy\"}],\"chance\":0.5,\"style\":{\"style\":\"FLAT\"}}," + + decorator("short_grass", 0.3) + "]}"); + Result result = decorate(biomes, DataVersion.V26_3, context -> { + }); + + assertEquals(List.of("iris_export:decorator/short_grass"), step(result.biomes().getFirst(), GenerationStep.VEGETAL_DECORATION)); + } + + @Test + public void clusteredStyleFollowsFittedNoisePattern() throws Exception { + String style = "{\"style\":\"SIMPLEX\",\"zoom\":4}"; + Map biomes = new LinkedHashMap<>(); + biomes.put("meadow", "{\"decorators\":[{\"palette\":[{\"block\":\"poppy\"},{\"block\":\"dandelion\",\"weight\":2}],\"chance\":0.3,\"style\":" + + style + "}]}"); + Result result = decorate(biomes, DataVersion.V26_3, context -> { + }); + + JsonObject provider = result.resource("data/iris_export/worldgen/feature/decorator/poppy.json").getAsJsonObject() + .getAsJsonObject("to_place"); + assertEquals("minecraft:noise_threshold", provider.get("type").getAsString()); + assertEquals("minecraft:air", provider.getAsJsonObject("default_state").get("id").getAsString()); + assertEquals(0.0, provider.get("high_chance").getAsDouble(), 0.0); + List low = new ArrayList<>(); + for (JsonElement state : provider.getAsJsonArray("low_states")) { + low.add(state.getAsJsonObject().get("id").getAsString()); + } + assertEquals(List.of("minecraft:poppy", "minecraft:dandelion", "minecraft:dandelion", "minecraft:dandelion"), low); + assertTrue(StageTestPacks.ofType(placement(result, "data/iris_export/worldgen/placed_feature/decorator/poppy.json"), + "minecraft:random_chance").isEmpty()); + + JsonObject noise = provider.getAsJsonObject("noise"); + List amplitudes = new ArrayList<>(); + for (JsonElement amplitude : noise.getAsJsonArray("amplitude_modifiers")) { + amplitudes.add(amplitude.getAsDouble()); + } + VanillaNoiseModel model = VanillaNoiseModel.create("iris_export:test", noise.get("base_octave").getAsInt(), amplitudes, 99L, DataVersion.V26_3); + double scale = provider.get("scale").getAsDouble(); + double threshold = provider.get("threshold").getAsDouble(); + GatePattern iris = irisPattern(style, 0.3); + GatePattern vanilla = pattern((x, z) -> model.sample(x * scale, 70.0 * scale, z * scale) < threshold); + assertTrue("iris coverage " + iris.coverage(), iris.coverage() > 0.1 && iris.coverage() < 0.5); + assertEquals(iris.coverage(), vanilla.coverage(), 0.03); + assertTrue("iris patch " + iris.patch() + " vanilla " + vanilla.patch(), + vanilla.patch() > iris.patch() * 0.7 && vanilla.patch() < iris.patch() * 1.4); + } + @Test public void columnWinnerSharesFollowIrisReservoirPick() { assertArrayEquals(new double[]{0.5, 0.5}, DecoratorCompiler.columnShares(new double[]{1.0, 1.0}), 1.0e-12); @@ -163,12 +330,40 @@ public class DecorationStageTest { JsonObject ore = result.resource("data/iris_export/worldgen/feature/deposit/coal_ore.json").getAsJsonObject(); assertEquals("minecraft:ore", ore.get("type").getAsString()); assertEquals(8, ore.get("size").getAsInt()); - JsonObject target = ore.getAsJsonArray("targets").get(0).getAsJsonObject(); + JsonObject target = ore.getAsJsonArray("targets").get(1).getAsJsonObject(); assertEquals("{\"predicate_type\":\"minecraft:tag_match\",\"tag\":\"iris_export:deposit_host\"}", target.get("target").toString()); assertEquals("{\"id\":\"minecraft:coal_ore\"}", target.get("state").toString()); assertNotNull(result.resource("data/iris_export/tags/block/deposit_host.json")); } + @Test + public void depositOresFollowTheirHostStone() throws Exception { + String explicit = "{\"palette\":[{\"block\":\"deepslate_redstone_ore[lit=false]\"}],\"minHeight\":0,\"maxHeight\":40," + + "\"minPerChunk\":1,\"maxPerChunk\":2,\"minSize\":4,\"maxSize\":4,\"replaceableBlocks\":[\"stone\",\"deepslate\",\"tuff\"]}"; + Map biomes = new LinkedHashMap<>(); + biomes.put("hills", "{\"deposits\":[" + COAL + "," + explicit + "]}"); + Result result = decorate(biomes, DataVersion.V26_3, context -> { + }); + + JsonArray coal = result.resource("data/iris_export/worldgen/feature/deposit/coal_ore.json").getAsJsonObject().getAsJsonArray("targets"); + assertEquals(2, coal.size()); + assertEquals("{\"predicate_type\":\"minecraft:block_match\",\"block\":\"minecraft:deepslate\"}", + coal.get(0).getAsJsonObject().get("target").toString()); + assertEquals("{\"id\":\"minecraft:deepslate_coal_ore\"}", coal.get(0).getAsJsonObject().get("state").toString()); + assertEquals("{\"id\":\"minecraft:coal_ore\"}", coal.get(1).getAsJsonObject().get("state").toString()); + + JsonArray redstone = result.resource("data/iris_export/worldgen/feature/deposit/deepslate_redstone_ore.json").getAsJsonObject() + .getAsJsonArray("targets"); + Map byHost = new LinkedHashMap<>(); + for (JsonElement element : redstone) { + JsonObject target = element.getAsJsonObject(); + byHost.put(target.getAsJsonObject("target").get("block").getAsString(), target.get("state").toString()); + } + assertEquals("{\"id\":\"minecraft:redstone_ore\",\"properties\":{\"lit\":\"false\"}}", byHost.get("minecraft:stone")); + assertEquals("{\"id\":\"minecraft:deepslate_redstone_ore\",\"properties\":{\"lit\":\"false\"}}", byHost.get("minecraft:deepslate")); + assertEquals("{\"id\":\"minecraft:redstone_ore\",\"properties\":{\"lit\":\"false\"}}", byHost.get("minecraft:tuff")); + } + @Test public void vanillaOreVeinsDisabled() throws Exception { Map biomes = new LinkedHashMap<>(); @@ -260,6 +455,56 @@ public class DecorationStageTest { return "{\"palette\":[{\"block\":\"" + block + "\"}],\"chance\":" + chance + "}"; } + private static double irisCoverage(String styleJson, double chance) { + return irisPattern(styleJson, chance).coverage(); + } + + private static GatePattern irisPattern(String styleJson, double chance) { + IrisGeneratorStyle style = new Gson().fromJson(styleJson, IrisGeneratorStyle.class); + CNG generator = style.create(new RNG(4242L), null); + double zoom = style.getZoom(); + return pattern((x, z) -> generator.fitDouble(0.0, 1.0, x / zoom, z / zoom) <= chance); + } + + private static GatePattern pattern(BiPredicate gate) { + int[] lags = {1, 2, 4, 8, 16, 32, 64, 128}; + int hits = 0; + int total = 0; + int[] both = new int[lags.length]; + for (int i = 0; i < 150; i++) { + for (int j = 0; j < 150; j++) { + double x = i * 197.0 + 3.0; + double z = j * 173.0 - 11.0; + total++; + if (!gate.test(x, z)) { + continue; + } + hits++; + for (int lag = 0; lag < lags.length; lag++) { + both[lag] += gate.test(x + lags[lag], z) ? 1 : 0; + both[lag] += gate.test(x, z + lags[lag]) ? 1 : 0; + } + } + } + double coverage = (double) hits / total; + double previousLag = 0.0; + double previousExcess = 1.0; + for (int lag = 0; lag < lags.length; lag++) { + double excess = ((double) both[lag] / (2.0 * hits) - coverage) / (1.0 - coverage); + if (excess < 0.5) { + double low = previousLag == 0.0 ? 0.0 : Math.log(previousLag); + double fraction = (previousExcess - 0.5) / (previousExcess - excess); + return new GatePattern(coverage, previousLag == 0.0 ? lags[lag] * fraction : Math.exp(low + (Math.log(lags[lag]) - low) * fraction)); + } + previousLag = lags[lag]; + previousExcess = excess; + } + return new GatePattern(coverage, lags[lags.length - 1]); + } + + private record GatePattern(double coverage, double patch) { + } + private static List step(BiomeDef biome, GenerationStep step) { List features = new ArrayList<>(); for (JsonElement feature : biome.json().getAsJsonArray("features").get(step.ordinal()).getAsJsonArray()) { diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectSourceTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectSourceTest.java new file mode 100644 index 000000000..8f64b3615 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectSourceTest.java @@ -0,0 +1,157 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import art.arcane.iris.generation.decoration.tree.TreeGrounding; +import art.arcane.iris.pack.datapack.export.TemplateBlock; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.structure.object.IrisObject; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ObjectSourceTest { + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @BeforeClass + public static void bindPlatform() { + IrisPlatforms.unbind(); + NativeBlockState stone = blockState("minecraft:stone"); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.block(anyString())).thenReturn(stone); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + when(platform.minecraftVersion()).thenReturn("26.3"); + IrisPlatforms.bind(platform); + } + + @AfterClass + public static void unbindPlatform() { + IrisPlatforms.unbind(); + } + + @Test + public void containedObjectKeepsItsBox() { + IrisObject object = new IrisObject(3, 3, 3); + object.setUnsigned(1, 0, 1, blockState("minecraft:oak_log[axis=y]")); + object.setUnsigned(0, 2, 2, blockState("minecraft:oak_leaves[distance=1,persistent=true,waterlogged=false]")); + List diagnostics = new ArrayList<>(); + + ObjectSource source = ObjectSource.of(object, "objects/bush", diagnostics); + + assertTrue(diagnostics.isEmpty()); + assertEquals(0, source.baseY()); + assertEquals(3, source.volume().sizeX()); + assertEquals(3, source.volume().sizeY()); + assertEquals(3, source.volume().sizeZ()); + assertEquals(2, source.volume().blocks().size()); + } + + @Test + public void waterlogIsLeftToThePlacementSite() { + IrisObject object = new IrisObject(1, 2, 1); + object.setUnsigned(0, 0, 0, blockState("minecraft:tube_coral_block")); + object.setUnsigned(0, 1, 0, blockState("minecraft:sea_pickle[pickles=2,waterlogged=true]")); + + ObjectSource source = ObjectSource.of(object, "objects/reef", new ArrayList<>()); + + TemplateBlock pickle = blockAt(source, 0, 1, 0); + assertEquals("false", pickle.state().properties().get("waterlogged")); + assertEquals("2", pickle.state().properties().get("pickles")); + } + + @Test + public void rowsAndColumnsPastTheNominalBoxAreKeptAroundTheCenter() { + IrisObject object = new IrisObject(3, 3, 3); + object.setUnsigned(1, -1, 1, blockState("minecraft:oak_log[axis=y]")); + object.setUnsigned(1, 0, 1, blockState("minecraft:oak_log[axis=y]")); + object.setUnsigned(-1, 1, 1, blockState("minecraft:oak_log[axis=x]")); + List diagnostics = new ArrayList<>(); + + ObjectSource source = ObjectSource.of(object, "objects/tree", diagnostics); + + assertTrue(diagnostics.isEmpty()); + assertEquals(3, source.volume().blocks().size()); + assertEquals(-1, source.baseY()); + int centerX = source.volume().sizeX() / 2; + int centerZ = source.volume().sizeZ() / 2; + TemplateBlock root = blockAt(source, centerX, 0, centerZ); + assertEquals("minecraft:oak_log", root.state().name()); + TemplateBlock branch = blockAt(source, centerX - 2, 2, centerZ); + assertEquals("x", branch.state().properties().get("axis")); + + BakedVariant variant = new ObjectVariantBaker().bake("iris_export:object/tree/a", source, VariantSpec.builder().build()); + + assertEquals(-1, variant.baseY()); + assertEquals(3, variant.volume().blocks().size()); + } + + @Test + public void treeRootsFollowTheTemplateShift() { + IrisObject object = new IrisObject(3, 3, 3); + NativeBlockState root = blockState("minecraft:oak_wood[axis=y]"); + object.setUnsigned(1, -1, 1, root); + object.setUnsigned(0, -2, 1, root); + object.setUnsigned(0, -1, 1, root); + object.setUnsigned(1, 0, 1, blockState("minecraft:oak_log[axis=y]")); + object.setTreeGrounding(TreeGrounding.create(List.of( + new TreeGrounding.Root(0, -2, 0, root), + new TreeGrounding.Root(-1, -3, 0, root), + new TreeGrounding.Root(-1, -2, 0, root)), 3, Set.of())); + + ObjectSource source = ObjectSource.of(object, "procedural/tree", new ArrayList<>()); + + assertEquals(-2, source.baseY()); + assertEquals(3, source.roots().reach()); + assertEquals(List.of("0,0,1", "1,1,1"), cells(source.roots())); + assertEquals("minecraft:oak_wood", source.roots().cells().getFirst().state().name()); + } + + @Test + public void objectsWithoutGroundingHaveNoRoots() { + IrisObject object = new IrisObject(1, 1, 1); + object.setUnsigned(0, 0, 0, blockState("minecraft:stone")); + + assertFalse(ObjectSource.of(object, "objects/rock", new ArrayList<>()).roots().present()); + } + + private static List cells(TreeRoots roots) { + List cells = new ArrayList<>(); + for (TemplateBlock cell : roots.cells()) { + cells.add(cell.x() + "," + cell.y() + "," + cell.z()); + } + return cells; + } + + private static TemplateBlock blockAt(ObjectSource source, int x, int y, int z) { + for (TemplateBlock block : source.volume().blocks()) { + if (block.x() == x && block.y() == y && block.z() == z) { + return block; + } + } + throw new AssertionError("No block at " + x + "," + y + "," + z + " in " + source.volume().blocks()); + } + + private static NativeBlockState blockState(String key) { + NativeBlockState state = mock(NativeBlockState.class); + when(state.key()).thenReturn(key); + when(state.namespace()).thenReturn(key.substring(0, key.indexOf(':'))); + return state; + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectStageTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectStageTest.java index 2173e0ad7..cab898cee 100644 --- a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectStageTest.java +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectStageTest.java @@ -129,6 +129,87 @@ public class ObjectStageTest { } } + @Test + public void proceduralTreesExportAsTemplates() throws Exception { + File pack = pack(); + CompatTestPlatform.write(pack, "biomes/plains.json", """ + {"name":"Plains","proceduralObjects":{"trees":[{"name":"field-oak","chance":0.5,"density":2,"variants":2,"seed":7, + "trunk":"minecraft:oak_log","leaves":"minecraft:oak_leaves","heightMin":5,"heightMax":7}]}} + """); + IrisData data = IrisData.openRuntime(pack); + try { + ExportIr ir = compile(data, DataVersion.V26_3, true); + + JsonArray plains = features(ir, "plains"); + assertEquals(1, plains.get(GenerationStep.VEGETAL_DECORATION.ordinal()).getAsJsonArray().size()); + long templates = ir.binaryResources().stream() + .filter(template -> template.path().contains("/procedural/") && template.path().contains("field-oak")) + .count(); + assertTrue("procedural templates: " + templates, templates >= 2); + } finally { + data.close(); + } + } + + @Test + public void proceduralTreeRootsReachDownToTheTerrain() throws Exception { + File pack = pack(); + CompatTestPlatform.write(pack, "biomes/plains.json", """ + {"name":"Plains","proceduralObjects":{"trees":[{"name":"field-oak","chance":0.5,"density":2,"variants":2,"seed":7, + "trunk":"minecraft:oak_log","leaves":"minecraft:oak_leaves","heightMin":5,"heightMax":7,"rootStyle":"TAPROOT","rootDepth":3}]}} + """); + IrisData data = IrisData.openRuntime(pack); + try { + ExportIr ir = compile(data, DataVersion.V26_3, true); + + String placedId = features(ir, "plains").get(GenerationStep.VEGETAL_DECORATION.ordinal()).getAsJsonArray().get(0).getAsString(); + JsonObject placed = json(ir, "data/iris_export/worldgen/placed_feature/" + placedId.substring(NAMESPACE.length() + 1) + ".json"); + String featureId = placed.get("feature").getAsString(); + JsonObject feature = json(ir, "data/iris_export/worldgen/feature/" + featureId.substring(NAMESPACE.length() + 1) + ".json"); + assertEquals("minecraft:weighted_random_selector", feature.get("type").getAsString()); + Set pillars = new HashSet<>(); + for (JsonElement branch : feature.getAsJsonArray("features")) { + JsonObject entry = branch.getAsJsonObject().getAsJsonObject("data"); + JsonArray parts = entry.getAsJsonObject("feature").getAsJsonArray("features"); + for (int index = 1; index < parts.size(); index++) { + pillars.add(parts.get(index).getAsJsonObject().get("feature").getAsString()); + } + } + assertFalse(pillars.isEmpty()); + for (String pillar : pillars) { + assertTrue(pillar, paths(ir).contains("data/iris_export/worldgen/feature/" + pillar.substring(NAMESPACE.length() + 1) + ".json")); + } + } finally { + data.close(); + } + } + + @Test + public void proceduralObjectsSharedByBiomesShareTemplates() throws Exception { + String procedural = """ + "proceduralObjects":{"trees":[{"name":"field-oak","chance":0.5,"density":2,"variants":2,"seed":7, + "trunk":"minecraft:oak_log","leaves":"minecraft:oak_leaves","heightMin":5,"heightMax":7}]}"""; + File pack = pack(); + CompatTestPlatform.write(pack, "biomes/plains.json", "{\"name\":\"Plains\"," + procedural + "}"); + CompatTestPlatform.write(pack, "biomes/forest.json", "{\"name\":\"Forest\"," + procedural + "}"); + IrisData data = IrisData.openRuntime(pack); + try { + ExportIr ir = compile(data, DataVersion.V26_3, true); + + Set objects = new HashSet<>(); + for (BinaryResource template : ir.binaryResources()) { + if (template.path().contains("field-oak")) { + objects.add(template.path().substring(0, template.path().lastIndexOf('/'))); + } + } + assertEquals(objects.toString(), 2, objects.size()); + assertEquals(1, features(ir, "forest").get(GenerationStep.VEGETAL_DECORATION.ordinal()).getAsJsonArray().size()); + assertEquals(1, features(ir, "plains").get(GenerationStep.VEGETAL_DECORATION.ordinal()).getAsJsonArray().size()); + } finally { + data.close(); + } + } + @Test public void lootBoundPerBiome() throws Exception { File pack = pack(); diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectVariantBakerTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectVariantBakerTest.java index b9d5abe9a..e552b7c80 100644 --- a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectVariantBakerTest.java +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectVariantBakerTest.java @@ -38,8 +38,8 @@ public class ObjectVariantBakerTest { .build(); String id = ObjectVariantBaker.templateId(NAMESPACE, "trees/oak", spec, DataVersion.V26_3); - BakedVariant first = new ObjectVariantBaker().bake(id, tree, spec, "trees/oak", new ArrayList<>()); - BakedVariant second = new ObjectVariantBaker().bake(id, tree, spec, "trees/oak", new ArrayList<>()); + BakedVariant first = new ObjectVariantBaker().bake(id, new ObjectSource(tree, 0, TreeRoots.NONE), spec); + BakedVariant second = new ObjectVariantBaker().bake(id, new ObjectSource(tree, 0, TreeRoots.NONE), spec); assertEquals(first, second); assertEquals(id, ObjectVariantBaker.templateId(NAMESPACE, "trees/oak", spec, DataVersion.V26_3)); @@ -58,7 +58,7 @@ public class ObjectVariantBakerTest { block(0, 1, 0, "minecraft:cave_air"))); BakedVariant variant = new ObjectVariantBaker() - .bake("iris_export:object/rock/a", volume, VariantSpec.builder().build(), "rock", new ArrayList<>()); + .bake("iris_export:object/rock/a", new ObjectSource(volume, 0, TreeRoots.NONE), VariantSpec.builder().build()); assertEquals(List.of("minecraft:stone"), states(variant.volume())); assertEquals(2, variant.volume().sizeX()); @@ -66,6 +66,30 @@ public class ObjectVariantBakerTest { assertEquals(0, variant.baseY()); } + @Test + public void plantsWithNothingUnderThemAreLeftOut() { + TemplateVolume reef = new TemplateVolume(3, 4, 3, List.of( + block(1, 0, 1, "minecraft:sea_pickle[pickles=1,waterlogged=false]"), + block(0, 0, 0, "minecraft:tube_coral_block"), + block(0, 1, 0, "minecraft:tube_coral_block"), + block(0, 2, 0, "minecraft:sea_pickle[pickles=2,waterlogged=false]"), + block(2, 2, 2, "minecraft:sea_pickle[pickles=3,waterlogged=false]"), + block(2, 1, 0, "minecraft:tube_coral_fan[waterlogged=false]"), + block(1, 1, 0, "minecraft:tube_coral_wall_fan[facing=east,waterlogged=false]"), + block(1, 2, 0, "minecraft:tube_coral_wall_fan[facing=west,waterlogged=false]"), + block(2, 3, 2, "minecraft:tube_coral_block"))); + + BakedVariant variant = new ObjectVariantBaker() + .bake("iris_export:object/reef/a", new ObjectSource(reef, 0, TreeRoots.NONE), VariantSpec.builder().build()); + + Set kept = new HashSet<>(); + for (TemplateBlock block : variant.volume().blocks()) { + kept.add(block.x() + "," + block.y() + "," + block.z() + " " + block.state().name()); + } + assertEquals(Set.of("1,0,1 minecraft:sea_pickle", "0,0,0 minecraft:tube_coral_block", "0,1,0 minecraft:tube_coral_block", + "0,2,0 minecraft:sea_pickle", "1,1,0 minecraft:tube_coral_wall_fan", "2,3,2 minecraft:tube_coral_block"), kept); + } + @Test public void quarterRotationMovesBlocksAndFacing() { TemplateVolume volume = new TemplateVolume(3, 1, 3, List.of( @@ -73,7 +97,7 @@ public class ObjectVariantBakerTest { block(1, 0, 0, "minecraft:oak_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]"))); VariantSpec spec = VariantSpec.builder().rotation(new VariantSpec.Rotation(0, 90, 0)).build(); - BakedVariant variant = new ObjectVariantBaker().bake("iris_export:object/s/a", volume, spec, "s", new ArrayList<>()); + BakedVariant variant = new ObjectVariantBaker().bake("iris_export:object/s/a", new ObjectSource(volume, 0, TreeRoots.NONE), spec); TemplateBlock stairs = variant.volume().blocks().stream() .filter(block -> block.state().name().equals("minecraft:oak_stairs")).findFirst().orElseThrow(); @@ -89,7 +113,7 @@ public class ObjectVariantBakerTest { block(0, 1, 0, "minecraft:oak_slab[type=bottom,waterlogged=false]"))); VariantSpec spec = VariantSpec.builder().rotation(new VariantSpec.Rotation(180, 0, 0)).build(); - BakedVariant variant = new ObjectVariantBaker().bake("iris_export:object/f/a", volume, spec, "f", new ArrayList<>()); + BakedVariant variant = new ObjectVariantBaker().bake("iris_export:object/f/a", new ObjectSource(volume, 0, TreeRoots.NONE), spec); TemplateBlock slab = variant.volume().blocks().getFirst(); assertEquals("minecraft:oak_slab", slab.state().name()); @@ -104,7 +128,7 @@ public class ObjectVariantBakerTest { block(1, 1, 1, "minecraft:oak_log[axis=y]"))); VariantSpec scaled = VariantSpec.builder().scale(2).build(); - BakedVariant big = new ObjectVariantBaker().bake("iris_export:object/l/a", volume, scaled, "l", new ArrayList<>()); + BakedVariant big = new ObjectVariantBaker().bake("iris_export:object/l/a", new ObjectSource(volume, 0, TreeRoots.NONE), scaled); assertEquals(16, big.volume().blocks().size()); assertEquals(4, big.volume().sizeY()); @@ -124,8 +148,8 @@ public class ObjectVariantBakerTest { ObjectEdit half = new ObjectEdit(List.of(VanillaBlockState.parse("minecraft:stone_bricks")), false, List.of(new WeightedState(VanillaBlockState.parse("minecraft:mossy_stone_bricks"), 1)), 0.5); - BakedVariant variant = new ObjectVariantBaker().bake("iris_export:object/floor/a", floor, - VariantSpec.builder().edits(List.of(always, half)).build(), "floor", new ArrayList<>()); + BakedVariant variant = new ObjectVariantBaker().bake("iris_export:object/floor/a", new ObjectSource(floor, 0, TreeRoots.NONE), + VariantSpec.builder().edits(List.of(always, half)).build()); Set names = new HashSet<>(); int mossy = 0; @@ -141,8 +165,8 @@ public class ObjectVariantBakerTest { @Test public void basesAlignAcrossVariants() { - BakedVariant low = new BakedVariant("iris_export:object/a/low", new TemplateVolume(1, 3, 1, List.of(block(0, 0, 0, "minecraft:stone"))), -2); - BakedVariant flat = new BakedVariant("iris_export:object/a/flat", new TemplateVolume(1, 1, 1, List.of(block(0, 0, 0, "minecraft:stone"))), 0); + BakedVariant low = new BakedVariant("iris_export:object/a/low", new TemplateVolume(1, 3, 1, List.of(block(0, 0, 0, "minecraft:stone"))), -2, TreeRoots.NONE); + BakedVariant flat = new BakedVariant("iris_export:object/a/flat", new TemplateVolume(1, 1, 1, List.of(block(0, 0, 0, "minecraft:stone"))), 0, TreeRoots.NONE); List aligned = ObjectVariantBaker.alignBases(List.of(low, flat)); @@ -154,6 +178,73 @@ public class ObjectVariantBakerTest { assertFalse(aligned.get(1).volume().blocks().isEmpty()); } + @Test + public void rootsSurviveAnUnchangedBake() { + TemplateVolume volume = new TemplateVolume(3, 2, 3, List.of( + block(1, 0, 1, "minecraft:oak_wood[axis=y]"), + block(2, 0, 1, "minecraft:oak_wood[axis=y]"), + block(1, 1, 1, "minecraft:oak_log[axis=y]"))); + TreeRoots roots = new TreeRoots(List.of(block(1, 0, 1, "minecraft:oak_wood[axis=y]"), + block(2, 0, 1, "minecraft:oak_wood[axis=y]")), 3); + + BakedVariant variant = new ObjectVariantBaker() + .bake("iris_export:object/tree/a", new ObjectSource(volume, 0, roots), VariantSpec.builder().build()); + + assertEquals(roots, variant.roots()); + } + + @Test + public void rootsTurnWithTheirBlocks() { + TemplateVolume volume = new TemplateVolume(3, 2, 3, List.of( + block(1, 0, 1, "minecraft:oak_wood[axis=y]"), + block(2, 0, 1, "minecraft:spruce_wood[axis=x]"), + block(1, 1, 1, "minecraft:oak_log[axis=y]"))); + TreeRoots roots = new TreeRoots(List.of(block(2, 0, 1, "minecraft:spruce_wood[axis=x]")), 2); + VariantSpec spec = VariantSpec.builder().rotation(new VariantSpec.Rotation(0, 90, 0)).build(); + + BakedVariant variant = new ObjectVariantBaker().bake("iris_export:object/tree/b", new ObjectSource(volume, 0, roots), spec); + + TemplateBlock moved = variant.volume().blocks().stream() + .filter(block -> block.state().name().equals("minecraft:spruce_wood")).findFirst().orElseThrow(); + assertEquals(1, variant.roots().cells().size()); + TemplateBlock root = variant.roots().cells().getFirst(); + assertEquals(List.of(moved.x(), moved.y(), moved.z()), List.of(root.x(), root.y(), root.z())); + assertEquals("z", root.state().properties().get("axis")); + assertEquals(2, variant.roots().reach()); + } + + @Test + public void scaledRootsKeepTheLowestCellPerColumnAndStretchTheirReach() { + TemplateVolume volume = new TemplateVolume(2, 2, 1, List.of( + block(0, 0, 0, "minecraft:oak_wood[axis=y]"), + block(0, 1, 0, "minecraft:oak_log[axis=y]"), + block(1, 1, 0, "minecraft:oak_log[axis=y]"))); + TreeRoots roots = new TreeRoots(List.of(block(0, 0, 0, "minecraft:oak_wood[axis=y]")), 3); + + BakedVariant variant = new ObjectVariantBaker().bake("iris_export:object/tree/c", new ObjectSource(volume, 0, roots), + VariantSpec.builder().scale(2).build()); + + assertEquals(6, variant.roots().reach()); + assertEquals(4, variant.roots().cells().size()); + for (TemplateBlock cell : variant.roots().cells()) { + assertEquals(0, cell.y()); + assertEquals("minecraft:oak_wood", cell.state().name()); + } + } + + @Test + public void alignedBasesLiftRoots() { + TreeRoots roots = new TreeRoots(List.of(block(0, 0, 0, "minecraft:oak_wood[axis=y]")), 2); + BakedVariant low = new BakedVariant("iris_export:object/r/low", new TemplateVolume(1, 3, 1, List.of(block(0, 0, 0, "minecraft:stone"))), -2, TreeRoots.NONE); + BakedVariant rooted = new BakedVariant("iris_export:object/r/rooted", new TemplateVolume(1, 1, 1, + List.of(block(0, 0, 0, "minecraft:oak_wood[axis=y]"))), 0, roots); + + BakedVariant lifted = ObjectVariantBaker.alignBases(List.of(low, rooted)).get(1); + + assertEquals(2, lifted.roots().cells().getFirst().y()); + assertEquals(2, lifted.roots().reach()); + } + private static TemplateVolume tree() { List blocks = new ArrayList<>(); for (int y = 0; y < 5; y++) { diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/PlacementCompilerTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/PlacementCompilerTest.java index 4e5c0cc67..c34573989 100644 --- a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/PlacementCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/PlacementCompilerTest.java @@ -4,6 +4,7 @@ import art.arcane.iris.pack.datapack.DataVersion; import art.arcane.iris.pack.datapack.export.TemplateBlock; import art.arcane.iris.pack.datapack.export.TemplateVolume; import art.arcane.iris.pack.datapack.export.VanillaBlockState; +import art.arcane.iris.pack.datapack.export.compile.StageTestPacks; import art.arcane.iris.pack.datapack.export.compile.decoration.GenerationStep; import art.arcane.iris.pack.datapack.export.ir.DimensionDef; import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; @@ -21,9 +22,11 @@ import org.junit.Test; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Random; +import java.util.Set; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -59,17 +62,244 @@ public class PlacementCompilerTest { assertFalse(templates.get(0).getAsJsonObject().getAsJsonObject("data").has("rotations")); JsonObject placed = placed(compiled); assertEquals(List.of("minecraft:random_chance", "minecraft:count", "minecraft:in_square", "minecraft:heightmap", - "minecraft:block_predicate_filter", "minecraft:offset", "minecraft:biome"), types(placed)); + "minecraft:block_predicate_filter", "minecraft:block_predicate_filter", "minecraft:offset", "minecraft:biome"), types(placed)); JsonArray modifiers = placed.getAsJsonArray("placement"); assertEquals(0.5, modifiers.get(0).getAsJsonObject().get("chance").getAsDouble(), 1e-6); assertEquals(3, modifiers.get(1).getAsJsonObject().get("count").getAsInt()); - assertEquals("WORLD_SURFACE_WG", modifiers.get(3).getAsJsonObject().get("heightmap").getAsString()); - assertEquals(1, modifiers.get(5).getAsJsonObject().get("y").getAsInt()); - JsonObject support = modifiers.get(4).getAsJsonObject().getAsJsonObject("predicate"); - assertEquals("minecraft:volume_match", support.get("type").getAsString()); + assertEquals("OCEAN_FLOOR_WG", modifiers.get(3).getAsJsonObject().get("heightmap").getAsString()); + assertEquals(1, modifiers.get(6).getAsJsonObject().get("y").getAsInt()); + assertTrue(StageTestPacks.ofType(placed, "minecraft:volume_match").isEmpty()); assertTrue(diagnostics.isEmpty()); } + @Test + public void surfaceObjectsStandOnTerrainInsteadOfCanopiesOrWater() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_HEIGHT, 1, 1); + + JsonObject placed = placed(compiler(DataVersion.V26_3).compile(input(placement, objects(5), List.of()), new ArrayList<>())); + JsonObject filter = filters(placed).getFirst(); + + assertEquals("OCEAN_FLOOR_WG", StageTestPacks.ofType(placed, "minecraft:heightmap").getFirst().get("heightmap").getAsString()); + List rejected = new ArrayList<>(); + for (JsonObject negated : StageTestPacks.ofType(filter, "minecraft:not")) { + JsonObject inner = negated.getAsJsonObject("predicate"); + rejected.add(inner.has("tag") ? inner.get("tag").getAsString() : inner.get("type").getAsString()); + } + assertTrue(rejected.toString(), rejected.contains("minecraft:leaves")); + assertTrue(rejected.toString(), rejected.contains("minecraft:logs")); + assertTrue(rejected.toString(), rejected.contains("minecraft:matching_fluids")); + assertFalse(StageTestPacks.ofType(filter, "minecraft:solid").isEmpty()); + } + + @Test + public void supportFollowsTheObjectBaseInsteadOfAFlatPlane() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_HEIGHT, 1, 1); + placement.setSurfaceSupportBuffer(1); + placement.setSurfaceSupportDepth(2); + TemplateBlock trunk = new TemplateBlock(4, 0, 4, VanillaBlockState.parse("minecraft:oak_log[axis=y]"), null); + TemplateBlock crown = new TemplateBlock(0, 5, 0, VanillaBlockState.parse("minecraft:oak_leaves"), null); + BakedVariant tree = new BakedVariant(NAMESPACE + ":object/tree/v", new TemplateVolume(9, 6, 9, List.of(trunk, crown)), 0, TreeRoots.NONE); + List objects = List.of(new ObjectTemplates("tree", List.of(new TemplateChoice(tree, List.of(0, 1, 2, 3), 1)))); + + JsonObject support = filters(placed(compiler(DataVersion.V26_3).compile(input(placement, objects, List.of()), new ArrayList<>()))).get(1); + + int reach = 0; + int depth = 0; + for (JsonObject solid : StageTestPacks.ofType(support, "minecraft:solid")) { + JsonArray offset = solid.getAsJsonArray("offset"); + reach = Math.max(reach, Math.max(Math.abs(offset.get(0).getAsInt()), Math.abs(offset.get(2).getAsInt()))); + depth = Math.min(depth, offset.get(1).getAsInt()); + } + assertEquals(1, reach); + assertEquals(-16, depth); + assertFalse(StageTestPacks.ofType(support, "minecraft:any_of").isEmpty()); + } + + @Test + public void supportAcceptsSteepGroundAnywhereInTheScanWindow() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_HEIGHT, 1, 1); + placement.setSurfaceSupportBuffer(2); + placement.setSurfaceSupportDepth(2); + TemplateBlock trunk = new TemplateBlock(0, 0, 4, VanillaBlockState.parse("minecraft:spruce_log[axis=y]"), null); + BakedVariant tree = new BakedVariant(NAMESPACE + ":object/tree/v", new TemplateVolume(9, 20, 9, List.of(trunk)), 0, TreeRoots.NONE); + List objects = List.of(new ObjectTemplates("tree", List.of(new TemplateChoice(tree, List.of(0, 1, 2, 3), 1)))); + + JsonObject support = filters(placed(compiler(DataVersion.V26_3).compile(input(placement, objects, List.of()), new ArrayList<>()))).get(1); + + Set corners = new HashSet<>(); + for (JsonObject solid : StageTestPacks.ofType(support, "minecraft:solid")) { + JsonArray offset = solid.getAsJsonArray("offset"); + corners.add(offset.get(0).getAsInt() + "," + offset.get(2).getAsInt()); + } + assertEquals(Set.of("-6,-6", "-6,6", "6,-6", "6,6"), corners); + Set steepSlope = new HashSet<>(); + Set thinLedge = new HashSet<>(); + for (String corner : corners) { + for (int y = -12; y >= -40; y--) { + steepSlope.add(cell(corner, y)); + } + thinLedge.add(cell(corner, -12)); + } + JsonObject predicate = support.getAsJsonObject("predicate"); + assertTrue("ground 11 blocks below every corner must support the object", supported(predicate, steepSlope)); + assertFalse("a one block ledge is thinner than surfaceSupportDepth", supported(predicate, thinLedge)); + assertFalse("open air below every corner must not support the object", supported(predicate, Set.of())); + } + + @Test + public void stiltedObjectsRelyOnTheirStiltsInsteadOfCornerSupport() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.FAST_MIN_STILT, 1, 1); + PlacementInput input = new PlacementInput(placement, PlacementInput.Anchor.SURFACE, true, objects(25), List.of(), + List.of(VanillaBlockState.parse("minecraft:dark_oak_wood[axis=y]")), "willow"); + + List filters = filters(placed(compiler(DataVersion.V26_3).compile(input, new ArrayList<>()))); + + assertEquals(1, filters.size()); + assertFalse(StageTestPacks.ofType(filters.getFirst(), "minecraft:matching_block_tag").isEmpty()); + } + + @Test + public void slopeConditionRejectsSteepAnchors() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_HEIGHT, 1, 1); + placement.setRequireSurfaceSupport(false); + placement.getSlopeCondition().setMaximumSlope(1.5); + List diagnostics = new ArrayList<>(); + + List filters = filters(placed(compiler(DataVersion.V26_3).compile(input(placement, objects(5), List.of()), diagnostics))); + + assertTrue(diagnostics.stream().anyMatch(diagnostic -> diagnostic.limit() == ExportLimit.SLOPE)); + assertEquals(2, filters.size()); + JsonObject slope = filters.get(1).getAsJsonObject("predicate"); + Set flat = new HashSet<>(); + Set cliff = new HashSet<>(); + for (int x = -3; x <= 3; x++) { + for (int z = -3; z <= 3; z++) { + for (int y = -20; y <= -1; y++) { + flat.add(x + "," + y + "," + z); + cliff.add(x + "," + y + "," + z); + } + } + } + for (int y = 0; y <= 4; y++) { + cliff.add("3," + y + ",0"); + } + assertTrue("flat ground must pass the slope limit", supported(slope, flat)); + assertFalse("a wall 3 blocks higher 3 blocks away is steeper than slope 1.5", supported(slope, cliff)); + } + + @Test + public void forcePlaceIgnoresSlopeCondition() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_HEIGHT, 1, 1); + placement.setForcePlace(true); + placement.getSlopeCondition().setMinimumSlope(2); + + List filters = filters(placed(compiler(DataVersion.V26_3).compile(input(placement, objects(5), List.of()), new ArrayList<>()))); + + assertEquals(1, filters.size()); + } + + private static String cell(String corner, int y) { + String[] parts = corner.split(","); + return parts[0] + "," + y + "," + parts[1]; + } + + private static boolean supported(JsonObject predicate, Set solids) { + String type = predicate.get("type").getAsString(); + return switch (type) { + case "minecraft:solid" -> { + JsonArray offset = predicate.getAsJsonArray("offset"); + yield solids.contains(offset.get(0).getAsInt() + "," + offset.get(1).getAsInt() + "," + offset.get(2).getAsInt()); + } + case "minecraft:all_of" -> { + for (JsonElement child : predicate.getAsJsonArray("predicates")) { + if (!supported(child.getAsJsonObject(), solids)) { + yield false; + } + } + yield true; + } + case "minecraft:any_of" -> { + for (JsonElement child : predicate.getAsJsonArray("predicates")) { + if (supported(child.getAsJsonObject(), solids)) { + yield true; + } + } + yield false; + } + case "minecraft:not" -> !supported(predicate.getAsJsonObject("predicate"), solids); + default -> throw new AssertionError("Unexpected support predicate " + type); + }; + } + + @Test + public void underwaterObjectsNeedWaterAndStayBelowTheSurface() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_HEIGHT, 1, 1); + placement.setUnderwater(true); + placement.setTranslate(new IrisObjectTranslate().setY(2)); + + JsonObject placed = placed(compiler(DataVersion.V26_3).compile(input(placement, columns(7), List.of()), new ArrayList<>())); + + List filters = filters(placed); + assertEquals(2, filters.size()); + assertTrue(StageTestPacks.ofType(filters.getFirst(), "minecraft:not").stream() + .noneMatch(negated -> !StageTestPacks.ofType(negated, "minecraft:matching_fluids").isEmpty())); + JsonObject water = StageTestPacks.ofType(filters.getFirst(), "minecraft:matching_fluids").getFirst(); + assertFalse(water.toString(), water.has("offset")); + assertEquals(DIMENSION.fluidHeight() - 3 * (7 / 2) - 2, ceiling(placed)); + } + + @Test + public void underwaterObjectsOfDifferentHeightsKeepTheirOwnDepth() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_HEIGHT, 0.6, 2); + placement.setUnderwater(true); + + CompiledPlacement compiled = compiler(DataVersion.V26_3).compile(input(placement, columns(4, 9, 4), List.of()), new ArrayList<>()); + + assertEquals(2, compiled.placedFeatures().size()); + List ceilings = new ArrayList<>(); + double perChunk = 0; + for (JsonResource resource : compiled.resources()) { + if (resource.path().contains("worldgen/placed_feature/")) { + JsonObject placed = resource.json().getAsJsonObject(); + ceilings.add(ceiling(placed)); + perChunk += expectedPerChunk(placed); + } + } + ceilings.sort(Integer::compareTo); + assertEquals(List.of(DIMENSION.fluidHeight() - 3 * (9 / 2), DIMENSION.fluidHeight() - 3 * (4 / 2)), ceilings); + assertEquals(0.6 * 2, perChunk, 1e-6); + } + + @Test + public void onwaterObjectsStandOnTheWaterSurface() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_HEIGHT, 1, 1); + placement.setOnwater(true); + + JsonObject placed = placed(compiler(DataVersion.V26_3).compile(input(placement, objects(5), List.of()), new ArrayList<>())); + + assertEquals("WORLD_SURFACE_WG", StageTestPacks.ofType(placed, "minecraft:heightmap").getFirst().get("heightmap").getAsString()); + List filters = filters(placed); + assertEquals(1, filters.size()); + assertTrue(StageTestPacks.ofType(filters.getFirst(), "minecraft:solid").isEmpty()); + JsonObject water = StageTestPacks.ofType(filters.getFirst(), "minecraft:matching_fluids").getFirst(); + assertEquals("[0,-1,0]", water.get("offset").toString()); + assertTrue(StageTestPacks.ofType(placed, "minecraft:not").isEmpty()); + } + + @Test + public void onwaterPaintDropsOntoTheWaterSurface() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.PAINT, 1, 1); + placement.setOnwater(true); + placement.setTranslate(new IrisObjectTranslate().setY(1)); + + CompiledPlacement compiled = compiler(DataVersion.V26_3).compile(input(placement, objects(1), List.of()), new ArrayList<>()); + + JsonObject gravity = feature(compiled, "worldgen/feature/").getAsJsonObject("processors").getAsJsonArray("processors").get(0).getAsJsonObject(); + assertEquals("WORLD_SURFACE_WG", gravity.get("heightmap").getAsString()); + assertEquals(0, gravity.get("offset").getAsInt()); + assertEquals("WORLD_SURFACE_WG", StageTestPacks.ofType(placed(compiled), "minecraft:heightmap").getFirst().get("heightmap").getAsString()); + } + @Test public void wideObjectBecomesSinglePieceStructure() { IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_HEIGHT, 0.02, 1); @@ -112,7 +342,8 @@ public class PlacementCompilerTest { @Test public void writeWindowNeverExceeded() { DataVersion target = DataVersion.V26_3; - for (ObjectPlaceMode mode : List.of(ObjectPlaceMode.CENTER_HEIGHT, ObjectPlaceMode.STILT, ObjectPlaceMode.PAINT)) { + for (ObjectPlaceMode mode : List.of(ObjectPlaceMode.CENTER_HEIGHT, ObjectPlaceMode.STILT, ObjectPlaceMode.PAINT, + ObjectPlaceMode.MIN_HEIGHT, ObjectPlaceMode.FAST_MAX_HEIGHT)) { for (int footprint = 1; footprint <= 40; footprint++) { for (int translate : List.of(0, 3, -5, 12)) { IrisObjectPlacement placement = placement(mode, 1, 1); @@ -160,13 +391,13 @@ public class PlacementCompilerTest { assertEquals("minecraft:rule", processors.get(0).getAsJsonObject().get("processor_type").getAsString()); JsonObject gravity = processors.get(1).getAsJsonObject(); assertEquals("minecraft:gravity", gravity.get("processor_type").getAsString()); - assertEquals("WORLD_SURFACE_WG", gravity.get("heightmap").getAsString()); + assertEquals("OCEAN_FLOOR_WG", gravity.get("heightmap").getAsString()); assertEquals(-1, gravity.get("offset").getAsInt()); } @Test public void stiltsUseOverlayPillarsOn26_3() { - IrisObjectPlacement placement = placement(ObjectPlaceMode.STILT, 1, 1); + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_STILT, 1, 1); PlacementInput input = new PlacementInput(placement, PlacementInput.Anchor.SURFACE, false, objects(20), List.of(), List.of(VanillaBlockState.parse("minecraft:oak_log[axis=y]")), "hut"); @@ -210,6 +441,254 @@ public class PlacementCompilerTest { assertEquals(first.resources().toString(), second.resources().toString()); } + @Test + public void minHeightScansDownUntilTheWholeFootprintHasGround() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.MIN_HEIGHT, 1, 1); + + CompiledPlacement compiled = compiler(DataVersion.V26_3).compile(input(placement, objects(9), List.of()), new ArrayList<>()); + + JsonObject placed = placed(compiled); + assertEquals(List.of("minecraft:in_square", "minecraft:heightmap", "minecraft:block_predicate_filter", + "minecraft:block_predicate_filter", "minecraft:biome"), types(placed)); + JsonObject feature = feature(compiled, "worldgen/feature/"); + assertEquals("minecraft:weighted_random_selector", feature.get("type").getAsString()); + JsonArray branches = feature.getAsJsonArray("features"); + assertEquals(1, branches.size()); + JsonObject branch = branches.get(0).getAsJsonObject().getAsJsonObject("data"); + assertEquals("minecraft:template", branch.getAsJsonObject("feature").get("type").getAsString()); + assertEquals(List.of("minecraft:environment_scan", "minecraft:offset"), types(branch)); + JsonObject scan = branch.getAsJsonArray("placement").get(0).getAsJsonObject(); + assertEquals("down", scan.get("direction_of_search").getAsString()); + assertEquals(32, scan.get("max_steps").getAsInt()); + assertEquals(List.of("-4,-1,-4..4,-1,4"), boxes(scan)); + JsonObject match = StageTestPacks.ofType(scan, "minecraft:volume_match").getFirst().getAsJsonObject("match"); + assertFalse(StageTestPacks.ofType(match, "minecraft:solid").isEmpty()); + assertFalse(StageTestPacks.ofType(match, "minecraft:matching_fluids").isEmpty()); + assertEquals(-1, branch.getAsJsonArray("placement").get(1).getAsJsonObject().get("y").getAsInt()); + } + + @Test + public void maxHeightScansUpUntilEveryColumnIsClear() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.MAX_HEIGHT, 1, 1); + placement.setUnderwater(true); + + CompiledPlacement compiled = compiler(DataVersion.V26_3).compile(input(placement, objects(9), List.of()), new ArrayList<>()); + + JsonObject scan = branchScan(feature(compiled, "worldgen/feature/"), 0); + assertEquals("up", scan.get("direction_of_search").getAsString()); + assertEquals(List.of("-4,0,-4..4,0,4"), boxes(scan)); + JsonObject match = StageTestPacks.ofType(scan, "minecraft:volume_match").getFirst().getAsJsonObject("match"); + assertEquals("minecraft:not", match.get("type").getAsString()); + assertTrue(StageTestPacks.ofType(match, "minecraft:matching_fluids").isEmpty()); + } + + @Test + public void fastHeightModesProbeTheRuntimeSamplePoints() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.FAST_MIN_HEIGHT, 1, 1); + + CompiledPlacement compiled = compiler(DataVersion.V26_3).compile(input(placement, objects(9), List.of()), new ArrayList<>()); + + JsonObject scan = branchScan(feature(compiled, "worldgen/feature/"), 0); + assertEquals(List.of("-4,-1,-4..-4,-1,-4", "-4,-1,1..-4,-1,1", "1,-1,-4..1,-1,-4", "1,-1,1..1,-1,1"), boxes(scan)); + } + + @Test + public void heightScanCoversEveryAllowedQuarter() { + TemplateBlock block = new TemplateBlock(0, 0, 0, VanillaBlockState.parse("minecraft:stone"), null); + BakedVariant wall = new BakedVariant(NAMESPACE + ":object/wall/v", new TemplateVolume(5, 4, 9, List.of(block)), 0, TreeRoots.NONE); + IrisObjectPlacement placement = placement(ObjectPlaceMode.MIN_HEIGHT, 1, 1); + + JsonObject turning = feature(compiler(DataVersion.V26_3).compile(input(placement, List.of(new ObjectTemplates("wall", + List.of(new TemplateChoice(wall, List.of(0, 1, 2, 3), 1)))), List.of()), new ArrayList<>()), "worldgen/feature/"); + JsonObject fixed = feature(compiler(DataVersion.V26_3).compile(input(placement, List.of(new ObjectTemplates("wall", + List.of(new TemplateChoice(wall, List.of(0), 1)))), List.of()), new ArrayList<>()), "worldgen/feature/"); + + assertEquals(List.of("-2,-1,-4..2,-1,4", "-4,-1,-2..4,-1,2"), boxes(branchScan(turning, 0))); + assertEquals(List.of("-2,-1,-4..2,-1,4"), boxes(branchScan(fixed, 0))); + } + + @Test + public void everyTemplateAnchorsOnItsOwnFootprint() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.MIN_HEIGHT, 1, 1); + + JsonObject feature = feature(compiler(DataVersion.V26_3).compile(input(placement, objects(5, 9), List.of()), new ArrayList<>()), + "worldgen/feature/"); + + JsonArray branches = feature.getAsJsonArray("features"); + assertEquals(2, branches.size()); + assertEquals(branches.get(0).getAsJsonObject().get("weight"), branches.get(1).getAsJsonObject().get("weight")); + assertEquals(List.of("-2,-1,-2..2,-1,2"), boxes(branchScan(feature, 0))); + assertEquals(List.of("-4,-1,-4..4,-1,4"), boxes(branchScan(feature, 1))); + } + + @Test + public void maxStiltsAnchorHighThenStilt() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.STILT, 1, 1); + PlacementInput input = new PlacementInput(placement, PlacementInput.Anchor.SURFACE, false, objects(20), List.of(), + List.of(VanillaBlockState.parse("minecraft:oak_log[axis=y]")), "hut"); + + JsonObject feature = feature(compiler(DataVersion.V26_3).compile(input, new ArrayList<>()), "worldgen/feature/"); + + JsonObject branch = feature.getAsJsonArray("features").get(0).getAsJsonObject().getAsJsonObject("data"); + assertEquals("up", branchScan(feature, 0).get("direction_of_search").getAsString()); + JsonObject stilts = branch.getAsJsonObject("feature"); + assertEquals("minecraft:overlay", stilts.get("type").getAsString()); + assertEquals(5, stilts.getAsJsonArray("features").size()); + assertEquals("minecraft:single_block_pillar", stilts.getAsJsonArray("features").get(1).getAsJsonObject() + .getAsJsonObject("feature").get("type").getAsString()); + } + + @Test + public void groundedTreesExtendRootsToTheTerrain() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_HEIGHT, 1, 1); + + CompiledPlacement compiled = compiler(DataVersion.V26_3).compile(tree(placement, rootedTree()), new ArrayList<>()); + + JsonObject feature = feature(compiled, "worldgen/feature/"); + assertEquals("minecraft:weighted_random_selector", feature.get("type").getAsString()); + JsonArray branches = feature.getAsJsonArray("features"); + assertEquals(4, branches.size()); + List rotations = new ArrayList<>(); + List roots = new ArrayList<>(); + List gaps = new ArrayList<>(); + String pillar = null; + for (JsonElement element : branches) { + assertEquals(branches.get(0).getAsJsonObject().get("weight"), element.getAsJsonObject().get("weight")); + JsonObject branch = element.getAsJsonObject().getAsJsonObject("data"); + assertEquals(List.of("minecraft:offset", "minecraft:block_predicate_filter"), types(branch)); + gaps.addAll(boxes(branch.getAsJsonArray("placement").get(1))); + JsonObject overlay = branch.getAsJsonObject("feature"); + assertEquals("minecraft:overlay", overlay.get("type").getAsString()); + JsonArray parts = overlay.getAsJsonArray("features"); + assertEquals(2, parts.size()); + JsonObject template = parts.get(0).getAsJsonObject().getAsJsonObject("feature"); + rotations.add(template.getAsJsonArray("templates").get(0).getAsJsonObject().getAsJsonObject("data") + .getAsJsonArray("rotations").get(0).getAsString()); + JsonObject root = parts.get(1).getAsJsonObject(); + pillar = root.get("feature").getAsString(); + JsonObject offset = root.getAsJsonArray("placement").get(0).getAsJsonObject(); + roots.add(offset.get("x").getAsInt() + "," + offset.get("y").getAsInt() + "," + offset.get("z").getAsInt()); + } + assertEquals(List.of("none", "counterclockwise_90", "180", "clockwise_90"), rotations); + assertEquals(List.of("1,-1,0", "0,-1,-1", "-1,-1,0", "0,-1,1"), roots); + assertEquals(List.of("1,-4,0..1,-1,0", "0,-4,-1..0,-1,-1", "-1,-4,0..-1,-1,0", "0,-4,1..0,-1,1"), gaps); + JsonObject shared = resourceAt(compiled, pillar); + JsonObject gated = shared.getAsJsonArray("features").get(0).getAsJsonObject(); + assertEquals("minecraft:block_predicate_filter", gated.getAsJsonArray("placement").get(0).getAsJsonObject().get("type").getAsString()); + JsonObject column = gated.getAsJsonObject("feature"); + assertEquals("minecraft:single_block_pillar", column.get("type").getAsString()); + assertEquals("down", column.get("direction").getAsString()); + assertEquals("minecraft:spruce_wood", column.getAsJsonObject("block").get("id").getAsString()); + JsonObject cap = column.getAsJsonObject("cap_feature"); + assertEquals("minecraft:simple_block", cap.getAsJsonObject("feature").get("type").getAsString()); + assertEquals(-1, cap.getAsJsonArray("placement").get(0).getAsJsonObject().get("y").getAsInt()); + } + + @Test + public void groundedRotationBranchesKeepTheTemplateOdds() { + BakedVariant turning = rootedTree().getFirst().choices().getFirst().variant(); + BakedVariant fixed = new BakedVariant(NAMESPACE + ":object/tree/fixed", turning.volume(), 0, turning.roots()); + List objects = List.of(new ObjectTemplates("tree", List.of( + new TemplateChoice(turning, List.of(0, 1, 2, 3), 1), + new TemplateChoice(fixed, List.of(2), 1)))); + + JsonObject feature = feature(compiler(DataVersion.V26_3).compile(tree(placement(ObjectPlaceMode.CENTER_HEIGHT, 1, 1), objects), + new ArrayList<>()), "worldgen/feature/"); + + List weights = new ArrayList<>(); + for (JsonElement branch : feature.getAsJsonArray("features")) { + weights.add(branch.getAsJsonObject().get("weight").getAsInt()); + } + assertEquals(List.of(1, 1, 1, 1, 4), weights); + } + + @Test + public void groundedTreesReplaceStiltsWithRoots() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_STILT, 1, 1); + + JsonObject feature = feature(compiler(DataVersion.V26_3).compile(tree(placement, rootedTree()), new ArrayList<>()), "worldgen/feature/"); + + for (JsonElement element : feature.getAsJsonArray("features")) { + JsonObject overlay = element.getAsJsonObject().getAsJsonObject("data").getAsJsonObject("feature"); + assertTrue(StageTestPacks.ofType(overlay, "minecraft:cuboid").isEmpty()); + assertEquals(2, overlay.getAsJsonArray("features").size()); + } + } + + @Test + public void forcedTreesKeepTheirBuiltRoots() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_HEIGHT, 1, 1); + placement.setForcePlace(true); + + JsonObject feature = feature(compiler(DataVersion.V26_3).compile(tree(placement, rootedTree()), new ArrayList<>()), "worldgen/feature/"); + + assertEquals("minecraft:template", feature.get("type").getAsString()); + } + + @Test + public void rootedHeightScansStayInsideTheFeatureWindow() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.FAST_MAX_HEIGHT, 1, 1); + placement.setTranslate(new IrisObjectTranslate().setX(3).setZ(-3)); + List objects = rootedTree(); + + CompiledPlacement compiled = compiler(DataVersion.V26_3).compile(tree(placement, objects), new ArrayList<>()); + + assertWithinWindow(compiled, objects, "rooted fast max"); + } + + private static List rootedTree() { + List blocks = new ArrayList<>(); + for (int y = 1; y < 4; y++) { + blocks.add(new TemplateBlock(2, y, 2, VanillaBlockState.parse("minecraft:oak_log[axis=y]"), null)); + } + TemplateBlock root = new TemplateBlock(3, 0, 2, VanillaBlockState.parse("minecraft:spruce_wood[axis=y]"), null); + blocks.add(root); + BakedVariant variant = new BakedVariant(NAMESPACE + ":object/tree/rooted", new TemplateVolume(5, 4, 5, blocks), 0, + new TreeRoots(List.of(root), 3)); + return List.of(new ObjectTemplates("tree", List.of(new TemplateChoice(variant, List.of(0, 1, 2, 3), 1)))); + } + + private static PlacementInput tree(IrisObjectPlacement placement, List objects) { + return new PlacementInput(placement, PlacementInput.Anchor.SURFACE, true, objects, List.of(), + List.of(VanillaBlockState.parse("minecraft:oak_log[axis=y]")), "tree"); + } + + private static JsonObject branchScan(JsonObject selector, int index) { + JsonObject branch = selector.getAsJsonArray("features").get(index).getAsJsonObject().getAsJsonObject("data"); + return StageTestPacks.ofType(branch, "minecraft:environment_scan").getFirst(); + } + + private static List boxes(JsonElement root) { + List boxes = new ArrayList<>(); + for (JsonObject volume : StageTestPacks.ofType(root, "minecraft:volume_match")) { + JsonArray min = volume.getAsJsonArray("min"); + JsonArray max = volume.getAsJsonArray("max"); + boxes.add(min.get(0).getAsInt() + "," + min.get(1).getAsInt() + "," + min.get(2).getAsInt() + ".." + + max.get(0).getAsInt() + "," + max.get(1).getAsInt() + "," + max.get(2).getAsInt()); + } + return boxes; + } + + private static JsonObject resourceAt(CompiledPlacement compiled, String id) { + String path = "/worldgen/feature/" + id.substring(NAMESPACE.length() + 1) + ".json"; + for (JsonResource resource : compiled.resources()) { + if (resource.path().endsWith(path)) { + return resource.json().getAsJsonObject(); + } + } + throw new AssertionError("No resource for " + id); + } + + private static List filters(JsonObject placed) { + List filters = new ArrayList<>(); + for (JsonElement element : placed.getAsJsonArray("placement")) { + if (element.getAsJsonObject().get("type").getAsString().equals("minecraft:block_predicate_filter")) { + filters.add(element.getAsJsonObject()); + } + } + return filters; + } + private static void assertWithinWindow(CompiledPlacement compiled, List objects, String label) { Map footprints = new HashMap<>(); for (ObjectTemplates object : objects) { @@ -217,10 +696,23 @@ public class PlacementCompilerTest { footprints.put(choice.variant().id(), choice.variant().volume().footprint()); } } - JsonObject placed = placed(compiled); - int offsetX = 0; - int offsetZ = 0; - for (JsonElement element : placed.getAsJsonArray("placement")) { + int[] offset = probeWindow(placed(compiled).getAsJsonArray("placement"), 0, 0, label); + JsonObject feature = feature(compiled, null); + if (!feature.get("type").getAsString().equals("minecraft:weighted_random_selector")) { + assertFeatureWithinWindow(feature, footprints, offset[0], offset[1], label); + return; + } + for (JsonElement entry : feature.getAsJsonArray("features")) { + JsonObject branch = entry.getAsJsonObject().getAsJsonObject("data"); + int[] branchOffset = probeWindow(branch.getAsJsonArray("placement"), offset[0], offset[1], label); + assertFeatureWithinWindow(branch.getAsJsonObject("feature"), footprints, branchOffset[0], branchOffset[1], label); + } + } + + private static int[] probeWindow(JsonArray modifiers, int startX, int startZ, String label) { + int offsetX = startX; + int offsetZ = startZ; + for (JsonElement element : modifiers) { JsonObject modifier = element.getAsJsonObject(); String type = modifier.get("type").getAsString(); if (type.equals("minecraft:offset")) { @@ -229,15 +721,26 @@ public class PlacementCompilerTest { } else if (type.equals("minecraft:random_offset")) { offsetX += modifier.get("xz_spread").getAsInt(); offsetZ += modifier.get("xz_spread").getAsInt(); - } else if (type.equals("minecraft:block_predicate_filter")) { - JsonObject predicate = modifier.getAsJsonObject("predicate"); - if (predicate.get("type").getAsString().equals("minecraft:volume_match")) { - assertRange(label + " support", predicate.getAsJsonArray("min").get(0).getAsInt() + offsetX, - predicate.getAsJsonArray("max").get(0).getAsInt() + offsetX); + } else if (type.equals("minecraft:block_predicate_filter") || type.equals("minecraft:environment_scan")) { + for (JsonObject probe : StageTestPacks.find(modifier, object -> object.has("offset") || object.has("min"))) { + for (String key : List.of("offset", "min", "max")) { + if (!probe.has(key)) { + continue; + } + JsonArray vector = probe.getAsJsonArray(key); + for (int axis = 0; axis < 3; axis++) { + assertTrue(label + " predicate " + key + " " + vector, Math.abs(vector.get(axis).getAsInt()) <= 16); + } + assertRange(label + " probe x", vector.get(0).getAsInt() + offsetX, vector.get(0).getAsInt() + offsetX); + assertRange(label + " probe z", vector.get(2).getAsInt() + offsetZ, vector.get(2).getAsInt() + offsetZ); + } } } } - JsonObject feature = feature(compiled, null); + return new int[]{offsetX, offsetZ}; + } + + private static void assertFeatureWithinWindow(JsonObject feature, Map footprints, int offsetX, int offsetZ, String label) { String type = feature.get("type").getAsString(); JsonObject body = feature.has("config") ? feature.getAsJsonObject("config") : feature; if (type.equals("minecraft:fossil")) { @@ -255,16 +758,27 @@ public class PlacementCompilerTest { assertNotNull(label, footprint); assertTemplate(label, footprint, offsetX, offsetZ); } - if (type.equals("minecraft:overlay")) { - JsonArray features = body.getAsJsonArray("features"); - for (int index = 1; index < features.size(); index++) { - JsonArray tile = features.get(index).getAsJsonObject().getAsJsonArray("placement"); - int tileX = tile.get(0).getAsJsonObject().get("x").getAsInt() + offsetX; - int tileZ = tile.get(0).getAsJsonObject().get("z").getAsInt() + offsetZ; - int size = tile.get(1).getAsJsonObject().get("xz_size").getAsInt(); - assertRange(label + " stilt tile x", tileX, tileX + size); - assertRange(label + " stilt tile z", tileZ, tileZ + size); + if (!type.equals("minecraft:overlay")) { + return; + } + JsonArray features = body.getAsJsonArray("features"); + for (int index = 1; index < features.size(); index++) { + JsonArray part = features.get(index).getAsJsonObject().getAsJsonArray("placement"); + int partX = offsetX; + int partZ = offsetZ; + int size = 0; + for (JsonElement element : part) { + JsonObject modifier = element.getAsJsonObject(); + String modifierType = modifier.get("type").getAsString(); + if (modifierType.equals("minecraft:offset")) { + partX += modifier.get("x").getAsInt(); + partZ += modifier.get("z").getAsInt(); + } else if (modifierType.equals("minecraft:cuboid")) { + size = modifier.get("xz_size").getAsInt(); + } } + assertRange(label + " part x", partX, partX + size); + assertRange(label + " part z", partZ, partZ + size); } } @@ -378,12 +892,35 @@ public class PlacementCompilerTest { int footprint = footprints[index]; TemplateBlock block = new TemplateBlock(0, 0, 0, VanillaBlockState.parse("minecraft:stone"), null); BakedVariant variant = new BakedVariant(NAMESPACE + ":object/o" + index + "/v" + footprint, - new TemplateVolume(footprint, 4, footprint, List.of(block)), 0); + new TemplateVolume(footprint, 4, footprint, List.of(block)), 0, TreeRoots.NONE); objects.add(new ObjectTemplates("o" + index, List.of(new TemplateChoice(variant, List.of(0, 1, 2, 3), 1)))); } return objects; } + private static List columns(int... heights) { + List objects = new ArrayList<>(); + for (int index = 0; index < heights.length; index++) { + List blocks = new ArrayList<>(); + for (int y = 1; y <= heights[index]; y++) { + blocks.add(new TemplateBlock(1, y, 1, VanillaBlockState.parse("minecraft:tube_coral_block"), null)); + } + BakedVariant variant = new BakedVariant(NAMESPACE + ":object/c" + index + "/h" + heights[index], + new TemplateVolume(3, heights[index] + 1, 3, blocks), 0, TreeRoots.NONE); + objects.add(new ObjectTemplates("c" + index, List.of(new TemplateChoice(variant, List.of(0, 1, 2, 3), 1)))); + } + return objects; + } + + private static int ceiling(JsonObject placed) { + List ranges = new ArrayList<>(); + for (JsonObject filter : filters(placed)) { + ranges.addAll(StageTestPacks.ofType(filter, "minecraft:height_range")); + } + assertEquals(placed.toString(), 1, ranges.size()); + return ranges.getFirst().getAsJsonObject("max_inclusive").get("absolute").getAsInt(); + } + private static JsonObject rule() { JsonObject rule = new JsonObject(); rule.addProperty("processor_type", "minecraft:rule"); diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/emit/Format26_3WriterTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/emit/Format26_3WriterTest.java index c7ecaa69d..97352bc59 100644 --- a/core/src/test/java/art/arcane/iris/pack/datapack/export/emit/Format26_3WriterTest.java +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/emit/Format26_3WriterTest.java @@ -70,6 +70,12 @@ public class Format26_3WriterTest { assertTrue(pack.get("description").getAsString().length() > 0); } + @Test + public void packDescriptionTellsPlayersToKeepTheVanillaPack() { + String description = json(write(fixture(WorldgenExportMode.OVERWORLD)), "pack.mcmeta").getAsJsonObject("pack").get("description").getAsString(); + assertTrue(description, description.contains("Keep Default selected")); + } + @Test public void noiseFileUsesNormalNoiseParameters() { JsonObject noise = json(write(fixture(WorldgenExportMode.PRESET)), "data/iris_export/worldgen/noise/flat/height.json");