diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d16fc8..bdcedde 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,9 +110,9 @@ jobs: if-no-files-found: error retention-days: 30 path: | - build/libs/SkysGrassSlabs-1.1.1.121012.jar - build/libs/SkysGrassSlabs-1.1.1.121012-sources.jar - build/libs/SkysGrassSlabs-1.1.1.121012-javadoc.jar + build/libs/SkysGrassSlabs-1.2.0.121012.jar + build/libs/SkysGrassSlabs-1.2.0.121012-sources.jar + build/libs/SkysGrassSlabs-1.2.0.121012-javadoc.jar build/release/SHA256SUMS CHANGELOG.md diff --git a/README.md b/README.md index 14934e4..14f53b3 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ thin layer of turf. Grass spreads naturally between full blocks and slabs, while new Overworld terrain uses grass slabs to soften suitable one block steps. -This branch is for Minecraft 1.21.1. Version `1.1.1.121012` requires NeoForge +This branch is for Minecraft 1.21.1. Version `1.2.0.121012` requires NeoForge 21.1.247 or a compatible NeoForge 21.1 build and Java 21. ## Installation @@ -34,7 +34,7 @@ or updating world changing mods. - [Gameplay and recipes](docs/GAMEPLAY.md) - [Configuration](docs/CONFIGURATION.md) - [Upgrading an older world](docs/WORLD-UPGRADES.md) -- [Release notes](docs/RELEASE-1.1.1.121012.md) +- [Release notes](docs/RELEASE-1.2.0.121012.md) ## License diff --git a/build.gradle b/build.gradle index 3059bd7..118e855 100644 --- a/build.gradle +++ b/build.gradle @@ -315,6 +315,7 @@ tasks.register('writeReleaseChecksums') { group = 'verification' dependsOn tasks.named('verifyReleaseArtifacts') def output = layout.buildDirectory.file('release/SHA256SUMS') + inputs.files(releaseFiles.map { names -> names.collect { layout.buildDirectory.file("libs/${it}") } }) outputs.file(output) doLast { File libs = layout.buildDirectory.dir('libs').get().asFile @@ -573,6 +574,8 @@ tasks.matching { it.name == 'prepareRunGameTestServer' || it.name == 'runGameTes } tasks.withType(AbstractArchiveTask).configureEach { + // Empty resource directories should not change the jar. + includeEmptyDirs = false preserveFileTimestamps = false reproducibleFileOrder = true } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 479f27b..270cf7b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1,6 +1,6 @@ # Configuration -NeoForge creates `config/skysgrassslabs-common.toml` after the mod starts for the +Forge creates `config/skysgrassslabs-common.toml` after the mod starts for the first time. Stop the game or server before editing it, then restart to apply changes. On a dedicated server, the server's configuration controls world generation and world upgrades. @@ -13,9 +13,14 @@ kept. An existing TOML file always takes priority. `worldgen.generateGrassSlabs=true` +In the Mods list, select Sky’s Grass Slabs and open Config to edit these +choices. They take effect the next time Minecraft starts. + When enabled, suitable one block slopes in newly generated Overworld chunks may receive grass slabs. Set this to `false` to leave new terrain unchanged. The setting never adds or removes slabs in chunks that already exist. +If Sky’s Terrain Smoother is installed, it handles generation for both mods. +This setting still controls whether that smoother places grass pieces. ## Legacy slab replacement diff --git a/docs/GAMEPLAY.md b/docs/GAMEPLAY.md index 1b4b240..fed1216 100644 --- a/docs/GAMEPLAY.md +++ b/docs/GAMEPLAY.md @@ -44,6 +44,7 @@ enabled is removed without dropping an item. - Three dirt blocks in a horizontal row make six dirt slabs. - Three grass blocks in a horizontal row make six grass slabs. +- Two matching dirt or grass slabs side by side make their original block. - A dirt block and a recognised seed make a grass block. - A dirt slab and a recognised seed make a grass slab. - A grass block or grass slab together with a compatible shovel makes one diff --git a/docs/RELEASE-1.2.0.121012.md b/docs/RELEASE-1.2.0.121012.md new file mode 100644 index 0000000..c20300f --- /dev/null +++ b/docs/RELEASE-1.2.0.121012.md @@ -0,0 +1,12 @@ +# Sky's Grass Slabs 1.2.0 for Minecraft 1.21.1 + +The Mods list now has a Config button for grass smoothing and recovery +settings. Changes take effect after a restart. + +Two matching dirt or grass slabs can be crafted back into their original +block. With Sky's Building Pieces installed, grass can also spread between +the smaller grass pieces. Sky's Terrain Smoother can handle the shared +generation pass when installed. + +Existing block and item IDs, saved data and settings carry forward. This +build requires Minecraft 1.21.1, NeoForge 21.1.247 and Java 21. diff --git a/docs/WORLD-UPGRADES.md b/docs/WORLD-UPGRADES.md index 25844ec..922cc11 100644 --- a/docs/WORLD-UPGRADES.md +++ b/docs/WORLD-UPGRADES.md @@ -1,12 +1,12 @@ # Upgrading an older world Always make a complete copy of the world before changing its Minecraft or -NeoForge version. Test the copy first and keep the original until the upgraded +Forge version. Test the copy first and keep the original until the upgraded world has been checked in game. ## Sky's Grass Slabs content -Install `SkysGrassSlabs-1.1.1.121012.jar` before opening a world that used an +Install `SkysGrassSlabs-1.2.0.121012.jar` before opening a world that used an earlier release. The permanent dirt slab, grass slab, path slab and turf IDs are unchanged. @@ -15,13 +15,12 @@ when their chunks are first opened. Old top and bottom slab orientations are retained and begin dry. Keep the mod installed during the first upgraded start so unopened chunks can be converted when they are later visited. -Minecraft 1.13.2 through 1.21.1 worlds already use named block states. Their +Minecraft 1.13.2 through 1.20.6 worlds already use named block states. Their orientation, snowy and waterlogged states load through Minecraft's normal upgrade process. -World smoothing still affects only newly generated chunks. Chunks indexed as -part of an older world are not treated as new terrain by the smoothing -feature. +World smoothing affects only new terrain. It skips chunks already saved in +your world, even if Minecraft still needs to finish decorating them. ## Supported older slab IDs @@ -30,7 +29,7 @@ original mod is absent. Slab orientation, stack counts and custom item data are retained. Other older shapes such as stairs, vertical slabs, corners and steps are not -part of this migration. NeoForge may report them as missing and may remove them +part of this migration. Forge may report them as missing and may remove them after offering its normal backup and confirmation screen. Do not continue unless losing those unsupported blocks is acceptable for that copy of the world. @@ -56,7 +55,7 @@ carpet produces an item which is converted to turf. Stairs, dirt carpet, dirt path carpet, mycelium variants and podzol variants do not have matching blocks in Sky's Grass Slabs. They are not converted. Read -NeoForge's complete missing content warning before continuing without the older +Forge's complete missing content warning before continuing without the older mod. When both mods remain installed, their content is left alone by default. See @@ -65,9 +64,9 @@ When both mods remain installed, their content is left alone by default. See ## Modpack checklist 1. Back up the complete instance and world. -2. Update NeoForge and every required mod to a matching Minecraft 1.21.1 build. +2. Update Forge and every required mod to a matching Minecraft 1.21.1 build. 3. Install the 1.21.1 Sky's Grass Slabs jar before the first upgraded start. -4. Read the complete NeoForge missing content list before confirming it. +4. Read the complete Forge missing content list before confirming it. 5. Check representative slabs, turf, paths, inventories and old terrain. 6. Stop and start the upgraded copy once more, then repeat the checks. diff --git a/gradle.properties b/gradle.properties index 8f5fbce..bcc14ac 100644 --- a/gradle.properties +++ b/gradle.properties @@ -40,7 +40,7 @@ mod_name=Sky's Grass Slabs # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. mod_license=LGPL-2.1-only # The mod version. See https://semver.org/ -mod_version=1.1.1.121012 +mod_version=1.2.0.121012 # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. # This should match the base package used for the mod sources. # See https://maven.apache.org/guides/mini/guide-naming-conventions.html diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/gameTest/java/zone/moddev/mc/skysgrassslabs/gametest/SlabGameTests.java b/src/gameTest/java/zone/moddev/mc/skysgrassslabs/gametest/SlabGameTests.java index 37f8556..6c4a113 100644 --- a/src/gameTest/java/zone/moddev/mc/skysgrassslabs/gametest/SlabGameTests.java +++ b/src/gameTest/java/zone/moddev/mc/skysgrassslabs/gametest/SlabGameTests.java @@ -44,6 +44,7 @@ import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; import net.neoforged.neoforge.event.entity.EntityJoinLevelEvent; import zone.moddev.mc.skysgrassslabs.SkysGrassSlabs; +import zone.moddev.mc.skysgrassslabs.api.GrassSlabsApi; import zone.moddev.mc.skysgrassslabs.block.DirtSlabBlock; import zone.moddev.mc.skysgrassslabs.block.GrassSlabBlock; import zone.moddev.mc.skysgrassslabs.block.GrassSpread; @@ -267,14 +268,17 @@ public static void snowPlantsBonemealAndWaterAreTopAware(GameTestHelper helper) require(helper, grass.canSustainPlant(bottom, helper.getLevel(), pos, Direction.UP, Blocks.DANDELION.defaultBlockState()).isFalse(), "bottom grass slab sustained a plant"); - require(helper, !grass.canSustainPlant(top, helper.getLevel(), pos, Direction.UP, - Blocks.DANDELION.defaultBlockState()).isFalse(), - "top grass slab rejected a plant"); - helper.getLevel().setBlock(pos, top, Block.UPDATE_ALL); + require(helper, Blocks.DANDELION.defaultBlockState().canSurvive(helper.getLevel(), pos.above()) + && Blocks.OAK_SAPLING.defaultBlockState().canSurvive(helper.getLevel(), pos.above()), + "flowers and saplings must survive on a top grass slab"); + require(helper, !Blocks.WHEAT.defaultBlockState().canSurvive(helper.getLevel(), pos.above()), + "grass slabs must not replace farmland"); require(helper, Blocks.SNOW.defaultBlockState().canSurvive(helper.getLevel(), pos.above()), "snow did not survive on a top grass slab"); helper.getLevel().setBlock(pos, bottom, Block.UPDATE_ALL); + require(helper, !Blocks.DANDELION.defaultBlockState().canSurvive(helper.getLevel(), pos.above()), + "a flower survived on a bottom grass slab"); require(helper, !Blocks.SNOW.defaultBlockState().canSurvive(helper.getLevel(), pos.above()), "snow survived on a bottom grass slab"); BlockState snowBlockAppearance = grass.updateShape(top, Direction.UP, @@ -690,6 +694,51 @@ private static boolean matchesSeedRecipe(CraftingRecipe recipe, GameTestHelper h .is(ModBlocks.GRASS_SLAB_ITEM.get()); } + @GameTest(template = EMPTY, batch = "slabs_family") + public static void sharedGrassFormsKeepTheirOrientationAndWater(GameTestHelper helper) { + for (SlabType half : new SlabType[] {SlabType.BOTTOM, SlabType.TOP}) { + for (boolean waterlogged : new boolean[] {false, true}) { + BlockState dirt = ModBlocks.DIRT_SLAB.get().defaultBlockState() + .setValue(SlabBlock.TYPE, half).setValue(SlabBlock.WATERLOGGED, waterlogged); + BlockState grass = GrassSlabsApi.grassFor(dirt); + require(helper, grass != null && grass.is(ModBlocks.GRASS_SLAB.get()), + "The shared catalogue is missing a native grass slab form"); + require(helper, grass.getValue(SlabBlock.TYPE) == half + && grass.getValue(SlabBlock.WATERLOGGED) == waterlogged, + "The shared catalogue changed a slab's orientation or water"); + require(helper, GrassSlabsApi.dirtFor(grass) == dirt + && GrassSlabsApi.isSpreadingGrassForm(grass) == !waterlogged, + "The shared catalogue changed the dirt form or wet grass behaviour"); + } + } + helper.succeed(); + } + + @GameTest(template = EMPTY, batch = "slabs_family") + public static void twoMatchingSlabsCraftBackToOneBlock(GameTestHelper helper) { + verifyRecombination(helper, "dirt_from_slabs", + new ItemStack(ModBlocks.DIRT_SLAB_ITEM.get()), Blocks.DIRT); + verifyRecombination(helper, "grass_from_slabs", + new ItemStack(ModBlocks.GRASS_SLAB_ITEM.get()), Blocks.GRASS_BLOCK); + helper.succeed(); + } + + private static void verifyRecombination(GameTestHelper helper, String recipeName, + ItemStack slab, Block fullBlock) { + CraftingRecipe recipe = (CraftingRecipe) helper.getLevel().getRecipeManager() + .byKey(ResourceLocation.fromNamespaceAndPath(SkysGrassSlabs.MOD_ID, recipeName)) + .orElseThrow().value(); + CraftingInput grid = craftingGrid(2, 1, slab.copy(), slab.copy()); + require(helper, recipe.matches(grid, helper.getLevel()), + "Matching slabs did not fit the recombination recipe"); + ItemStack result = recipe.assemble(grid, helper.getLevel().registryAccess()); + require(helper, result.is(fullBlock.asItem()) && result.getCount() == 1, + "Two slabs must give back exactly one full block"); + CraftingInput singleSlab = craftingGrid(1, 1, slab.copy()); + require(helper, !recipe.matches(singleSlab, helper.getLevel()), + "A single slab must not give back a full block"); + } + private static CraftingInput craftingGrid(int width, int height, ItemStack... stacks) { NonNullList items = NonNullList.withSize(width * height, ItemStack.EMPTY); for (int slot = 0; slot < stacks.length; ++slot) { diff --git a/src/gameTest/java/zone/moddev/mc/skysgrassslabs/gametest/WorldgenGameTests.java b/src/gameTest/java/zone/moddev/mc/skysgrassslabs/gametest/WorldgenGameTests.java index 214aaee..02e45bf 100644 --- a/src/gameTest/java/zone/moddev/mc/skysgrassslabs/gametest/WorldgenGameTests.java +++ b/src/gameTest/java/zone/moddev/mc/skysgrassslabs/gametest/WorldgenGameTests.java @@ -58,6 +58,8 @@ public static void smoothingIsBorderSafeAndIdempotent(GameTestHelper helper) { BlockPos center = new BlockPos(owner.getMinBlockX() + 8, y, owner.getMinBlockZ() + 8); makeOneBlockRise(helper, center, center.east()); + helper.getLevel().setBlock(center.above(), Blocks.SHORT_GRASS.defaultBlockState(), + Block.UPDATE_ALL); BlockPos border = new BlockPos(owner.getMinBlockX(), y, owner.getMinBlockZ() + 8); makeOneBlockRise(helper, border, border.west()); @@ -85,7 +87,7 @@ public static void smoothingIsBorderSafeAndIdempotent(GameTestHelper helper) { center, NoneFeatureConfiguration.INSTANCE); require(helper, feature.place(context), "controlled feature pass made no changes"); require(helper, helper.getLevel().getBlockState(center.above()) - .is(ModBlocks.GRASS_SLAB.get()), "one-block transition was not smoothed"); + .is(ModBlocks.GRASS_SLAB.get()), "short grass prevented a valid one-block transition"); require(helper, helper.getLevel().getBlockState(border.above()) .is(ModBlocks.GRASS_SLAB.get()), "owning-chunk border transition was not smoothed"); require(helper, helper.getLevel().getBlockState(border.west().above()) diff --git a/src/main/java/zone/moddev/mc/skysgrassslabs/SkysGrassSlabs.java b/src/main/java/zone/moddev/mc/skysgrassslabs/SkysGrassSlabs.java index a1c591e..a8a5071 100644 --- a/src/main/java/zone/moddev/mc/skysgrassslabs/SkysGrassSlabs.java +++ b/src/main/java/zone/moddev/mc/skysgrassslabs/SkysGrassSlabs.java @@ -2,11 +2,19 @@ import java.lang.reflect.InvocationTargetException; import java.util.Arrays; +import net.minecraft.world.level.block.SlabBlock; +import net.minecraft.world.level.block.state.properties.SlabType; +import net.neoforged.api.distmarker.Dist; import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.event.server.ServerStartedEvent; import net.neoforged.bus.api.IEventBus; import net.neoforged.fml.common.Mod; import net.neoforged.fml.ModContainer; +import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent; +import net.neoforged.fml.event.lifecycle.FMLLoadCompleteEvent; +import net.neoforged.fml.loading.FMLEnvironment; +import zone.moddev.mc.skysgrassslabs.api.GrassSlabsApi; +import zone.moddev.mc.skysgrassslabs.client.ClientConfig; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import zone.moddev.mc.skysgrassslabs.compat.BuildingBricksCompat; @@ -27,13 +35,13 @@ public final class SkysGrassSlabs { /** Stable mod identifier and resource namespace. */ public static final String MOD_ID = "skysgrassslabs"; public static final String NAME = "Sky's Grass Slabs"; - public static final String VERSION = "1.1.1.121012"; + public static final String VERSION = "1.2.0.121012"; public static final Logger LOGGER = LogManager.getLogger(); /** Registers content, configuration, world generation, and persistent state. */ public SkysGrassSlabs(IEventBus modBus, ModContainer modContainer) { SkysGrassSlabsConfig.migrateLegacyConfig(); - SkysGrassSlabsConfig.register(modContainer); + SkysGrassSlabsConfig.register(modBus, modContainer); ModBlocks.register(modBus); ModRecipes.register(modBus); WorldgenBootstrap.register(modBus); @@ -44,10 +52,33 @@ public SkysGrassSlabs(IEventBus modBus, ModContainer modContainer) { GrassSlabsMigrationHandler.register(); CommonEvents.register(); registerGameTests(modBus); + modBus.addListener(this::setupGrassForms); + modBus.addListener(this::finishGrassRegistration); + if (FMLEnvironment.dist == Dist.CLIENT) { + ClientConfig.register(modContainer); + } NeoForge.EVENT_BUS.addListener(this::onServerStarted); } + private void setupGrassForms(FMLCommonSetupEvent event) { + SkysGrassSlabsConfig.activateSettingsForThisRun(); + GrassSlabsApi.configurationLoaded(); + for (SlabType half : new SlabType[] {SlabType.BOTTOM, SlabType.TOP}) { + for (boolean waterlogged : new boolean[] {false, true}) { + GrassSlabsApi.registerGrassForm( + ModBlocks.DIRT_SLAB.get().defaultBlockState() + .setValue(SlabBlock.TYPE, half).setValue(SlabBlock.WATERLOGGED, waterlogged), + ModBlocks.GRASS_SLAB.get().defaultBlockState() + .setValue(SlabBlock.TYPE, half).setValue(SlabBlock.WATERLOGGED, waterlogged)); + } + } + } + + private void finishGrassRegistration(FMLLoadCompleteEvent event) { + event.enqueueWork(GrassSlabsApi::freeze); + } + private static void registerGameTests(IEventBus modBus) { try { Class bootstrap = Class.forName( diff --git a/src/main/java/zone/moddev/mc/skysgrassslabs/api/GrassSlabsApi.java b/src/main/java/zone/moddev/mc/skysgrassslabs/api/GrassSlabsApi.java new file mode 100644 index 0000000..bed80cc --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysgrassslabs/api/GrassSlabsApi.java @@ -0,0 +1,234 @@ +package zone.moddev.mc.skysgrassslabs.api; + +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import net.minecraft.util.RandomSource; +import java.util.Set; +import java.util.function.BiFunction; +import java.util.function.BooleanSupplier; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.SlabBlock; +import net.minecraft.world.level.block.SnowyDirtBlock; +import net.minecraft.world.phys.AABB; +import net.minecraft.core.BlockPos; +import net.minecraft.world.phys.shapes.VoxelShape; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.Level; +import zone.moddev.mc.skysgrassslabs.block.GrassSpread; +import zone.moddev.mc.skysgrassslabs.config.SkysGrassSlabsConfig; + +/** Optional grass and terrain integration. Register forms before load completion. */ +public final class GrassSlabsApi { + public static final int VERSION = 1; + + private static final Map GRASS = new LinkedHashMap<>(); + private static final Map DIRT = new LinkedHashMap<>(); + private static final Set NON_SPREADING = new HashSet<>(); + private static final Map SUPPORTS = new LinkedHashMap<>(); + private static final Map> SPREAD_RULES = + new LinkedHashMap<>(); + + private static String smoothingOwner; + private static BooleanSupplier ownerEnabled = () -> true; + private static boolean configurationLoaded; + private static boolean frozen; + + private GrassSlabsApi() { + } + + /** Claim once during construction, before common setup closes ownership registration. */ + public static void claimSmoothing(String owner, boolean enabled) { + claimSmoothing(owner, () -> enabled); + } + + /** Forge loads configuration after construction, so owners may supply their setting lazily. */ + public static synchronized void claimSmoothing(String owner, BooleanSupplier enabled) { + if (configurationLoaded || frozen || owner == null || owner.isEmpty() + || enabled == null || smoothingOwner != null) { + throw new IllegalStateException("Smoothing must be claimed once before common setup"); + } + + smoothingOwner = owner; + ownerEnabled = enabled; + } + + public static boolean hasSmoothingOwner() { + return smoothingOwner != null; + } + + public static boolean ownerAllowsGeneration() { + return smoothingOwner == null || ownerEnabled.getAsBoolean(); + } + + /** Respect the player's grass switch even when another mod owns terrain generation. */ + public static boolean grassGenerationAllowed() { + return SkysGrassSlabsConfig.isSmoothingActive(); + } + + /** Register each orientation and water state; snow is a derived visual property. */ + public static synchronized void registerGrassForm(BlockState dirt, BlockState grass) { + checkRegistrationOpen(); + checkForms(dirt, grass); + dirt = canonical(dirt); + grass = canonical(grass); + if (DIRT.containsKey(dirt) || GRASS.containsKey(grass)) { + throw new IllegalArgumentException("Conflicting grass form mapping"); + } + + DIRT.put(dirt, grass); + GRASS.put(grass, dirt); + } + + /** Additional skins can share dirt without replacing its ordinary regrowth. */ + public static synchronized void registerGrassVariant(BlockState dirt, BlockState grass, + boolean spreads) { + checkRegistrationOpen(); + checkForms(dirt, grass); + dirt = canonical(dirt); + grass = canonical(grass); + if (GRASS.containsKey(grass)) { + throw new IllegalArgumentException("Conflicting grass variant mapping"); + } + + GRASS.put(grass, dirt); + if (!spreads) { + NON_SPREADING.add(grass); + } + } + + public static boolean isGrassForm(BlockState state) { + return state != null && GRASS.containsKey(canonical(state)); + } + + public static boolean isSpreadingGrassForm(BlockState state) { + return isGrassForm(state) && !NON_SPREADING.contains(canonical(state)) + && !isWet(state); + } + + public static BlockState grassFor(BlockState dirt) { + return dirt == null ? null : DIRT.get(canonical(dirt)); + } + + /** Give source-aware rules first choice, then use the default grass for this dirt. */ + public static BlockState grassFor(BlockState dirt, BlockState source) { + if (dirt == null) { + return null; + } + for (BiFunction rule : SPREAD_RULES.values()) { + BlockState result = rule.apply(canonical(source), canonical(dirt)); + if (result != null) { + return result; + } + } + + return grassFor(dirt); + } + + public static BlockState dirtFor(BlockState grass) { + return grass == null ? null : GRASS.get(canonical(grass)); + } + + public static synchronized void registerSpreadRule(String owner, + BiFunction rule) { + checkRegistrationOpen(); + if (owner == null || owner.isEmpty() || rule == null || SPREAD_RULES.containsKey(owner)) { + throw new IllegalArgumentException("Invalid or conflicting grass spread rule"); + } + + SPREAD_RULES.put(owner, rule); + } + + /** Register native grass that may need dirtifying beneath a full-cover piece. */ + public static synchronized void registerGrassSupport(BlockState grass, BlockState dirt) { + checkRegistrationOpen(); + checkForms(dirt, grass); + if (SUPPORTS.containsKey(canonical(grass))) { + throw new IllegalArgumentException("Conflicting grass support mapping"); + } + + SUPPORTS.put(canonical(grass), canonical(dirt)); + } + + public static synchronized void configurationLoaded() { + configurationLoaded = true; + } + + public static synchronized void freeze() { + frozen = true; + } + + public static void tickDirt(Level world, BlockPos position, BlockState state, RandomSource random) { + GrassSpread.tickDirtSlab(world, position, state, random); + } + + /** Retain the registered shape and water state when grass turns back into dirt. */ + public static void tickGrass(Level world, BlockPos position, BlockState state, RandomSource random) { + if (world.isClientSide) { + return; + } + + repairSupport(world, position); + if (isWet(state) + || !GrassSpread.canRemainGrass(world, position)) { + BlockState dirt = dirtFor(state); + if (dirt != null) { + world.setBlock(position, dirt, 3); + } + } else if (isSpreadingGrassForm(state)) { + GrassSpread.spreadFrom(world, position, random, + coversSupport(state, world, position) ? position.below() : null); + } + } + + public static void repairSupport(Level world, BlockPos position) { + if (world.isClientSide || !world.isLoaded(position.below()) + || !coversSupport(world.getBlockState(position), world, position)) { + return; + } + + BlockState support = world.getBlockState(position.below()); + BlockState dirt = support.getBlock() == Blocks.GRASS_BLOCK + ? Blocks.DIRT.defaultBlockState() : SUPPORTS.get(canonical(support)); + if (dirt != null) { + // The support keeps its full shape. Send its new appearance to clients, + // but do not let neighbour-shape updates load chunks across a generation border. + world.setBlock(position.below(), dirt, 2 | 16); + } + } + + /** Steps and corners leave grass exposed; a slab or turf covers the whole footprint. */ + public static boolean coversSupport(BlockState piece, BlockGetter world, BlockPos position) { + VoxelShape shape = piece.getShape(world, position); + if (shape.isEmpty()) { + return false; + } + + AABB bounds = shape.bounds(); + return bounds.minX == 0 && bounds.maxX == 1 && bounds.minZ == 0 && bounds.maxZ == 1; + } + + private static BlockState canonical(BlockState state) { + return state != null && state.hasProperty(SnowyDirtBlock.SNOWY) + ? state.setValue(SnowyDirtBlock.SNOWY, false) : state; + } + + private static boolean isWet(BlockState state) { + return state.hasProperty(SlabBlock.WATERLOGGED) && state.getValue(SlabBlock.WATERLOGGED) + || !state.getFluidState().isEmpty(); + } + + private static void checkForms(BlockState dirt, BlockState grass) { + if (dirt == null || grass == null || canonical(dirt).equals(canonical(grass)) + || dirt.hasBlockEntity() || grass.hasBlockEntity()) { + throw new IllegalArgumentException("Grass forms need distinct states without block entities"); + } + } + + private static void checkRegistrationOpen() { + if (frozen) { + throw new IllegalStateException("Grass form registration is closed"); + } + } +} diff --git a/src/main/java/zone/moddev/mc/skysgrassslabs/block/GrassSlabBlock.java b/src/main/java/zone/moddev/mc/skysgrassslabs/block/GrassSlabBlock.java index f6e21f5..3df35e4 100644 --- a/src/main/java/zone/moddev/mc/skysgrassslabs/block/GrassSlabBlock.java +++ b/src/main/java/zone/moddev/mc/skysgrassslabs/block/GrassSlabBlock.java @@ -32,6 +32,7 @@ import net.minecraft.world.level.levelgen.placement.PlacedFeature; import net.neoforged.neoforge.common.ItemAbility; import net.neoforged.neoforge.common.util.TriState; +import zone.moddev.mc.skysgrassslabs.api.GrassSlabsApi; import zone.moddev.mc.skysgrassslabs.init.ModBlocks; /** Grass slab with target aware spreading and top slab vegetation behaviour. */ @@ -116,8 +117,7 @@ public TriState canSustainPlant(BlockState state, BlockGetter level, BlockPos po || direction != Direction.UP) { return TriState.FALSE; } - return Blocks.GRASS_BLOCK.defaultBlockState() - .canSustainPlant(level, pos, direction, plant); + return PlantSoilView.supports(Blocks.GRASS_BLOCK.defaultBlockState(), level, pos, direction, plant); } @Override @@ -189,8 +189,6 @@ public void performBonemeal(ServerLevel level, RandomSource random, BlockPos pos } private static void dirtifyGrassSupport(Level level, BlockPos pos) { - if (!level.isClientSide && level.getBlockState(pos.below()).is(Blocks.GRASS_BLOCK)) { - level.setBlock(pos.below(), Blocks.DIRT.defaultBlockState(), Block.UPDATE_CLIENTS); - } + GrassSlabsApi.repairSupport(level, pos); } } diff --git a/src/main/java/zone/moddev/mc/skysgrassslabs/block/GrassSpread.java b/src/main/java/zone/moddev/mc/skysgrassslabs/block/GrassSpread.java index 101fd26..dc9bbbb 100644 --- a/src/main/java/zone/moddev/mc/skysgrassslabs/block/GrassSpread.java +++ b/src/main/java/zone/moddev/mc/skysgrassslabs/block/GrassSpread.java @@ -3,26 +3,25 @@ import net.minecraft.util.RandomSource; import javax.annotation.Nullable; import net.minecraft.core.BlockPos; -import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.Level; import net.minecraft.tags.FluidTags; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.SlabBlock; import net.minecraft.world.level.block.SnowyDirtBlock; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.properties.SlabType; +import zone.moddev.mc.skysgrassslabs.api.GrassSlabsApi; import zone.moddev.mc.skysgrassslabs.init.ModBlocks; /** Shared vanilla-shaped grass propagation for slabs and turf. */ public final class GrassSpread { private static final int SPREAD_ATTEMPTS = 4; - public static boolean canRemainGrass(ServerLevel level, BlockPos pos) { - BlockPos above = pos.above(); - return level.getMaxLocalRawBrightness(above) >= 4 - || level.getBlockState(above).getLightBlock(level, above) < level.getMaxLightLevel(); + public static boolean canRemainGrass(Level level, BlockPos pos) { + return SoilLifecycle.canRemainGrass(level.getBlockState(pos), level, pos); } - public static boolean hasSpreadLight(ServerLevel level, BlockPos pos) { + public static boolean hasSpreadLight(Level level, BlockPos pos) { BlockPos above = pos.above(); BlockState cover = level.getBlockState(above); return level.getMaxLocalRawBrightness(above) >= 9 @@ -30,7 +29,7 @@ public static boolean hasSpreadLight(ServerLevel level, BlockPos pos) { && !level.getFluidState(above).is(FluidTags.WATER); } - public static void spreadFrom(ServerLevel level, BlockPos source, RandomSource random, + public static void spreadFrom(Level level, BlockPos source, RandomSource random, @Nullable BlockPos excludedTarget) { if (!level.isAreaLoaded(source, 3) || !hasSpreadLight(level, source)) { return; @@ -42,11 +41,11 @@ public static void spreadFrom(ServerLevel level, BlockPos source, RandomSource r || !level.isLoaded(target)) { continue; } - growTarget(level, target); + growTarget(level, target, level.getBlockState(source)); } } - public static void tickDirtSlab(ServerLevel level, BlockPos target, BlockState state, + public static void tickDirtSlab(Level level, BlockPos target, BlockState state, RandomSource random) { if (!level.isAreaLoaded(target, 3) || !targetIsViable(level, target)) { return; @@ -58,37 +57,42 @@ public static void tickDirtSlab(ServerLevel level, BlockPos target, BlockState s continue; } if (isViableSource(level, source)) { - level.setBlockAndUpdate(target, snowyState(level, target, - ModBlocks.grassStateLike(state))); - return; + BlockState grass = GrassSlabsApi.grassFor(state, level.getBlockState(source)); + if (grass != null) { + level.setBlockAndUpdate(target, snowyState(level, target, grass)); + return; + } } } } - public static boolean growTarget(ServerLevel level, BlockPos target) { + public static boolean growTarget(Level level, BlockPos target) { + return growTarget(level, target, Blocks.GRASS_BLOCK.defaultBlockState()); + } + + private static boolean growTarget(Level level, BlockPos target, BlockState source) { if (!targetIsViable(level, target)) { return false; } BlockState state = level.getBlockState(target); - if (state.is(Blocks.DIRT)) { + if (state.is(ModBlocks.DIRT_SLAB.get()) + && state.getValue(SlabBlock.TYPE) == SlabType.DOUBLE) { return level.setBlockAndUpdate(target, Blocks.GRASS_BLOCK.defaultBlockState()); } - if (state.is(ModBlocks.DIRT_SLAB.get())) { - if (state.getValue(SlabBlock.TYPE) == SlabType.DOUBLE) { - return level.setBlockAndUpdate(target, Blocks.GRASS_BLOCK.defaultBlockState()); - } - return level.setBlockAndUpdate(target, - snowyState(level, target, ModBlocks.grassStateLike(state))); + + BlockState grass = GrassSlabsApi.grassFor(state, source); + if (grass == null && state.is(Blocks.DIRT)) { + grass = Blocks.GRASS_BLOCK.defaultBlockState(); } - return false; + return grass != null && level.setBlockAndUpdate(target, snowyState(level, target, grass)); } - public static boolean isViableSource(ServerLevel level, BlockPos pos) { + public static boolean isViableSource(Level level, BlockPos pos) { BlockState state = level.getBlockState(pos); if (!canRemainGrass(level, pos) || !hasSpreadLight(level, pos)) { return false; } - if (state.is(Blocks.GRASS_BLOCK)) { + if (state.is(Blocks.GRASS_BLOCK) || GrassSlabsApi.isSpreadingGrassForm(state)) { return true; } if (state.is(ModBlocks.GRASS_SLAB.get())) { @@ -98,17 +102,18 @@ public static boolean isViableSource(ServerLevel level, BlockPos pos) { && level.getBlockState(pos.below()).is(Blocks.DIRT); } - private static boolean targetIsViable(ServerLevel level, BlockPos target) { + private static boolean targetIsViable(Level level, BlockPos target) { BlockState state = level.getBlockState(target); - boolean dirt = state.is(Blocks.DIRT) - || state.is(ModBlocks.DIRT_SLAB.get()) - && !state.getValue(SlabBlock.WATERLOGGED); - if (!dirt) { + boolean dirt = state.is(Blocks.DIRT) || GrassSlabsApi.grassFor(state) != null + || state.is(ModBlocks.DIRT_SLAB.get()); + if (!dirt || !state.getFluidState().isEmpty()) { return false; } BlockPos above = target.above(); BlockState cover = level.getBlockState(above); - if (cover.is(ModBlocks.TURF.get()) || cover.is(ModBlocks.GRASS_SLAB.get())) { + if (cover.is(ModBlocks.TURF.get()) || GrassSlabsApi.isGrassForm(cover) + && (!state.isCollisionShapeFullBlock(level, target) + || GrassSlabsApi.coversSupport(cover, level, above))) { return false; } return level.getMaxLocalRawBrightness(above) >= 4 @@ -116,14 +121,14 @@ private static boolean targetIsViable(ServerLevel level, BlockPos target) { && !level.getFluidState(above).is(FluidTags.WATER); } - private static BlockState snowyState(ServerLevel level, BlockPos pos, BlockState state) { + private static BlockState snowyState(Level level, BlockPos pos, BlockState state) { return state.hasProperty(SnowyDirtBlock.SNOWY) ? state.setValue(SnowyDirtBlock.SNOWY, SnowySlabAppearance.hasNearbySnow(level, pos)) : state; } - private static boolean withinBuildHeight(ServerLevel level, BlockPos pos) { + private static boolean withinBuildHeight(Level level, BlockPos pos) { return pos.getY() >= level.getMinBuildHeight() && pos.getY() < level.getMaxBuildHeight(); } diff --git a/src/main/java/zone/moddev/mc/skysgrassslabs/block/PlantSoilView.java b/src/main/java/zone/moddev/mc/skysgrassslabs/block/PlantSoilView.java new file mode 100644 index 0000000..a213458 --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysgrassslabs/block/PlantSoilView.java @@ -0,0 +1,140 @@ +package zone.moddev.mc.skysgrassslabs.block; + +import java.util.List; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.Holder; +import net.minecraft.core.RegistryAccess; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.flag.FeatureFlagSet; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.ColorResolver; +import net.minecraft.world.level.LevelReader; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeManager; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.border.WorldBorder; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.dimension.DimensionType; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.lighting.LevelLightEngine; +import net.minecraft.world.level.material.FluidState; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.shapes.VoxelShape; +import net.neoforged.neoforge.common.util.TriState; + +/** Shows a plant the full soil beneath it while keeping its real surroundings. */ +record PlantSoilView(LevelReader world, BlockPos soilPosition, BlockState soil) implements LevelReader { + static TriState supports(BlockState soil, BlockGetter world, BlockPos position, Direction face, + BlockState plant) { + TriState support = soil.canSustainPlant(world, position, face, plant); + if (!support.isDefault() || !(world instanceof LevelReader level)) { + return support; + } + // Ask the plant about the full soil without changing the actual block. + return plant.canSurvive(new PlantSoilView(level, position, soil), position.relative(face)) + ? TriState.TRUE : TriState.FALSE; + } + + @Override + public BlockState getBlockState(BlockPos position) { + return soilPosition.equals(position) ? soil : world.getBlockState(position); + } + + @Override + public FluidState getFluidState(BlockPos position) { + return soilPosition.equals(position) ? soil.getFluidState() : world.getFluidState(position); + } + + @Override + public BlockEntity getBlockEntity(BlockPos position) { + return soilPosition.equals(position) ? null : world.getBlockEntity(position); + } + + @Override + public ChunkAccess getChunk(int x, int z, ChunkStatus status, boolean load) { + return world.getChunk(x, z, status, load); + } + + @Override + public boolean hasChunk(int x, int z) { + return world.hasChunk(x, z); + } + + @Override + public int getHeight(Heightmap.Types type, int x, int z) { + return world.getHeight(type, x, z); + } + + @Override + public int getSkyDarken() { + return world.getSkyDarken(); + } + + @Override + public BiomeManager getBiomeManager() { + return world.getBiomeManager(); + } + + @Override + public Holder getUncachedNoiseBiome(int x, int y, int z) { + return world.getUncachedNoiseBiome(x, y, z); + } + + @Override + public boolean isClientSide() { + return world.isClientSide(); + } + + @Override + public int getSeaLevel() { + return world.getSeaLevel(); + } + + @Override + public DimensionType dimensionType() { + return world.dimensionType(); + } + + @Override + public RegistryAccess registryAccess() { + return world.registryAccess(); + } + + @Override + public FeatureFlagSet enabledFeatures() { + return world.enabledFeatures(); + } + + @Override + public float getShade(Direction direction, boolean shade) { + return world.getShade(direction, shade); + } + + @Override + public LevelLightEngine getLightEngine() { + return world.getLightEngine(); + } + + @Override + public int getBlockTint(BlockPos position, ColorResolver resolver) { + return world.getBlockTint(position, resolver); + } + + @Override + public WorldBorder getWorldBorder() { + return world.getWorldBorder(); + } + + @Override + public BlockGetter getChunkForCollisions(int x, int z) { + return world.getChunkForCollisions(x, z); + } + + @Override + public List getEntityCollisions(Entity entity, AABB area) { + return world.getEntityCollisions(entity, area); + } +} diff --git a/src/main/java/zone/moddev/mc/skysgrassslabs/block/SoilLifecycle.java b/src/main/java/zone/moddev/mc/skysgrassslabs/block/SoilLifecycle.java index f9d988c..3213695 100644 --- a/src/main/java/zone/moddev/mc/skysgrassslabs/block/SoilLifecycle.java +++ b/src/main/java/zone/moddev/mc/skysgrassslabs/block/SoilLifecycle.java @@ -9,6 +9,7 @@ import net.minecraft.world.level.block.SnowLayerBlock; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.lighting.LightEngine; +import zone.moddev.mc.skysgrassslabs.api.GrassSlabsApi; import zone.moddev.mc.skysgrassslabs.init.ModBlocks; /** Vanilla light and water rules adapted for slab identities. */ @@ -37,6 +38,7 @@ static boolean canRemainGrass(BlockState state, LevelReader level, BlockPos pos) // block's own collision shape. BlockState lightState = state.is(ModBlocks.GRASS_SLAB.get()) || state.is(ModBlocks.TURF.get()) + || GrassSlabsApi.isGrassForm(state) ? Blocks.AIR.defaultBlockState() : state; int blocked = LightEngine.getLightBlockInto(level, lightState, pos, cover, above, Direction.UP, cover.getLightBlock(level, above)); diff --git a/src/main/java/zone/moddev/mc/skysgrassslabs/client/ClientConfig.java b/src/main/java/zone/moddev/mc/skysgrassslabs/client/ClientConfig.java new file mode 100644 index 0000000..5e541f0 --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysgrassslabs/client/ClientConfig.java @@ -0,0 +1,14 @@ +package zone.moddev.mc.skysgrassslabs.client; + +import net.neoforged.fml.ModContainer; +import net.neoforged.neoforge.client.gui.IConfigScreenFactory; + +public final class ClientConfig { + private ClientConfig() { + } + + public static void register(ModContainer container) { + IConfigScreenFactory factory = (minecraft, parent) -> new GrassSlabsConfigScreen(parent); + container.registerExtensionPoint(IConfigScreenFactory.class, factory); + } +} diff --git a/src/main/java/zone/moddev/mc/skysgrassslabs/client/GrassSlabsConfigScreen.java b/src/main/java/zone/moddev/mc/skysgrassslabs/client/GrassSlabsConfigScreen.java new file mode 100644 index 0000000..9eab4d4 --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysgrassslabs/client/GrassSlabsConfigScreen.java @@ -0,0 +1,81 @@ +package zone.moddev.mc.skysgrassslabs.client; + +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.resources.language.I18n; +import zone.moddev.mc.skysgrassslabs.SkysGrassSlabs; +import zone.moddev.mc.skysgrassslabs.config.SkysGrassSlabsConfig; + +/** Save generation and recovery choices for the next Minecraft start. */ +public final class GrassSlabsConfigScreen extends Screen { + private static final String GENERATION_KEY = "config.skysgrassslabs.generateGrassSlabs"; + private static final String REPLACEMENT_KEY = "config.skysgrassslabs.forceReplaceBuildingBricksSlabs"; + private static final String GRASS_SLABS_KEY = "config.skysgrassslabs.forceReplaceGrassSlabsModContent"; + + private final Screen parent; + private boolean generateGrass; + private boolean replaceLegacy; + private boolean replaceGrassSlabs; + private Button generationButton; + private Button replacementButton; + private Button grassSlabsButton; + + public GrassSlabsConfigScreen(Screen parent) { + super(Component.literal(SkysGrassSlabs.NAME)); + this.parent = parent; + generateGrass = SkysGrassSlabsConfig.configuredGrassGeneration(); + replaceLegacy = SkysGrassSlabsConfig.configuredLegacyReplacement(); + replaceGrassSlabs = SkysGrassSlabsConfig.configuredGrassSlabsReplacement(); + } + + @Override + protected void init() { + int left = width / 2 - 150; + generationButton = addRenderableWidget(Button.builder(label(GENERATION_KEY, generateGrass), button -> { + generateGrass = !generateGrass; + button.setMessage(label(GENERATION_KEY, generateGrass)); + }).bounds(left, 65, 300, 20).build()); + replacementButton = addRenderableWidget(Button.builder(label(REPLACEMENT_KEY, replaceLegacy), button -> { + replaceLegacy = !replaceLegacy; + button.setMessage(label(REPLACEMENT_KEY, replaceLegacy)); + }).bounds(left, 95, 300, 20).build()); + grassSlabsButton = addRenderableWidget(Button.builder(label(GRASS_SLABS_KEY, replaceGrassSlabs), button -> { + replaceGrassSlabs = !replaceGrassSlabs; + button.setMessage(label(GRASS_SLABS_KEY, replaceGrassSlabs)); + }).bounds(left, 125, 300, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.done"), button -> { + SkysGrassSlabsConfig.saveForNextStart(generateGrass, replaceLegacy, replaceGrassSlabs); + minecraft.setScreen(parent); + }).bounds(width / 2 - 100, height - 35, 200, 20).build()); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTicks) { + renderBackground(graphics, mouseX, mouseY, partialTicks); + graphics.drawCenteredString(font, title, width / 2, 25, 0xFFFFFF); + super.render(graphics, mouseX, mouseY, partialTicks); + + if (generationButton.isMouseOver(mouseX, mouseY)) { + graphics.renderTooltip(font, font.split(Component.translatable(GENERATION_KEY + ".tooltip"), 280), + mouseX, mouseY); + } else if (replacementButton.isMouseOver(mouseX, mouseY)) { + graphics.renderTooltip(font, font.split(Component.translatable(REPLACEMENT_KEY + ".tooltip"), 280), + mouseX, mouseY); + } else if (grassSlabsButton.isMouseOver(mouseX, mouseY)) { + graphics.renderTooltip(font, font.split(Component.translatable(GRASS_SLABS_KEY + ".tooltip"), 280), + mouseX, mouseY); + } + } + + @Override + public void onClose() { + // Escape returns without saving the pending button values. + minecraft.setScreen(parent); + } + + private static Component label(String key, boolean enabled) { + return Component.literal(I18n.get(key) + ": " + I18n.get(enabled ? "options.on" : "options.off")); + } +} diff --git a/src/main/java/zone/moddev/mc/skysgrassslabs/compat/LegacyWorldDataHook.java b/src/main/java/zone/moddev/mc/skysgrassslabs/compat/LegacyWorldDataHook.java index 9f5bdb7..1dd4d5e 100644 --- a/src/main/java/zone/moddev/mc/skysgrassslabs/compat/LegacyWorldDataHook.java +++ b/src/main/java/zone/moddev/mc/skysgrassslabs/compat/LegacyWorldDataHook.java @@ -45,12 +45,14 @@ public final class LegacyWorldDataHook { private static final Logger LOGGER = LogManager.getLogger(); private static final BitSet SUPPORTED_BLOCK_IDS = new BitSet(); private static final Set LEGACY_CHUNKS = ConcurrentHashMap.newKeySet(); + private static final Set SAVED_OVERWORLD_CHUNKS = ConcurrentHashMap.newKeySet(); private static final Set SKY_IDS = new LinkedHashSet<>(); private static final Set HISTORICAL_IDS = new LinkedHashSet<>(); private static final String PRESERVE_CHUNK_MARKER = "SkysGrassSlabsLegacyPreserveChunk"; private static final String SIDECAR_NAME = "skysgrassslabs_legacy_registry.dat"; private static volatile boolean legacyWorldActive; + private static CompoundTag legacyFmlData = new CompoundTag(); private static boolean registered; static { @@ -89,10 +91,12 @@ public static void captureLegacyLevelData(CompoundTag root, public static void onServerAboutToStart(ServerAboutToStartEvent event) { File levelDat = event.getServer().getWorldPath(LevelResource.LEVEL_DATA_FILE).toFile(); prepareLegacyWorld(levelDat); + snapshotSavedOverworldChunks(levelDat.getParentFile()); } static synchronized void prepareLegacyWorld(File levelDat) { legacyWorldActive = false; + legacyFmlData = new CompoundTag(); LEGACY_CHUNKS.clear(); if (!levelDat.isFile()) { return; @@ -100,8 +104,10 @@ static synchronized void prepareLegacyWorld(File levelDat) { try (FileInputStream input = new FileInputStream(levelDat)) { CompoundTag root = NbtIo.readCompressed(input, NbtAccounter.unlimitedHeap()); if (root.contains("FML", Tag.TAG_COMPOUND)) { - CompoundTag registries = root.getCompound("FML").getCompound("Registries"); + CompoundTag fmlData = root.getCompound("FML"); + CompoundTag registries = fmlData.getCompound("Registries"); if (registries.contains("minecraft:blocks", Tag.TAG_COMPOUND)) { + legacyFmlData = fmlData.copy(); CompoundTag blocks = registries.getCompound("minecraft:blocks"); install(levelDat.getParentFile(), blocks); writeSidecar(levelDat.getParentFile(), blocks); @@ -118,10 +124,12 @@ static synchronized void prepareLegacyWorld(File levelDat) { private static synchronized void prepareLegacyWorld(File worldDirectory, CompoundTag fmlData) { legacyWorldActive = false; + legacyFmlData = new CompoundTag(); LEGACY_CHUNKS.clear(); if (fmlData.contains("Registries", Tag.TAG_COMPOUND)) { CompoundTag registries = fmlData.getCompound("Registries"); if (registries.contains("minecraft:blocks", Tag.TAG_COMPOUND)) { + legacyFmlData = fmlData.copy(); CompoundTag blocks = registries.getCompound("minecraft:blocks"); install(worldDirectory, blocks); writeSidecar(worldDirectory, blocks); @@ -131,14 +139,24 @@ private static synchronized void prepareLegacyWorld(File worldDirectory, Compoun readSidecar(worldDirectory); } + /** Keep the old numeric registry alongside NeoForge's current lowercase snapshot. */ + public static synchronized void writeLegacyLevelData(CompoundTag levelTag) { + if (!legacyFmlData.isEmpty()) { + levelTag.put("FML", legacyFmlData.copy()); + } + } + private static void readSidecar(File worldDirectory) { File sidecar = sidecar(worldDirectory); if (!sidecar.isFile()) { return; } try (FileInputStream input = new FileInputStream(sidecar)) { - install(worldDirectory, NbtIo.readCompressed(input, - NbtAccounter.unlimitedHeap()).getCompound("Blocks")); + CompoundTag snapshot = NbtIo.readCompressed(input, NbtAccounter.unlimitedHeap()); + if (snapshot.contains("FML", Tag.TAG_COMPOUND)) { + legacyFmlData = snapshot.getCompound("FML").copy(); + } + install(worldDirectory, snapshot.getCompound("Blocks")); } catch (IOException exception) { LOGGER.warn("Could not read legacy Sky's Grass Slabs registry sidecar '{}'", sidecar, exception); @@ -149,7 +167,7 @@ private static void install(File worldDirectory, CompoundTag blockSnapshot) { int mapped = installLegacyBlockStates(blockSnapshot); legacyWorldActive = mapped > 0; if (legacyWorldActive) { - int indexed = indexLegacyChunks(worldDirectory); + int indexed = indexRegionChunks(worldDirectory, LEGACY_CHUNKS); LOGGER.info("Prepared {} legacy slab states and protected {} existing Overworld " + "chunks from '{}'", mapped, indexed, worldDirectory); } @@ -166,6 +184,9 @@ private static void writeSidecar(File worldDirectory, CompoundTag blockSnapshot) Files.createDirectories(parent.toPath()); CompoundTag root = new CompoundTag(); root.put("Blocks", blockSnapshot.copy()); + if (!legacyFmlData.isEmpty()) { + root.put("FML", legacyFmlData.copy()); + } try (FileOutputStream output = new FileOutputStream(temporary)) { NbtIo.writeCompressed(root, output); } @@ -217,8 +238,20 @@ public static CompoundTag finalizeLegacyChunk(CompoundTag root) { return root; } - public static boolean isLegacyChunk(int chunkX, int chunkZ) { - return legacyWorldActive && LEGACY_CHUNKS.contains(chunkKey(chunkX, chunkZ)); + static void snapshotSavedOverworldChunks(File worldDirectory) { + if (legacyWorldActive) { + SAVED_OVERWORLD_CHUNKS.clear(); + SAVED_OVERWORLD_CHUNKS.addAll(LEGACY_CHUNKS); + } else { + indexRegionChunks(worldDirectory, SAVED_OVERWORLD_CHUNKS); + } + } + + /** A saved chunk may still go through decoration when the world next opens. */ + public static boolean isSavedOverworldChunk(int chunkX, int chunkZ) { + long key = chunkKey(chunkX, chunkZ); + return SAVED_OVERWORLD_CHUNKS.contains(key) + || (legacyWorldActive && LEGACY_CHUNKS.contains(key)); } private static int installLegacyBlockStates(CompoundTag blockSnapshot) { @@ -306,8 +339,8 @@ private static boolean containsSupportedBlock(CompoundTag level) { return false; } - private static int indexLegacyChunks(File worldDirectory) { - LEGACY_CHUNKS.clear(); + private static int indexRegionChunks(File worldDirectory, Set chunks) { + chunks.clear(); File regionDirectory = new File(worldDirectory, "region"); File[] regionFiles = regionDirectory.listFiles((directory, name) -> name.startsWith("r.") && (name.endsWith(".mca") || name.endsWith(".mcr"))); @@ -341,16 +374,16 @@ private static int indexLegacyChunks(File worldDirectory) { int offset = index * 4; if ((locations[offset] | locations[offset + 1] | locations[offset + 2] | locations[offset + 3]) != 0) { - LEGACY_CHUNKS.add(chunkKey(regionX * 32 + (index & 31), + chunks.add(chunkKey(regionX * 32 + (index & 31), regionZ * 32 + (index >> 5))); } } } catch (IOException exception) { - LOGGER.warn("Could not inspect legacy chunk locations in '{}'", regionFile, + LOGGER.warn("Could not inspect saved chunk locations in '{}'", regionFile, exception); } } - return LEGACY_CHUNKS.size(); + return chunks.size(); } private static Method findLegacyStateRegistrationMethod() { diff --git a/src/main/java/zone/moddev/mc/skysgrassslabs/config/SkysGrassSlabsConfig.java b/src/main/java/zone/moddev/mc/skysgrassslabs/config/SkysGrassSlabsConfig.java index ad1883b..b1d794b 100644 --- a/src/main/java/zone/moddev/mc/skysgrassslabs/config/SkysGrassSlabsConfig.java +++ b/src/main/java/zone/moddev/mc/skysgrassslabs/config/SkysGrassSlabsConfig.java @@ -5,12 +5,18 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import com.electronwill.nightconfig.core.CommentedConfig; +import com.electronwill.nightconfig.core.concurrent.ConcurrentCommentedConfig; import org.apache.commons.lang3.tuple.Pair; import net.neoforged.neoforge.common.ModConfigSpec; -import net.neoforged.fml.ModContainer; +import net.neoforged.fml.config.IConfigSpec.ILoadedConfig; import net.neoforged.fml.config.ModConfig; +import net.neoforged.fml.event.config.ModConfigEvent; import net.neoforged.fml.loading.FMLPaths; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.fml.ModContainer; import zone.moddev.mc.skysgrassslabs.SkysGrassSlabs; +import zone.moddev.mc.skysgrassslabs.api.GrassSlabsApi; /** Common configuration and one-time migration from the 1.10 configuration file. */ public final class SkysGrassSlabsConfig { @@ -23,7 +29,12 @@ public final class SkysGrassSlabsConfig { private static final Common COMMON; private static final ModConfigSpec SPEC; + private static volatile ModConfig commonConfig; private static volatile boolean compatibilitySuppressed; + private static boolean settingsActivated; + private static boolean activeGrassGeneration; + private static boolean activeLegacyReplacement; + private static boolean activeGrassSlabsReplacement; static { Pair pair = new ModConfigSpec.Builder().configure(Common::new); @@ -31,8 +42,13 @@ public final class SkysGrassSlabsConfig { SPEC = pair.getRight(); } - public static void register(ModContainer modContainer) { - modContainer.registerConfig(ModConfig.Type.COMMON, SPEC, FILE_NAME); + public static void register(IEventBus modBus, ModContainer container) { + container.registerConfig(ModConfig.Type.COMMON, SPEC, FILE_NAME); + modBus.addListener((ModConfigEvent.Loading event) -> { + if (event.getConfig().getSpec() == SPEC) { + commonConfig = event.getConfig(); + } + }); } public static void migrateLegacyConfig() { @@ -65,7 +81,7 @@ static boolean migrateLegacyConfig(Path configDirectory) { return true; } catch (IOException exception) { SkysGrassSlabs.LOGGER.warn("Could not migrate the legacy Sky's Grass Slabs config; " - + "built-in defaults will be used", exception); + + "NeoForge defaults will be used", exception); return false; } } @@ -94,19 +110,71 @@ private static boolean readLegacyBoolean(Path file, String key, boolean fallback } public static boolean generateGrassSlabs() { - return COMMON.generateGrassSlabs.get(); + return settingsActivated ? activeGrassGeneration : COMMON.generateGrassSlabs.get(); } public static boolean forceReplaceBuildingBricksSlabs() { - return COMMON.forceReplaceBuildingBricksSlabs.get(); + return settingsActivated ? activeLegacyReplacement : COMMON.forceReplaceBuildingBricksSlabs.get(); } public static boolean forceReplaceGrassSlabsModContent() { - return COMMON.forceReplaceGrassSlabsModContent.get(); + return settingsActivated ? activeGrassSlabsReplacement : COMMON.forceReplaceGrassSlabsModContent.get(); + } + + public static boolean configuredGrassSlabsReplacement() { + return COMMON.forceReplaceGrassSlabsModContent.getRaw(); + } + + /** Keep a running world on its startup settings when the Config screen saves new choices. */ + public static void activateSettingsForThisRun() { + activeGrassGeneration = COMMON.generateGrassSlabs.get(); + activeLegacyReplacement = COMMON.forceReplaceBuildingBricksSlabs.get(); + activeGrassSlabsReplacement = COMMON.forceReplaceGrassSlabsModContent.get(); + settingsActivated = true; + } + + public static boolean configuredGrassGeneration() { + return COMMON.generateGrassSlabs.getRaw(); + } + + public static boolean configuredLegacyReplacement() { + return COMMON.forceReplaceBuildingBricksSlabs.getRaw(); + } + + public static void saveForNextStart(boolean generateGrass, boolean replaceLegacy) { + saveForNextStart(generateGrass, replaceLegacy, configuredGrassSlabsReplacement()); + } + + public static void saveForNextStart(boolean generateGrass, boolean replaceLegacy, + boolean replaceGrassSlabs) { + ModConfig config = commonConfig; + ILoadedConfig loaded = config == null ? null : config.getLoadedConfig(); + if (loaded == null) { + throw new IllegalStateException("Grass Slabs settings have not loaded yet"); + } + CommentedConfig values = loaded.config(); + // Keep all three changes together under NightConfig's write lock, then + // let NeoForge save the file and notify any reload listeners. + if (values instanceof ConcurrentCommentedConfig concurrent) { + concurrent.bulkCommentedUpdate(view -> { + setChoices(view, generateGrass, replaceLegacy, replaceGrassSlabs); + }); + } else { + setChoices(values, generateGrass, replaceLegacy, replaceGrassSlabs); + } + loaded.save(); + } + + private static void setChoices(CommentedConfig values, boolean generateGrass, + boolean replaceLegacy, boolean replaceGrassSlabs) { + values.set("worldgen." + GENERATE_GRASS_SLABS, generateGrass); + values.set("compat." + FORCE_REPLACE_BUILDINGBRICKS_SLABS, replaceLegacy); + values.set("compat." + FORCE_REPLACE_GRASS_SLABS_MOD_CONTENT, replaceGrassSlabs); } public static boolean isSmoothingActive() { - return generateGrassSlabs() && !compatibilitySuppressed; + return generateGrassSlabs() && !compatibilitySuppressed + && GrassSlabsApi.ownerAllowsGeneration(); } public static void suppressSmoothingForThisRun() { @@ -115,6 +183,7 @@ public static void suppressSmoothingForThisRun() { static void resetRunStateForTests() { compatibilitySuppressed = false; + settingsActivated = false; } private static final class Common { diff --git a/src/main/java/zone/moddev/mc/skysgrassslabs/world/GrassSlabSmoothingFeature.java b/src/main/java/zone/moddev/mc/skysgrassslabs/world/GrassSlabSmoothingFeature.java index 079814c..3d3bea2 100644 --- a/src/main/java/zone/moddev/mc/skysgrassslabs/world/GrassSlabSmoothingFeature.java +++ b/src/main/java/zone/moddev/mc/skysgrassslabs/world/GrassSlabSmoothingFeature.java @@ -9,6 +9,7 @@ import net.minecraft.server.level.WorldGenRegion; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.DoublePlantBlock; import net.minecraft.world.level.block.SlabBlock; import net.minecraft.world.level.block.SnowyDirtBlock; import net.minecraft.world.level.block.state.BlockState; @@ -18,6 +19,7 @@ import net.minecraft.world.level.levelgen.feature.Feature; import net.minecraft.world.level.levelgen.feature.FeaturePlaceContext; import net.minecraft.world.level.levelgen.feature.configurations.NoneFeatureConfiguration; +import zone.moddev.mc.skysgrassslabs.api.GrassSlabsApi; import zone.moddev.mc.skysgrassslabs.compat.LegacyWorldDataHook; import zone.moddev.mc.skysgrassslabs.config.SkysGrassSlabsConfig; import zone.moddev.mc.skysgrassslabs.init.ModBlocks; @@ -36,7 +38,7 @@ public GrassSlabSmoothingFeature(Codec codec) { @Override public boolean place(FeaturePlaceContext context) { WorldGenLevel level = context.level(); - if (!SkysGrassSlabsConfig.isSmoothingActive() + if (GrassSlabsApi.hasSmoothingOwner() || !SkysGrassSlabsConfig.isSmoothingActive() || level.getLevel().dimension() != Level.OVERWORLD) { return false; } @@ -44,7 +46,12 @@ public boolean place(FeaturePlaceContext context) { ? level.getChunk(region.getCenter().x, region.getCenter().z) : level.getChunk(context.origin()); ChunkPos ownerPos = owner.getPos(); - if (LegacyWorldDataHook.isLegacyChunk(ownerPos.x, ownerPos.z)) { + if (LegacyWorldDataHook.isSavedOverworldChunk(ownerPos.x, ownerPos.z)) { + return false; + } + StructureProtection structures = StructureProtection.read(owner, position -> + level.hasChunk(position.x, position.z) ? level.getChunk(position.x, position.z) : null); + if (!structures.canInspectSafely()) { return false; } @@ -52,6 +59,7 @@ public boolean place(FeaturePlaceContext context) { Arrays.fill(buffer.heights, MISSING); Arrays.fill(buffer.grass, false); Arrays.fill(buffer.candidates, false); + Arrays.fill(buffer.originalTargets, null); BlockPos.MutableBlockPos cursor = buffer.cursor; for (int haloZ = 0; haloZ < HALO_WIDTH; ++haloZ) { @@ -72,7 +80,9 @@ public boolean place(FeaturePlaceContext context) { if (surfaceY != MISSING) { cursor.set(chunk.getPos().getMinBlockX() + x, surfaceY, chunk.getPos().getMinBlockZ() + z); - buffer.grass[index] = chunk.getBlockState(cursor).is(Blocks.GRASS_BLOCK); + BlockState surface = chunk.getBlockState(cursor); + buffer.grass[index] = surface.is(Blocks.GRASS_BLOCK) + || surface.is(Blocks.DIRT) && isGrassCap(chunk.getBlockState(cursor.above())); } } } @@ -88,7 +98,11 @@ public boolean place(FeaturePlaceContext context) { } cursor.set(minX + localX, y + 1, minZ + localZ); BlockState target = owner.getBlockState(cursor); - boolean clear = target.isAir() && owner.getBlockEntityNbt(cursor) == null; + if (structures.contains(cursor) || structures.contains(cursor.below()) + || target.getBlock() instanceof DoublePlantBlock && structures.contains(cursor.above())) { + continue; + } + boolean clear = SmoothingFoliage.canReplace(level, owner, cursor); boolean dry = target.getFluidState().isEmpty(); cursor.setY(y); BlockState support = owner.getBlockState(cursor); @@ -97,11 +111,15 @@ public boolean place(FeaturePlaceContext context) { int south = haloIndex(localX + 1, localZ + 2); int west = haloIndex(localX, localZ + 1); int east = haloIndex(localX + 2, localZ + 1); - buffer.candidates[localZ << 4 | localX] = SmoothingDecision.shouldPlace( + int index = localZ << 4 | localX; + buffer.candidates[index] = SmoothingDecision.shouldPlace( y, buffer.heights[north], buffer.heights[south], buffer.heights[west], buffer.heights[east], buffer.grass[center], buffer.grass[north], buffer.grass[south], buffer.grass[west], buffer.grass[east], clear, dry, supported); + if (buffer.candidates[index]) { + buffer.originalTargets[index] = target; + } } } @@ -118,12 +136,21 @@ public boolean place(FeaturePlaceContext context) { int localZ = index >>> 4; int y = buffer.heights[haloIndex(localX + 1, localZ + 1)]; cursor.set(minX + localX, y + 1, minZ + localZ); - if (!level.ensureCanWrite(cursor) || !owner.getBlockState(cursor).isAir() - || !owner.getFluidState(cursor).isEmpty() - || owner.getBlockEntityNbt(cursor) != null) { + BlockState target = buffer.originalTargets[index]; + boolean tallPlant = target.getBlock() instanceof DoublePlantBlock; + if (!level.ensureCanWrite(cursor) || !level.ensureCanWrite(cursor.below()) + || tallPlant && !level.ensureCanWrite(cursor.above()) + || owner.getBlockState(cursor) != target + || !SmoothingFoliage.canReplace(level, owner, cursor)) { continue; } owner.setBlockState(cursor, slab, false); + if (owner.getBlockState(cursor) != slab) { + continue; + } + if (tallPlant) { + owner.setBlockState(cursor.above(), Blocks.AIR.defaultBlockState(), false); + } cursor.setY(y); if (owner.getBlockState(cursor).is(Blocks.GRASS_BLOCK)) { owner.setBlockState(cursor, Blocks.DIRT.defaultBlockState(), false); @@ -135,16 +162,39 @@ public boolean place(FeaturePlaceContext context) { private static int surfaceY(ChunkAccess chunk, int localX, int localZ, BlockPos.MutableBlockPos cursor) { - int y = chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, localX, localZ); + // The generation map is not retained on every finished chunk at the border. + // The live surface map counts the same blocks and is already up to date there. + Heightmap.Types heightmap = Heightmap.Types.WORLD_SURFACE_WG; + if (!chunk.hasPrimedHeightmap(heightmap)) { + heightmap = Heightmap.Types.WORLD_SURFACE; + if (!chunk.hasPrimedHeightmap(heightmap)) { + return MISSING; + } + } + int y = chunk.getHeight(heightmap, localX, localZ); int minY = chunk.getMinBuildHeight(); int x = chunk.getPos().getMinBlockX() + localX; int z = chunk.getPos().getMinBlockZ() + localZ; - while (y >= minY && chunk.getBlockState(cursor.set(x, y, z)).isAir()) { + while (y >= minY) { + BlockState state = chunk.getBlockState(cursor.set(x, y, z)); + if (!state.isAir() && !SmoothingFoliage.isPlant(state)) { + break; + } + --y; + } + if (y > minY && isGrassCap(chunk.getBlockState(cursor.set(x, y, z))) + && chunk.getBlockState(cursor.below()).is(Blocks.DIRT)) { --y; } return y >= minY ? y : MISSING; } + private static boolean isGrassCap(BlockState state) { + return state.is(ModBlocks.GRASS_SLAB.get()) + && state.getValue(SlabBlock.TYPE) == SlabType.BOTTOM + && !state.getValue(SlabBlock.WATERLOGGED); + } + private static int haloIndex(int x, int z) { return z * HALO_WIDTH + x; } @@ -153,6 +203,7 @@ private static final class DecisionBuffer { private final int[] heights = new int[HALO_WIDTH * HALO_WIDTH]; private final boolean[] grass = new boolean[HALO_WIDTH * HALO_WIDTH]; private final boolean[] candidates = new boolean[256]; + private final BlockState[] originalTargets = new BlockState[256]; private final BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); } } diff --git a/src/main/java/zone/moddev/mc/skysgrassslabs/world/ModWorldState.java b/src/main/java/zone/moddev/mc/skysgrassslabs/world/ModWorldState.java index 39d9160..ac827f8 100644 --- a/src/main/java/zone/moddev/mc/skysgrassslabs/world/ModWorldState.java +++ b/src/main/java/zone/moddev/mc/skysgrassslabs/world/ModWorldState.java @@ -10,7 +10,6 @@ import net.minecraft.server.level.ServerLevel; import net.minecraft.world.level.Level; import net.minecraft.world.level.saveddata.SavedData; -import net.minecraft.util.datafix.DataFixTypes; /** Persistent schema and aggregate historical-slab migration totals. */ public final class ModWorldState extends SavedData { @@ -56,10 +55,13 @@ public static ModWorldState get(Level level) { if (!(level instanceof ServerLevel serverLevel)) { return new ModWorldState(); } - SavedData.Factory factory = new SavedData.Factory<>( - ModWorldState::new, (tag, registries) -> new ModWorldState(tag), - DataFixTypes.SAVED_DATA_MAP_DATA); - return serverLevel.getDataStorage().computeIfAbsent(factory, DATA_NAME); + return serverLevel.getDataStorage().computeIfAbsent(factory(), DATA_NAME); + } + + static SavedData.Factory factory() { + // Older Grass Slabs saves may have no DataVersion. The vanilla map + // fixer would move our counters out of the data section in those saves. + return new SavedData.Factory<>(ModWorldState::new, (tag, registries) -> new ModWorldState(tag), null); } public void recordChunk() { diff --git a/src/main/java/zone/moddev/mc/skysgrassslabs/world/SmoothingBiomeModifier.java b/src/main/java/zone/moddev/mc/skysgrassslabs/world/SmoothingBiomeModifier.java index 1b99a67..d1e621a 100644 --- a/src/main/java/zone/moddev/mc/skysgrassslabs/world/SmoothingBiomeModifier.java +++ b/src/main/java/zone/moddev/mc/skysgrassslabs/world/SmoothingBiomeModifier.java @@ -10,6 +10,7 @@ import net.neoforged.neoforge.common.world.BiomeGenerationSettingsBuilder; import net.neoforged.neoforge.common.world.BiomeModifier; import net.neoforged.neoforge.common.world.ModifiableBiomeInfo.BiomeInfo; +import zone.moddev.mc.skysgrassslabs.api.GrassSlabsApi; /** Adds the smoother before vegetation in every non-Nether, non-End biome. */ public final class SmoothingBiomeModifier implements BiomeModifier { @@ -22,7 +23,7 @@ private SmoothingBiomeModifier() { @Override public void modify(Holder biome, Phase phase, BiomeInfo.Builder builder) { Holder smoothing = WorldgenBootstrap.placedFeature(); - if (phase != Phase.AFTER_EVERYTHING || smoothing == null + if (phase != Phase.AFTER_EVERYTHING || smoothing == null || GrassSlabsApi.hasSmoothingOwner() || biome.is(BiomeTags.IS_NETHER) || biome.is(BiomeTags.IS_END)) { return; } diff --git a/src/main/java/zone/moddev/mc/skysgrassslabs/world/SmoothingFoliage.java b/src/main/java/zone/moddev/mc/skysgrassslabs/world/SmoothingFoliage.java new file mode 100644 index 0000000..8b40c28 --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysgrassslabs/world/SmoothingFoliage.java @@ -0,0 +1,55 @@ +package zone.moddev.mc.skysgrassslabs.world; + +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.DoublePlantBlock; +import net.minecraft.world.level.block.state.properties.DoubleBlockHalf; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.LevelAccessor; +import net.minecraft.world.level.chunk.ChunkAccess; + +/** Small native plants may give way to a transition in a newly generated chunk. */ +final class SmoothingFoliage { + static boolean isPlant(BlockState state) { + Block block = state.getBlock(); + return block == Blocks.SHORT_GRASS || block == Blocks.FERN || block == Blocks.DEAD_BUSH + || block == Blocks.DANDELION || block == Blocks.POPPY || block == Blocks.BLUE_ORCHID + || block == Blocks.ALLIUM || block == Blocks.AZURE_BLUET || block == Blocks.RED_TULIP + || block == Blocks.ORANGE_TULIP || block == Blocks.WHITE_TULIP || block == Blocks.PINK_TULIP + || block == Blocks.OXEYE_DAISY || block == Blocks.CORNFLOWER + || block == Blocks.LILY_OF_THE_VALLEY || block == Blocks.TALL_GRASS + || block == Blocks.LARGE_FERN || block == Blocks.SUNFLOWER || block == Blocks.LILAC + || block == Blocks.ROSE_BUSH || block == Blocks.PEONY; + } + + static boolean canReplace(LevelAccessor world, ChunkAccess owner, BlockPos position) { + BlockState state = owner.getBlockState(position); + if (state.hasBlockEntity() || owner.getBlockEntity(position) != null + || owner.getBlockEntityNbt(position) != null + || !state.getFluidState().isEmpty()) { + return false; + } + if (state.isAir()) { + return true; + } + if (!isPlant(state)) { + return false; + } + if (!(state.getBlock() instanceof DoublePlantBlock)) { + return true; + } + if (position.getY() >= owner.getMaxBuildHeight() - 1 || state.getValue(DoublePlantBlock.HALF) != DoubleBlockHalf.LOWER) { + return false; + } + + BlockState upper = owner.getBlockState(position.above()); + return upper.getBlock() == state.getBlock() + && upper.getValue(DoublePlantBlock.HALF) == DoubleBlockHalf.UPPER + && owner.getBlockEntity(position.above()) == null + && owner.getBlockEntityNbt(position.above()) == null; + } + + private SmoothingFoliage() { + } +} diff --git a/src/main/java/zone/moddev/mc/skysgrassslabs/world/StructureProtection.java b/src/main/java/zone/moddev/mc/skysgrassslabs/world/StructureProtection.java new file mode 100644 index 0000000..7920dbc --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysgrassslabs/world/StructureProtection.java @@ -0,0 +1,65 @@ +package zone.moddev.mc.skysgrassslabs.world; + +import it.unimi.dsi.fastutil.longs.LongSet; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Function; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.levelgen.structure.StructureStart; + +/** Read known structure bounds without asking the generator for another chunk. */ +final class StructureProtection { + private final List bounds = new ArrayList<>(); + private boolean unresolved; + + static StructureProtection read(ChunkAccess owner, Function loadedChunk) { + StructureProtection result = new StructureProtection(); + Set visited = new HashSet<>(); + visited.add(owner.getPos().toLong()); + result.addStarts(owner); + for (LongSet references : owner.getAllReferences().values()) { + for (long reference : references) { + if (!visited.add(reference)) { + continue; + } + ChunkAccess startChunk = loadedChunk.apply(new ChunkPos(reference)); + if (startChunk == null) { + // The reference may point beyond the loaded generation halo. + // Leave this chunk alone instead of loading it to find out. + result.unresolved = true; + } else { + result.addStarts(startChunk); + } + } + } + return result; + } + + boolean canInspectSafely() { + return !unresolved; + } + + boolean contains(BlockPos position) { + for (BoundingBox box : bounds) { + if (box.isInside(position)) { + return true; + } + } + return false; + } + + private void addStarts(ChunkAccess chunk) { + for (StructureStart start : chunk.getAllStarts().values()) { + if (start != null && start.isValid()) { + BoundingBox box = start.getBoundingBox(); + bounds.add(new BoundingBox(box.minX(), box.minY(), box.minZ(), + box.maxX(), box.maxY(), box.maxZ())); + } + } + } +} diff --git a/src/main/resources/assets/skysgrassslabs/lang/de_at.json b/src/main/resources/assets/skysgrassslabs/lang/de_at.json index 41f3d83..08ced0b 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/de_at.json +++ b/src/main/resources/assets/skysgrassslabs/lang/de_at.json @@ -2,5 +2,11 @@ "block.skysgrassslabs.dirt_slab": "Eanstufn", "block.skysgrassslabs.grass_slab": "Grosstufn", "block.skysgrassslabs.path_slab": "Steigstufn", - "block.skysgrassslabs.turf": "Grassodn" + "block.skysgrassslabs.turf": "Grassodn", + "config.skysgrassslabs.generateGrassSlabs": "Grasstufen erzeugen", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs": "Alte Stufen ersetzen", + "config.skysgrassslabs.generateGrassSlabs.tooltip": "Fügt Grasplatten an neuen Hängen der Oberwelt hinzu. Mit Terrain Smoother steuert dies nur Gras. Bestehende Chunks bleiben unverändert. Minecraft neu starten.", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip": "Kompatibilitätseinstellung für alte Welten. In normalen 1.21.1-Modpacks ausgeschaltet lassen. Alte Platten werden automatisch wiederhergestellt. Minecraft neu starten.", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent": "Grass Slabs-Inhalte ersetzen", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent.tooltip": "Wandelt unterstützte Inhalte von Grass Slabs um, solange die Mod installiert ist. Nach dem Entfernen bleibt die automatische Wiederherstellung aktiv. Minecraft neu starten." } diff --git a/src/main/resources/assets/skysgrassslabs/lang/de_au.json b/src/main/resources/assets/skysgrassslabs/lang/de_au.json index 6f1858f..d5427b1 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/de_au.json +++ b/src/main/resources/assets/skysgrassslabs/lang/de_au.json @@ -2,5 +2,11 @@ "block.skysgrassslabs.dirt_slab": "Erdstufe", "block.skysgrassslabs.grass_slab": "Grasblockstufe", "block.skysgrassslabs.path_slab": "Trampelpfadstufe", - "block.skysgrassslabs.turf": "Grassode" + "block.skysgrassslabs.turf": "Grassode", + "config.skysgrassslabs.generateGrassSlabs": "Grasstufen erzeugen", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs": "Alte Stufen ersetzen", + "config.skysgrassslabs.generateGrassSlabs.tooltip": "Fügt Grasplatten an neuen Hängen der Oberwelt hinzu. Mit Terrain Smoother steuert dies nur Gras. Bestehende Chunks bleiben unverändert. Minecraft neu starten.", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip": "Kompatibilitätseinstellung für alte Welten. In normalen 1.21.1-Modpacks ausgeschaltet lassen. Alte Platten werden automatisch wiederhergestellt. Minecraft neu starten.", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent": "Grass Slabs-Inhalte ersetzen", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent.tooltip": "Wandelt unterstützte Inhalte von Grass Slabs um, solange die Mod installiert ist. Nach dem Entfernen bleibt die automatische Wiederherstellung aktiv. Minecraft neu starten." } diff --git a/src/main/resources/assets/skysgrassslabs/lang/de_de.json b/src/main/resources/assets/skysgrassslabs/lang/de_de.json index 6f1858f..d5427b1 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/de_de.json +++ b/src/main/resources/assets/skysgrassslabs/lang/de_de.json @@ -2,5 +2,11 @@ "block.skysgrassslabs.dirt_slab": "Erdstufe", "block.skysgrassslabs.grass_slab": "Grasblockstufe", "block.skysgrassslabs.path_slab": "Trampelpfadstufe", - "block.skysgrassslabs.turf": "Grassode" + "block.skysgrassslabs.turf": "Grassode", + "config.skysgrassslabs.generateGrassSlabs": "Grasstufen erzeugen", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs": "Alte Stufen ersetzen", + "config.skysgrassslabs.generateGrassSlabs.tooltip": "Fügt Grasplatten an neuen Hängen der Oberwelt hinzu. Mit Terrain Smoother steuert dies nur Gras. Bestehende Chunks bleiben unverändert. Minecraft neu starten.", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip": "Kompatibilitätseinstellung für alte Welten. In normalen 1.21.1-Modpacks ausgeschaltet lassen. Alte Platten werden automatisch wiederhergestellt. Minecraft neu starten.", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent": "Grass Slabs-Inhalte ersetzen", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent.tooltip": "Wandelt unterstützte Inhalte von Grass Slabs um, solange die Mod installiert ist. Nach dem Entfernen bleibt die automatische Wiederherstellung aktiv. Minecraft neu starten." } diff --git a/src/main/resources/assets/skysgrassslabs/lang/en_ca.json b/src/main/resources/assets/skysgrassslabs/lang/en_ca.json index b747612..9dc8f48 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/en_ca.json +++ b/src/main/resources/assets/skysgrassslabs/lang/en_ca.json @@ -2,5 +2,11 @@ "block.skysgrassslabs.dirt_slab": "Dirt Slab", "block.skysgrassslabs.grass_slab": "Grass Slab", "block.skysgrassslabs.path_slab": "Grass Path Slab", - "block.skysgrassslabs.turf": "Turf" + "block.skysgrassslabs.turf": "Turf", + "config.skysgrassslabs.generateGrassSlabs": "Generate grass slabs", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs": "Replace legacy slabs", + "config.skysgrassslabs.generateGrassSlabs.tooltip": "Add grass slabs to new Overworld slopes. With Terrain Smoother, this controls grass only. Existing chunks stay unchanged. Restart Minecraft.", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip": "Legacy compatibility setting. Leave off in normal Minecraft 1.21.1 packs. Recovery of old slabs is automatic. Restart Minecraft.", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent": "Replace Grass Slabs content", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent.tooltip": "Convert supported Grass Slabs content while that mod is installed. Automatic recovery after removal stays enabled. Restart Minecraft." } diff --git a/src/main/resources/assets/skysgrassslabs/lang/en_en.json b/src/main/resources/assets/skysgrassslabs/lang/en_en.json index b747612..9dc8f48 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/en_en.json +++ b/src/main/resources/assets/skysgrassslabs/lang/en_en.json @@ -2,5 +2,11 @@ "block.skysgrassslabs.dirt_slab": "Dirt Slab", "block.skysgrassslabs.grass_slab": "Grass Slab", "block.skysgrassslabs.path_slab": "Grass Path Slab", - "block.skysgrassslabs.turf": "Turf" + "block.skysgrassslabs.turf": "Turf", + "config.skysgrassslabs.generateGrassSlabs": "Generate grass slabs", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs": "Replace legacy slabs", + "config.skysgrassslabs.generateGrassSlabs.tooltip": "Add grass slabs to new Overworld slopes. With Terrain Smoother, this controls grass only. Existing chunks stay unchanged. Restart Minecraft.", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip": "Legacy compatibility setting. Leave off in normal Minecraft 1.21.1 packs. Recovery of old slabs is automatic. Restart Minecraft.", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent": "Replace Grass Slabs content", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent.tooltip": "Convert supported Grass Slabs content while that mod is installed. Automatic recovery after removal stays enabled. Restart Minecraft." } diff --git a/src/main/resources/assets/skysgrassslabs/lang/en_gb.json b/src/main/resources/assets/skysgrassslabs/lang/en_gb.json index b747612..9dc8f48 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/en_gb.json +++ b/src/main/resources/assets/skysgrassslabs/lang/en_gb.json @@ -2,5 +2,11 @@ "block.skysgrassslabs.dirt_slab": "Dirt Slab", "block.skysgrassslabs.grass_slab": "Grass Slab", "block.skysgrassslabs.path_slab": "Grass Path Slab", - "block.skysgrassslabs.turf": "Turf" + "block.skysgrassslabs.turf": "Turf", + "config.skysgrassslabs.generateGrassSlabs": "Generate grass slabs", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs": "Replace legacy slabs", + "config.skysgrassslabs.generateGrassSlabs.tooltip": "Add grass slabs to new Overworld slopes. With Terrain Smoother, this controls grass only. Existing chunks stay unchanged. Restart Minecraft.", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip": "Legacy compatibility setting. Leave off in normal Minecraft 1.21.1 packs. Recovery of old slabs is automatic. Restart Minecraft.", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent": "Replace Grass Slabs content", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent.tooltip": "Convert supported Grass Slabs content while that mod is installed. Automatic recovery after removal stays enabled. Restart Minecraft." } diff --git a/src/main/resources/assets/skysgrassslabs/lang/en_pt.json b/src/main/resources/assets/skysgrassslabs/lang/en_pt.json index b747612..9dc8f48 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/en_pt.json +++ b/src/main/resources/assets/skysgrassslabs/lang/en_pt.json @@ -2,5 +2,11 @@ "block.skysgrassslabs.dirt_slab": "Dirt Slab", "block.skysgrassslabs.grass_slab": "Grass Slab", "block.skysgrassslabs.path_slab": "Grass Path Slab", - "block.skysgrassslabs.turf": "Turf" + "block.skysgrassslabs.turf": "Turf", + "config.skysgrassslabs.generateGrassSlabs": "Generate grass slabs", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs": "Replace legacy slabs", + "config.skysgrassslabs.generateGrassSlabs.tooltip": "Add grass slabs to new Overworld slopes. With Terrain Smoother, this controls grass only. Existing chunks stay unchanged. Restart Minecraft.", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip": "Legacy compatibility setting. Leave off in normal Minecraft 1.21.1 packs. Recovery of old slabs is automatic. Restart Minecraft.", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent": "Replace Grass Slabs content", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent.tooltip": "Convert supported Grass Slabs content while that mod is installed. Automatic recovery after removal stays enabled. Restart Minecraft." } diff --git a/src/main/resources/assets/skysgrassslabs/lang/en_us.json b/src/main/resources/assets/skysgrassslabs/lang/en_us.json index b747612..9dc8f48 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/en_us.json +++ b/src/main/resources/assets/skysgrassslabs/lang/en_us.json @@ -2,5 +2,11 @@ "block.skysgrassslabs.dirt_slab": "Dirt Slab", "block.skysgrassslabs.grass_slab": "Grass Slab", "block.skysgrassslabs.path_slab": "Grass Path Slab", - "block.skysgrassslabs.turf": "Turf" + "block.skysgrassslabs.turf": "Turf", + "config.skysgrassslabs.generateGrassSlabs": "Generate grass slabs", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs": "Replace legacy slabs", + "config.skysgrassslabs.generateGrassSlabs.tooltip": "Add grass slabs to new Overworld slopes. With Terrain Smoother, this controls grass only. Existing chunks stay unchanged. Restart Minecraft.", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip": "Legacy compatibility setting. Leave off in normal Minecraft 1.21.1 packs. Recovery of old slabs is automatic. Restart Minecraft.", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent": "Replace Grass Slabs content", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent.tooltip": "Convert supported Grass Slabs content while that mod is installed. Automatic recovery after removal stays enabled. Restart Minecraft." } diff --git a/src/main/resources/assets/skysgrassslabs/lang/es_es.json b/src/main/resources/assets/skysgrassslabs/lang/es_es.json index 5958db0..fcfb9f7 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/es_es.json +++ b/src/main/resources/assets/skysgrassslabs/lang/es_es.json @@ -2,5 +2,11 @@ "block.skysgrassslabs.dirt_slab": "Losa de tierra", "block.skysgrassslabs.grass_slab": "Losa de césped", "block.skysgrassslabs.path_slab": "Losa de camino de hierba", - "block.skysgrassslabs.turf": "Tepe de césped" + "block.skysgrassslabs.turf": "Tepe de césped", + "config.skysgrassslabs.generateGrassSlabs": "Generar losas de césped", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs": "Reemplazar losas antiguas", + "config.skysgrassslabs.generateGrassSlabs.tooltip": "Añade losas de hierba a pendientes nuevas del mundo normal. Con Terrain Smoother, solo controla la hierba. Los chunks existentes no cambian. Reinicia Minecraft.", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip": "Opción de compatibilidad con mundos antiguos. Déjala desactivada en packs normales de Minecraft 1.21.1. Las losas antiguas se recuperan automáticamente. Reinicia Minecraft.", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent": "Reemplazar contenido de Grass Slabs", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent.tooltip": "Convierte el contenido compatible de Grass Slabs mientras está instalado. La recuperación automática sigue activa al quitarlo. Reinicia Minecraft." } diff --git a/src/main/resources/assets/skysgrassslabs/lang/es_mx.json b/src/main/resources/assets/skysgrassslabs/lang/es_mx.json index a7c45b7..7bd714f 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/es_mx.json +++ b/src/main/resources/assets/skysgrassslabs/lang/es_mx.json @@ -2,5 +2,11 @@ "block.skysgrassslabs.dirt_slab": "Losa de tierra", "block.skysgrassslabs.grass_slab": "Losa de pasto", "block.skysgrassslabs.path_slab": "Losa de sendero de pasto", - "block.skysgrassslabs.turf": "Tapete de pasto" + "block.skysgrassslabs.turf": "Tapete de pasto", + "config.skysgrassslabs.generateGrassSlabs": "Generar losas de césped", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs": "Reemplazar losas antiguas", + "config.skysgrassslabs.generateGrassSlabs.tooltip": "Añade losas de hierba a pendientes nuevas del mundo normal. Con Terrain Smoother, solo controla la hierba. Los chunks existentes no cambian. Reinicia Minecraft.", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip": "Opción de compatibilidad con mundos antiguos. Déjala desactivada en packs normales de Minecraft 1.21.1. Las losas antiguas se recuperan automáticamente. Reinicia Minecraft.", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent": "Reemplazar contenido de Grass Slabs", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent.tooltip": "Convierte el contenido compatible de Grass Slabs mientras está instalado. La recuperación automática sigue activa al quitarlo. Reinicia Minecraft." } diff --git a/src/main/resources/assets/skysgrassslabs/lang/fr_ca.json b/src/main/resources/assets/skysgrassslabs/lang/fr_ca.json index 1aa1323..23b0a6f 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/fr_ca.json +++ b/src/main/resources/assets/skysgrassslabs/lang/fr_ca.json @@ -2,5 +2,11 @@ "block.skysgrassslabs.dirt_slab": "Dalle de terre", "block.skysgrassslabs.grass_slab": "Dalle de gazon", "block.skysgrassslabs.path_slab": "Dalle de sentier de gazon", - "block.skysgrassslabs.turf": "Plaque de gazon" + "block.skysgrassslabs.turf": "Plaque de gazon", + "config.skysgrassslabs.generateGrassSlabs": "Générer des dalles d'herbe", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs": "Remplacer les anciennes dalles", + "config.skysgrassslabs.generateGrassSlabs.tooltip": "Ajoute des dalles d'herbe aux nouvelles pentes du monde normal. Avec Terrain Smoother, cette option ne contrôle que l'herbe. Les chunks existants ne changent pas. Redémarrez Minecraft.", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip": "Option de compatibilité avec les anciens mondes. Laissez-la désactivée dans les packs 1.21.1 habituels. Les anciennes dalles sont récupérées automatiquement. Redémarrez Minecraft.", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent": "Remplacer le contenu de Grass Slabs", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent.tooltip": "Convertit le contenu compatible de Grass Slabs quand ce mod est installé. La récupération automatique reste active après son retrait. Redémarrez Minecraft." } diff --git a/src/main/resources/assets/skysgrassslabs/lang/fr_fr.json b/src/main/resources/assets/skysgrassslabs/lang/fr_fr.json index 7498268..2d76bbd 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/fr_fr.json +++ b/src/main/resources/assets/skysgrassslabs/lang/fr_fr.json @@ -2,5 +2,11 @@ "block.skysgrassslabs.dirt_slab": "Dalle de terre", "block.skysgrassslabs.grass_slab": "Dalle d'herbe", "block.skysgrassslabs.path_slab": "Dalle de chemin d'herbe", - "block.skysgrassslabs.turf": "Plaque de gazon" + "block.skysgrassslabs.turf": "Plaque de gazon", + "config.skysgrassslabs.generateGrassSlabs": "Générer des dalles d'herbe", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs": "Remplacer les anciennes dalles", + "config.skysgrassslabs.generateGrassSlabs.tooltip": "Ajoute des dalles d'herbe aux nouvelles pentes du monde normal. Avec Terrain Smoother, cette option ne contrôle que l'herbe. Les chunks existants ne changent pas. Redémarrez Minecraft.", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip": "Option de compatibilité avec les anciens mondes. Laissez-la désactivée dans les packs 1.21.1 habituels. Les anciennes dalles sont récupérées automatiquement. Redémarrez Minecraft.", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent": "Remplacer le contenu de Grass Slabs", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent.tooltip": "Convertit le contenu compatible de Grass Slabs quand ce mod est installé. La récupération automatique reste active après son retrait. Redémarrez Minecraft." } diff --git a/src/main/resources/assets/skysgrassslabs/lang/ja_jp.json b/src/main/resources/assets/skysgrassslabs/lang/ja_jp.json index caaa616..4af58e1 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/ja_jp.json +++ b/src/main/resources/assets/skysgrassslabs/lang/ja_jp.json @@ -2,5 +2,11 @@ "block.skysgrassslabs.dirt_slab": "土のハーフブロック", "block.skysgrassslabs.grass_slab": "草ブロックのハーフブロック", "block.skysgrassslabs.path_slab": "草の道のハーフブロック", - "block.skysgrassslabs.turf": "芝生" + "block.skysgrassslabs.turf": "芝生", + "config.skysgrassslabs.generateGrassSlabs": "草のハーフブロックを生成", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs": "旧MODのハーフブロックを置換", + "config.skysgrassslabs.generateGrassSlabs.tooltip": "新しく生成されるオーバーワールドの斜面に草のハーフブロックを置きます。Terrain Smoother導入時は草だけを設定します。既存のチャンクは変わりません。Minecraftの再起動が必要です。", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip": "古いワールド用の互換設定です。通常のMinecraft 1.21.1環境では無効のままにしてください。古いハーフブロックは自動で復元されます。再起動が必要です。", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent": "Grass Slabs のブロックを置換", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent.tooltip": "Grass Slabs が導入されている間、対応するブロックを変換します。削除後の自動復元は有効のままです。Minecraft の再起動が必要です。" } diff --git a/src/main/resources/assets/skysgrassslabs/lang/ko_kr.json b/src/main/resources/assets/skysgrassslabs/lang/ko_kr.json index 74ed7d9..501de7f 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/ko_kr.json +++ b/src/main/resources/assets/skysgrassslabs/lang/ko_kr.json @@ -2,5 +2,11 @@ "block.skysgrassslabs.dirt_slab": "흙 반 블록", "block.skysgrassslabs.grass_slab": "잔디 블록 반 블록", "block.skysgrassslabs.path_slab": "잔디 길 반 블록", - "block.skysgrassslabs.turf": "잔디" + "block.skysgrassslabs.turf": "잔디", + "config.skysgrassslabs.generateGrassSlabs": "잔디 반 블록 생성", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs": "이전 모드 반 블록 교체", + "config.skysgrassslabs.generateGrassSlabs.tooltip": "새로 생성되는 오버월드 경사면에 잔디 반 블록을 놓습니다. Terrain Smoother가 있으면 잔디에만 적용됩니다. 기존 청크는 바뀌지 않습니다. Minecraft를 다시 시작해야 합니다.", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip": "이전 월드용 호환 설정입니다. 일반적인 Minecraft 1.21.1 팩에서는 꺼 두세요. 이전 반 블록은 자동으로 복구됩니다. 다시 시작해야 합니다.", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent": "Grass Slabs 콘텐츠 교체", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent.tooltip": "Grass Slabs가 설치되어 있을 때 지원되는 콘텐츠를 변환합니다. 모드를 제거한 뒤에도 자동 복구는 유지됩니다. Minecraft를 다시 시작하세요." } diff --git a/src/main/resources/assets/skysgrassslabs/lang/pt_br.json b/src/main/resources/assets/skysgrassslabs/lang/pt_br.json index 0a95546..34da4b0 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/pt_br.json +++ b/src/main/resources/assets/skysgrassslabs/lang/pt_br.json @@ -2,5 +2,11 @@ "block.skysgrassslabs.dirt_slab": "Laje de Terra", "block.skysgrassslabs.grass_slab": "Laje de Bloco de Grama", "block.skysgrassslabs.path_slab": "Laje de Caminho de Grama", - "block.skysgrassslabs.turf": "Placa de Grama" + "block.skysgrassslabs.turf": "Placa de Grama", + "config.skysgrassslabs.generateGrassSlabs": "Gerar lajes de relva", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs": "Substituir lajes antigas", + "config.skysgrassslabs.generateGrassSlabs.tooltip": "Adiciona lajes de grama às novas encostas do mundo normal. Com Terrain Smoother, controla apenas a grama. Os chunks existentes não mudam. Reinicie o Minecraft.", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip": "Opção de compatibilidade com mundos antigos. Deixe desativada em pacotes normais do Minecraft 1.21.1. As lajes antigas são recuperadas automaticamente. Reinicie o Minecraft.", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent": "Substituir conteúdo de Grass Slabs", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent.tooltip": "Converte o conteúdo compatível de Grass Slabs enquanto o mod está instalado. A recuperação automática continua ativa após a remoção. Reinicie o Minecraft." } diff --git a/src/main/resources/assets/skysgrassslabs/lang/pt_pt.json b/src/main/resources/assets/skysgrassslabs/lang/pt_pt.json index 92b4305..4f94759 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/pt_pt.json +++ b/src/main/resources/assets/skysgrassslabs/lang/pt_pt.json @@ -2,5 +2,11 @@ "block.skysgrassslabs.dirt_slab": "Degrau de Terra", "block.skysgrassslabs.grass_slab": "Degrau de Bloco de Relva", "block.skysgrassslabs.path_slab": "Degrau de Caminho de Relva", - "block.skysgrassslabs.turf": "Placa de Relva" + "block.skysgrassslabs.turf": "Placa de Relva", + "config.skysgrassslabs.generateGrassSlabs": "Gerar lajes de relva", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs": "Substituir lajes antigas", + "config.skysgrassslabs.generateGrassSlabs.tooltip": "Acrescenta degraus de relva às novas encostas do mundo normal. Com Terrain Smoother, controla apenas a relva. Os chunks existentes não mudam. Reinicie o Minecraft.", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip": "Opção de compatibilidade com mundos antigos. Deixe desativada nos packs normais do Minecraft 1.21.1. Os degraus antigos são recuperados automaticamente. Reinicie o Minecraft.", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent": "Substituir conteúdo de Grass Slabs", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent.tooltip": "Converte o conteúdo compatível de Grass Slabs enquanto o mod está instalado. A recuperação automática continua ativa após a remoção. Reinicie o Minecraft." } diff --git a/src/main/resources/assets/skysgrassslabs/lang/ru_ru.json b/src/main/resources/assets/skysgrassslabs/lang/ru_ru.json index c3a5199..54bcf39 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/ru_ru.json +++ b/src/main/resources/assets/skysgrassslabs/lang/ru_ru.json @@ -2,5 +2,11 @@ "block.skysgrassslabs.dirt_slab": "Земляная плита", "block.skysgrassslabs.grass_slab": "Дёрновая плита", "block.skysgrassslabs.path_slab": "Плита тропы", - "block.skysgrassslabs.turf": "Дёрн" + "block.skysgrassslabs.turf": "Дёрн", + "config.skysgrassslabs.generateGrassSlabs": "Генерировать дёрновые плиты", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs": "Заменять старые плиты", + "config.skysgrassslabs.generateGrassSlabs.tooltip": "Добавляет дёрновые плиты на новые склоны обычного мира. С Terrain Smoother настройка управляет только травой. Существующие чанки не меняются. Перезапустите Minecraft.", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip": "Совместимость со старыми мирами. В обычных сборках Minecraft 1.21.1 оставьте выключенной. Старые плиты восстанавливаются автоматически. Требуется перезапуск.", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent": "Заменять содержимое Grass Slabs", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent.tooltip": "Преобразует поддерживаемое содержимое установленного Grass Slabs. После удаления мода автоматическое восстановление остаётся включённым. Перезапустите Minecraft." } diff --git a/src/main/resources/assets/skysgrassslabs/lang/zh_cn.json b/src/main/resources/assets/skysgrassslabs/lang/zh_cn.json index bf3ba40..01a4b39 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/zh_cn.json +++ b/src/main/resources/assets/skysgrassslabs/lang/zh_cn.json @@ -2,5 +2,11 @@ "block.skysgrassslabs.dirt_slab": "泥土台阶", "block.skysgrassslabs.grass_slab": "草方块台阶", "block.skysgrassslabs.path_slab": "草径台阶", - "block.skysgrassslabs.turf": "草皮" + "block.skysgrassslabs.turf": "草皮", + "config.skysgrassslabs.generateGrassSlabs": "生成草方块台阶", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs": "替换旧版台阶", + "config.skysgrassslabs.generateGrassSlabs.tooltip": "在新生成的主世界斜坡上放置草方块台阶。安装Terrain Smoother后,此选项只控制草地。已有区块不会改变。需要重启Minecraft。", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip": "旧世界兼容设置。普通Minecraft 1.21.1整合包请保持关闭。旧台阶会自动恢复。需要重启Minecraft。", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent": "替换 Grass Slabs 内容", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent.tooltip": "安装 Grass Slabs 时转换受支持的内容。移除该模组后仍会自动恢复旧内容。需要重启 Minecraft。" } diff --git a/src/main/resources/coremods/skysgrassslabs_legacy_world.js b/src/main/resources/coremods/skysgrassslabs_legacy_world.js index 790b14c..c25cbdc 100644 --- a/src/main/resources/coremods/skysgrassslabs_legacy_world.js +++ b/src/main/resources/coremods/skysgrassslabs_legacy_world.js @@ -75,28 +75,40 @@ function initializeCoreMod() { 'name': 'net.neoforged.neoforge.common.CommonHooks' }, 'transformer': function(classNode) { - var patched = false; - for (var methodIndex = 0; methodIndex < classNode.methods.size(); ++methodIndex) { - var method = classNode.methods.get(methodIndex); - if (method.desc !== '(Lnet/minecraft/nbt/CompoundTag;' - + 'Lnet/minecraft/world/level/storage/LevelStorageSource$LevelDirectory;)V') { - continue; + var readMethods = 0; + var writeMethods = 0; + var owner = 'zone/moddev/mc/skysgrassslabs/compat/LegacyWorldDataHook'; + for (var index = 0; index < classNode.methods.size(); ++index) { + var method = classNode.methods.get(index); + var reading = method.name === 'readAdditionalLevelSaveData' && + method.desc === '(Lnet/minecraft/nbt/CompoundTag;Lnet/minecraft/world/level/storage/LevelStorageSource$LevelDirectory;)V'; + var writing = method.name === 'writeAdditionalLevelSaveData' && + method.desc === '(Lnet/minecraft/world/level/storage/WorldData;' + + 'Lnet/minecraft/nbt/CompoundTag;)V'; + if (!reading && !writing) continue; + if (reading) ++readMethods; + else ++writeMethods; + var callback = reading ? 'captureLegacyLevelData' : 'writeLegacyLevelData'; + var existingCalls = 0; + for (var instruction = method.instructions.getFirst(); instruction !== null; + instruction = instruction.getNext()) { + if (instruction instanceof MethodInsnNode && instruction.owner === owner && + instruction.name === callback) ++existingCalls; } + if (existingCalls > 1) { + throw new Error("Sky's Grass Slabs found duplicate NeoForge 21.1 level hooks"); + } + if (existingCalls === 1) continue; var prefix = new InsnList(); - prefix.add(new VarInsnNode(Opcodes.ALOAD, 0)); - prefix.add(new VarInsnNode(Opcodes.ALOAD, 1)); - prefix.add(new MethodInsnNode( - Opcodes.INVOKESTATIC, - 'zone/moddev/mc/skysgrassslabs/compat/LegacyWorldDataHook', - 'captureLegacyLevelData', - '(Lnet/minecraft/nbt/CompoundTag;' - + 'Lnet/minecraft/world/level/storage/LevelStorageSource$LevelDirectory;)V', - false)); + prefix.add(new VarInsnNode(Opcodes.ALOAD, reading ? 0 : 1)); + if (reading) prefix.add(new VarInsnNode(Opcodes.ALOAD, 1)); + prefix.add(new MethodInsnNode(Opcodes.INVOKESTATIC, owner, callback, + reading ? '(Lnet/minecraft/nbt/CompoundTag;Lnet/minecraft/world/level/storage/LevelStorageSource$LevelDirectory;)V' : + '(Lnet/minecraft/nbt/CompoundTag;)V', false)); method.instructions.insert(prefix); - patched = true; } - if (!patched) { - throw new Error("Sky's Grass Slabs could not patch NeoForge 21.1 level data"); + if (readMethods !== 1 || writeMethods !== 1) { + throw new Error("Sky's Grass Slabs could not find the exact NeoForge 21.1 level methods"); } return classNode; } @@ -110,28 +122,56 @@ function initializeCoreMod() { var patched = false; for (var methodIndex = 0; methodIndex < classNode.methods.size(); ++methodIndex) { var method = classNode.methods.get(methodIndex); - if (method.desc.indexOf('Ljava/util/function/Supplier;' - + 'Lnet/minecraft/nbt/CompoundTag;') < 0 - || !method.desc.endsWith('Lnet/minecraft/nbt/CompoundTag;')) { + if (method.desc !== '(Lnet/minecraft/resources/ResourceKey;' + + 'Ljava/util/function/Supplier;Lnet/minecraft/nbt/CompoundTag;Ljava/util/Optional;)' + + 'Lnet/minecraft/nbt/CompoundTag;') { continue; } + + var owner = 'zone/moddev/mc/skysgrassslabs/compat/LegacyWorldDataHook'; + var preparations = 0; + var finalizations = 0; + var returns = 0; + for (var existing = method.instructions.getFirst(); existing !== null; + existing = existing.getNext()) { + if (existing.getOpcode() === Opcodes.ARETURN) { + ++returns; + } + if (existing instanceof MethodInsnNode && existing.owner === owner) { + if (existing.name === 'prepareLegacyChunk') ++preparations; + if (existing.name === 'finalizeLegacyChunk') ++finalizations; + } + } + if (returns === 0) { + throw new Error("Sky's Grass Slabs found no NeoForge 21.1 chunk return"); + } + if (preparations !== 0 || finalizations !== 0) { + if (preparations !== 1 || finalizations !== returns) { + throw new Error("Sky's Grass Slabs found a partial NeoForge 21.1 chunk patch"); + } + patched = true; + continue; + } + var prefix = new InsnList(); prefix.add(new VarInsnNode(Opcodes.ALOAD, 3)); prefix.add(new MethodInsnNode( Opcodes.INVOKESTATIC, - 'zone/moddev/mc/skysgrassslabs/compat/LegacyWorldDataHook', + owner, 'prepareLegacyChunk', '(Lnet/minecraft/nbt/CompoundTag;)V', false)); method.instructions.insert(prefix); + for (var instruction = method.instructions.getFirst(); instruction !== null; instruction = instruction.getNext()) { if (instruction.getOpcode() === Opcodes.ARETURN) { method.instructions.insertBefore(instruction, new MethodInsnNode( Opcodes.INVOKESTATIC, - 'zone/moddev/mc/skysgrassslabs/compat/LegacyWorldDataHook', + owner, 'finalizeLegacyChunk', - '(Lnet/minecraft/nbt/CompoundTag;)Lnet/minecraft/nbt/CompoundTag;', + '(Lnet/minecraft/nbt/CompoundTag;)' + + 'Lnet/minecraft/nbt/CompoundTag;', false)); } } diff --git a/src/main/resources/data/skysgrassslabs/advancement/recipes/dirt_from_slabs.json b/src/main/resources/data/skysgrassslabs/advancement/recipes/dirt_from_slabs.json new file mode 100644 index 0000000..14c6cd0 --- /dev/null +++ b/src/main/resources/data/skysgrassslabs/advancement/recipes/dirt_from_slabs.json @@ -0,0 +1,32 @@ +{ + "parent": "minecraft:recipes/root", + "rewards": { + "recipes": [ + "skysgrassslabs:dirt_from_slabs" + ] + }, + "criteria": { + "has_slab": { + "trigger": "minecraft:inventory_changed", + "conditions": { + "items": [ + { + "items": "skysgrassslabs:dirt_slab" + } + ] + } + }, + "has_the_recipe": { + "trigger": "minecraft:recipe_unlocked", + "conditions": { + "recipe": "skysgrassslabs:dirt_from_slabs" + } + } + }, + "requirements": [ + [ + "has_slab", + "has_the_recipe" + ] + ] +} diff --git a/src/main/resources/data/skysgrassslabs/advancement/recipes/grass_from_slabs.json b/src/main/resources/data/skysgrassslabs/advancement/recipes/grass_from_slabs.json new file mode 100644 index 0000000..0dea1af --- /dev/null +++ b/src/main/resources/data/skysgrassslabs/advancement/recipes/grass_from_slabs.json @@ -0,0 +1,32 @@ +{ + "parent": "minecraft:recipes/root", + "rewards": { + "recipes": [ + "skysgrassslabs:grass_from_slabs" + ] + }, + "criteria": { + "has_slab": { + "trigger": "minecraft:inventory_changed", + "conditions": { + "items": [ + { + "items": "skysgrassslabs:grass_slab" + } + ] + } + }, + "has_the_recipe": { + "trigger": "minecraft:recipe_unlocked", + "conditions": { + "recipe": "skysgrassslabs:grass_from_slabs" + } + } + }, + "requirements": [ + [ + "has_slab", + "has_the_recipe" + ] + ] +} diff --git a/src/main/resources/data/skysgrassslabs/recipe/dirt_from_slabs.json b/src/main/resources/data/skysgrassslabs/recipe/dirt_from_slabs.json new file mode 100644 index 0000000..8678997 --- /dev/null +++ b/src/main/resources/data/skysgrassslabs/recipe/dirt_from_slabs.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shaped", + "pattern": [ + "SS" + ], + "key": { + "S": { + "item": "skysgrassslabs:dirt_slab" + } + }, + "result": { + "id": "minecraft:dirt" + }, + "category": "building" +} diff --git a/src/main/resources/data/skysgrassslabs/recipe/grass_from_slabs.json b/src/main/resources/data/skysgrassslabs/recipe/grass_from_slabs.json new file mode 100644 index 0000000..44d7f7e --- /dev/null +++ b/src/main/resources/data/skysgrassslabs/recipe/grass_from_slabs.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shaped", + "pattern": [ + "SS" + ], + "key": { + "S": { + "item": "skysgrassslabs:grass_slab" + } + }, + "result": { + "id": "minecraft:grass_block" + }, + "category": "building" +} diff --git a/src/test/java/zone/moddev/mc/skysgrassslabs/LocalizationContractTest.java b/src/test/java/zone/moddev/mc/skysgrassslabs/LocalizationContractTest.java index 19f2e4e..1bd1427 100644 --- a/src/test/java/zone/moddev/mc/skysgrassslabs/LocalizationContractTest.java +++ b/src/test/java/zone/moddev/mc/skysgrassslabs/LocalizationContractTest.java @@ -27,11 +27,19 @@ public class LocalizationContractTest { private static final Path LANG_DIR = Path.of("src/main/resources/assets/skysgrassslabs/lang"); - private static final List KEYS = Arrays.asList( + private static final List BLOCK_KEYS = Arrays.asList( "block.skysgrassslabs.dirt_slab", "block.skysgrassslabs.grass_slab", "block.skysgrassslabs.path_slab", "block.skysgrassslabs.turf"); + private static final List CONFIG_KEYS = Arrays.asList( + "config.skysgrassslabs.generateGrassSlabs", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs", + "config.skysgrassslabs.generateGrassSlabs.tooltip", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent", + "config.skysgrassslabs.forceReplaceGrassSlabsModContent.tooltip"); + private static final List KEYS = Stream.concat(BLOCK_KEYS.stream(), CONFIG_KEYS.stream()).toList(); private static final List LOCALES = Arrays.asList( "de_at", "de_au", "de_de", "en_ca", "en_en", "en_gb", "en_pt", "en_us", "es_es", "es_mx", "fr_ca", "fr_fr", "ja_jp", "ko_kr", "pt_br", "pt_pt", @@ -50,7 +58,7 @@ public void allEighteenLocalesHaveIdenticalOrderedKeys() throws Exception { for (String locale : LOCALES) { Map translations = read(locale); assertEquals(locale, KEYS, new ArrayList<>(translations.keySet())); - assertEquals(locale, 4, translations.size()); + assertEquals(locale, 10, translations.size()); assertFalse(locale, translations.values().stream().anyMatch(String::isBlank)); } } @@ -104,7 +112,8 @@ public void translationsAndRegionalDifferencesRemainIntact() throws Exception { } private static void assertValues(String locale, String... values) throws Exception { - assertEquals(locale, Arrays.asList(values), new ArrayList<>(read(locale).values())); + Map translations = read(locale); + assertEquals(locale, Arrays.asList(values), BLOCK_KEYS.stream().map(translations::get).toList()); } private static Map read(String locale) throws Exception { diff --git a/src/test/java/zone/moddev/mc/skysgrassslabs/ProjectContractTest.java b/src/test/java/zone/moddev/mc/skysgrassslabs/ProjectContractTest.java index 22de262..e63c940 100644 --- a/src/test/java/zone/moddev/mc/skysgrassslabs/ProjectContractTest.java +++ b/src/test/java/zone/moddev/mc/skysgrassslabs/ProjectContractTest.java @@ -62,14 +62,14 @@ public void playerAndMaintainerDocumentsExist() { assertTrue(Files.isRegularFile(Path.of("docs/GAMEPLAY.md"))); assertTrue(Files.isRegularFile(Path.of("docs/WORLD-UPGRADES.md"))); assertFalse(Files.exists(Path.of("docs/REPOSITORY.md"))); - assertTrue(Files.isRegularFile(Path.of("docs/RELEASE-1.1.1.121012.md"))); + assertTrue(Files.isRegularFile(Path.of("docs/RELEASE-1.2.0.121012.md"))); assertTrue(Files.isRegularFile(Path.of("docs/BETA-0.2.0.118021.md"))); } @Test public void releaseIdentityAndLicenseAreStable() throws Exception { String properties = Files.readString(Path.of("gradle.properties"), StandardCharsets.UTF_8); - assertTrue(properties.contains("mod_version=1.1.1.121012")); + assertTrue(properties.contains("mod_version=1.2.0.121012")); assertTrue(properties.contains("mod_license=LGPL-2.1-only")); assertEquals("LGPL-2.1-only", Files.readString(Path.of("LICENSE.spdx"), StandardCharsets.UTF_8).trim()); assertTrue(Files.readString(Path.of("NOTICE"), StandardCharsets.UTF_8) @@ -126,7 +126,7 @@ public void commonConfigAndWorldStateUsePermanentKeys() throws Exception { assertTrue(config.contains("push(\"compat\")")); assertTrue(config.contains("define(FORCE_REPLACE_BUILDINGBRICKS_SLABS, false)")); assertTrue(config.contains("define(FORCE_REPLACE_GRASS_SLABS_MOD_CONTENT, false)")); - assertTrue(main.contains("VERSION = \"1.1.1.121012\"")); + assertTrue(main.contains("VERSION = \"1.2.0.121012\"")); assertTrue(state.contains("skysgrassslabs_world_state")); assertTrue(state.contains("SCHEMA_VERSION = 1")); assertTrue(state.contains("schema_version")); @@ -180,7 +180,7 @@ public void legacyUpgradeHooksAreNarrowAndAvoidUnsafe() throws Exception { assertTrue(coremod.contains("current !== vanilla && current !== target")); assertTrue(coremod.contains("throw new Error")); assertTrue(bridge.contains("skysgrassslabs_legacy_registry.dat")); - assertTrue(bridge.contains("indexLegacyChunks")); + assertTrue(bridge.contains("indexRegionChunks")); assertTrue(bridge.contains("SUPPORTED_BLOCK_IDS")); assertFalse(bridge.contains("import sun.misc")); assertFalse(bridge.contains("Unsafe.class")); @@ -273,9 +273,9 @@ public void continuousIntegrationTargetsTheStableBranchAndArtifacts() throws Exc } String ci = Files.readString(Path.of(".github/workflows/ci.yml"), StandardCharsets.UTF_8); - assertTrue(ci.contains("SkysGrassSlabs-1.1.1.121012.jar")); - assertTrue(ci.contains("SkysGrassSlabs-1.1.1.121012-sources.jar")); - assertTrue(ci.contains("SkysGrassSlabs-1.1.1.121012-javadoc.jar")); + assertTrue(ci.contains("SkysGrassSlabs-1.2.0.121012.jar")); + assertTrue(ci.contains("SkysGrassSlabs-1.2.0.121012-sources.jar")); + assertTrue(ci.contains("SkysGrassSlabs-1.2.0.121012-javadoc.jar")); assertTrue(ci.contains("if-no-files-found: error")); assertTrue(ci.contains("java-version: '21.0.7+6.0.LTS'")); assertTrue(ci.contains("--offline --no-daemon")); diff --git a/src/test/java/zone/moddev/mc/skysgrassslabs/compat/LegacyRegistrySnapshotTest.java b/src/test/java/zone/moddev/mc/skysgrassslabs/compat/LegacyRegistrySnapshotTest.java new file mode 100644 index 0000000..2b6a2be --- /dev/null +++ b/src/test/java/zone/moddev/mc/skysgrassslabs/compat/LegacyRegistrySnapshotTest.java @@ -0,0 +1,62 @@ +package zone.moddev.mc.skysgrassslabs.compat; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtIo; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class LegacyRegistrySnapshotTest { + @Rule + public final TemporaryFolder folders = new TemporaryFolder(); + + @Test + public void copiesTheCompleteSnapshotWithoutReplacingCurrentForgeData() throws IOException { + File world = folders.newFolder("world"); + File levelFile = new File(world, "level.dat"); + CompoundTag registries = new CompoundTag(); + registries.put("minecraft:blocks", new CompoundTag()); + registries.put("minecraft:items", new CompoundTag()); + CompoundTag legacy = new CompoundTag(); + legacy.put("Registries", registries); + legacy.putString("OtherModData", "Keep this too"); + CompoundTag source = new CompoundTag(); + source.put("FML", legacy); + try (var output = Files.newOutputStream(levelFile.toPath())) { + NbtIo.writeCompressed(source, output); + } + + try { + LegacyWorldDataHook.prepareLegacyWorld(levelFile); + CompoundTag current = new CompoundTag(); + current.putString("Marker", "Current Forge data"); + CompoundTag saved = new CompoundTag(); + saved.put("fml", current); + LegacyWorldDataHook.writeLegacyLevelData(saved); + assertEquals(legacy, saved.getCompound("FML")); + assertEquals(current, saved.getCompound("fml")); + + saved.remove("FML"); + try (var output = Files.newOutputStream(levelFile.toPath())) { + NbtIo.writeCompressed(saved, output); + } + LegacyWorldDataHook.prepareLegacyWorld(levelFile); + LegacyWorldDataHook.writeLegacyLevelData(saved); + assertEquals(legacy, saved.getCompound("FML")); + + File emptyWorld = folders.newFolder("empty-world"); + LegacyWorldDataHook.prepareLegacyWorld(new File(emptyWorld, "level.dat")); + CompoundTag unrelatedSave = new CompoundTag(); + LegacyWorldDataHook.writeLegacyLevelData(unrelatedSave); + assertFalse(unrelatedSave.contains("FML")); + } finally { + LegacyWorldDataHook.prepareLegacyWorld(new File(world, "missing-level.dat")); + } + } +} diff --git a/src/test/java/zone/moddev/mc/skysgrassslabs/compat/SavedOverworldChunksTest.java b/src/test/java/zone/moddev/mc/skysgrassslabs/compat/SavedOverworldChunksTest.java new file mode 100644 index 0000000..0d494e7 --- /dev/null +++ b/src/test/java/zone/moddev/mc/skysgrassslabs/compat/SavedOverworldChunksTest.java @@ -0,0 +1,45 @@ +package zone.moddev.mc.skysgrassslabs.compat; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class SavedOverworldChunksTest { + @Rule + public final TemporaryFolder folders = new TemporaryFolder(); + + @Test + public void savedChunksAreProtectedWithoutALegacyRegistry() throws IOException { + Path world = folders.newFolder("world").toPath(); + Path emptyWorld = folders.newFolder("empty-world").toPath(); + writeRegionHeader(world.resolve("region/r.-1.2.mca"), 31, 0); + writeRegionHeader(world.resolve("DIM-1/region/r.0.0.mca"), 0, 0); + + try { + LegacyWorldDataHook.snapshotSavedOverworldChunks(world.toFile()); + assertTrue(LegacyWorldDataHook.isSavedOverworldChunk(-1, 64)); + assertFalse(LegacyWorldDataHook.isSavedOverworldChunk(-2, 64)); + assertFalse(LegacyWorldDataHook.isSavedOverworldChunk(0, 0)); + + LegacyWorldDataHook.snapshotSavedOverworldChunks(emptyWorld.toFile()); + assertFalse(LegacyWorldDataHook.isSavedOverworldChunk(-1, 64)); + } finally { + LegacyWorldDataHook.snapshotSavedOverworldChunks(emptyWorld.toFile()); + } + } + + private static void writeRegionHeader(Path path, int localX, int localZ) throws IOException { + ByteBuffer header = ByteBuffer.allocate(8192); + int slot = localZ * 32 + localX; + header.putInt(slot * Integer.BYTES, (2 << 8) | 1); + Files.createDirectories(path.getParent()); + Files.write(path, header.array()); + } +} diff --git a/src/test/java/zone/moddev/mc/skysgrassslabs/config/ConfigSaveTest.java b/src/test/java/zone/moddev/mc/skysgrassslabs/config/ConfigSaveTest.java new file mode 100644 index 0000000..f9863d0 --- /dev/null +++ b/src/test/java/zone/moddev/mc/skysgrassslabs/config/ConfigSaveTest.java @@ -0,0 +1,122 @@ +package zone.moddev.mc.skysgrassslabs.config; + +import com.electronwill.nightconfig.core.CommentedConfig; +import com.electronwill.nightconfig.toml.TomlParser; +import com.electronwill.nightconfig.toml.TomlWriter; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.lang.reflect.Proxy; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import net.neoforged.bus.api.BusBuilder; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.fml.ModContainer; +import net.neoforged.fml.config.ConfigTracker; +import net.neoforged.fml.config.IConfigSpec; +import net.neoforged.fml.config.ModConfig; +import net.neoforged.fml.event.config.ModConfigEvent; +import net.neoforged.fml.loading.FMLPaths; +import net.neoforged.neoforgespi.language.IModInfo; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import static org.junit.Assert.*; + +public class ConfigSaveTest { + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + + @Test + public void savePublishesOneCompleteSetOfChoicesAndKeepsStartupSettings() throws Exception { + Path directory = temporary.newFolder().toPath(); + Path file = directory.resolve(SkysGrassSlabsConfig.FILE_NAME); + FMLPaths.loadAbsolutePaths(directory); + TestContainer container = new TestContainer(); + SkysGrassSlabsConfig.register(container.getEventBus(), container); + + CommentedConfig defaults = CommentedConfig.inMemory(); + container.config.getSpec().correct(defaults); + try (var writer = Files.newBufferedWriter(file)) { + new TomlWriter().write(defaults, writer); + } + container.tracker.loadConfigs(ModConfig.Type.COMMON, directory); + try { + CommentedConfig values = container.config.getLoadedConfig().config(); + values.set("compat.unrelated", "kept"); + values.setComment("compat.unrelated", "A pack setting"); + values.set("other", values.createSubConfig()); + values.set("other.setting", 42); + + List> published = new CopyOnWriteArrayList<>(); + container.getEventBus().addListener((ModConfigEvent.Reloading event) -> { + if (event.getConfig() == container.config) { + published.add(choices(read(file))); + } + }); + SkysGrassSlabsConfig.activateSettingsForThisRun(); + SkysGrassSlabsConfig.saveForNextStart(false, true, true); + + assertFalse("NeoForge should notify listeners after saving", published.isEmpty()); + for (List saved : published) { + assertEquals(List.of(false, true, true), saved); + } + assertFalse(SkysGrassSlabsConfig.configuredGrassGeneration()); + assertTrue(SkysGrassSlabsConfig.configuredLegacyReplacement()); + assertTrue(SkysGrassSlabsConfig.configuredGrassSlabsReplacement()); + assertTrue(SkysGrassSlabsConfig.generateGrassSlabs()); + assertFalse(SkysGrassSlabsConfig.forceReplaceBuildingBricksSlabs()); + assertFalse(SkysGrassSlabsConfig.forceReplaceGrassSlabsModContent()); + CommentedConfig saved = read(file); + assertEquals("kept", saved.get("compat.unrelated")); + assertEquals("A pack setting", saved.getComment("compat.unrelated").trim()); + assertEquals(42, (int) saved.get("other.setting")); + } finally { + container.tracker.unloadConfigs(ModConfig.Type.COMMON); + SkysGrassSlabsConfig.resetRunStateForTests(); + } + } + + private static List choices(CommentedConfig values) { + return List.of(values.get("worldgen.generateGrassSlabs"), + values.get("compat.forceReplaceBuildingBricksSlabs"), + values.get("compat.forceReplaceGrassSlabsModContent")); + } + + private static CommentedConfig read(Path file) { + try (var input = Files.newBufferedReader(file)) { + return new TomlParser().parse(input); + } catch (IOException exception) { + throw new UncheckedIOException(exception); + } + } + + /** Uses a private tracker and bus so the test never touches the game's settings. */ + private static final class TestContainer extends ModContainer { + private final ConfigTracker tracker = new ConfigTracker(); + private final IEventBus bus = BusBuilder.builder().build(); + private ModConfig config; + + private TestContainer() { + super((IModInfo) Proxy.newProxyInstance(IModInfo.class.getClassLoader(), + new Class[] {IModInfo.class}, (proxy, method, arguments) -> { + if (method.getName().equals("getModId")) { + return "skysgrassslabs"; + } + throw new UnsupportedOperationException(method.getName()); + })); + } + + @Override + public IEventBus getEventBus() { + return bus; + } + + @Override + public void registerConfig(ModConfig.Type type, IConfigSpec spec, String fileName) { + config = tracker.registerConfig(type, spec, this, fileName); + } + } +} diff --git a/src/test/java/zone/moddev/mc/skysgrassslabs/world/ModWorldStateTest.java b/src/test/java/zone/moddev/mc/skysgrassslabs/world/ModWorldStateTest.java new file mode 100644 index 0000000..14fdcf9 --- /dev/null +++ b/src/test/java/zone/moddev/mc/skysgrassslabs/world/ModWorldStateTest.java @@ -0,0 +1,54 @@ +package zone.moddev.mc.skysgrassslabs.world; + +import static org.junit.Assert.assertEquals; + +import java.nio.file.Path; +import net.minecraft.SharedConstants; +import net.minecraft.core.RegistryAccess; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtIo; +import net.minecraft.util.datafix.DataFixers; +import net.minecraft.world.level.storage.DimensionDataStorage; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class ModWorldStateTest { + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + + @Test + public void readsUnversionedModDataAndKeepsItsCountersOnReload() throws Exception { + SharedConstants.tryDetectVersion(); + + ModWorldState original = new ModWorldState(); + original.recordChunk(); + original.recordGrassBlocks(11, 0); + original.recordGrassBlocks(13, 1); + original.recordDirtBlocks(17, 0); + original.recordGrassItems(19); + original.recordDirtItems(23); + original.recordUnsupported("buildingbricks:vertical_dirt_slab", 2); + + Path dataDirectory = temporary.newFolder("world-data").toPath(); + CompoundTag savedFile = new CompoundTag(); + CompoundTag expected = original.save(new CompoundTag(), RegistryAccess.EMPTY); + // Old Grass Slabs versions stored their own data without a vanilla DataVersion. + savedFile.put("data", expected); + NbtIo.writeCompressed(savedFile, dataDirectory.resolve(ModWorldState.DATA_NAME + ".dat")); + + DimensionDataStorage first = open(dataDirectory); + ModWorldState migrated = first.computeIfAbsent(ModWorldState.factory(), ModWorldState.DATA_NAME); + assertEquals(expected, migrated.save(new CompoundTag(), RegistryAccess.EMPTY)); + + migrated.setDirty(); + first.save(); + ModWorldState reloaded = open(dataDirectory) + .computeIfAbsent(ModWorldState.factory(), ModWorldState.DATA_NAME); + assertEquals(expected, reloaded.save(new CompoundTag(), RegistryAccess.EMPTY)); + } + + private static DimensionDataStorage open(Path dataDirectory) { + return new DimensionDataStorage(dataDirectory.toFile(), DataFixers.getDataFixer(), RegistryAccess.EMPTY); + } +}