)QA4zNXeG)KXFzSDa6
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 '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..db3a6ac
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/modules/hfas/build.gradle.kts b/modules/hfas/build.gradle.kts
new file mode 100644
index 0000000..8c3204c
--- /dev/null
+++ b/modules/hfas/build.gradle.kts
@@ -0,0 +1,76 @@
+// Structure generation is an internal feature of HFAS and shares its mod entry point.
+sourceSets.main {
+ java.srcDir(rootProject.file("src/structures/java"))
+ resources.srcDir(rootProject.file("src/structures/resources"))
+}
+
+// Generated files take precedence over checked-in fallbacks for builds without datagen.
+tasks.processResources {
+ filesMatching(listOf("assets/**/*.json", "data/**/*.json")) {
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+ }
+ // Keep filtering unfinished boats, non-azalea signs and potted saplings.
+ // Keep the source assets, but do not load recipes/loot/advancements for absent content.
+ listOf("azalea", "blaze", "soul").forEach { wood ->
+ val unfinished = if (wood == "azalea") listOf("boat", "chest_boat")
+ else listOf("boat", "chest_boat", "sign", "hanging_sign", "wall_sign", "wall_hanging_sign")
+ unfinished.forEach { kind ->
+ exclude("data/ww_hfas/recipe/${wood}_${kind}.json")
+ exclude("data/ww_hfas/loot_table/blocks/${wood}_${kind}.json")
+ exclude("data/ww_hfas/advancement/recipes/**/${wood}_${kind}.json")
+ exclude("assets/ww_hfas/blockstates/${wood}_${kind}.json")
+ }
+ exclude("data/ww_hfas/loot_table/blocks/potted_${wood}_sapling.json")
+ exclude("assets/ww_hfas/blockstates/potted_${wood}_sapling.json")
+ }
+}
+
+sourceSets.test {
+ java.srcDir(rootProject.file("src/hfas/test"))
+ java.srcDir(rootProject.file("src/structures/test"))
+}
+repositories { mavenCentral() }
+dependencies {
+ testImplementation(platform("org.junit:junit-bom:5.13.4"))
+ testImplementation("org.junit.jupiter:junit-jupiter")
+ testRuntimeOnly("org.junit.platform:junit-platform-launcher")
+}
+tasks.test {
+ useJUnitPlatform()
+ val runtimeDirectory = layout.buildDirectory.dir("test-runtime")
+ workingDir(runtimeDirectory)
+ doFirst { runtimeDirectory.get().asFile.mkdirs() }
+}
+val gameTest by sourceSets.creating {
+ java.srcDir(rootProject.file("src/hfas/gametest/java"))
+ java.srcDir(rootProject.file("src/structures/gametest/java"))
+ resources.srcDir(rootProject.file("src/hfas/gametest/resources"))
+ resources.srcDir(rootProject.file("src/structures/gametest/resources"))
+ compileClasspath += sourceSets.main.get().output
+ runtimeClasspath += sourceSets.main.get().output
+}
+configurations[gameTest.implementationConfigurationName].extendsFrom(configurations.implementation.get())
+configurations[gameTest.runtimeOnlyConfigurationName].extendsFrom(configurations.runtimeOnly.get())
+val prepareGameTest by tasks.registering(Sync::class) {
+ from(rootProject.file("src/hfas/gametest/config"))
+ from(rootProject.file("src/structures/gametest/config"))
+ from(rootProject.file("src/structures/resources/examples/example_ruins.nbt")) {
+ into("ww_structures/templates")
+ }
+ into(layout.buildDirectory.dir("runs/hfas/gameTest/config"))
+}
+neoForge {
+ addModdingDependenciesTo(sourceSets.test.get())
+ addModdingDependenciesTo(gameTest)
+ val mainMod = mods.getByName("ww_hfas")
+ val testMod = mods.create("ww_hfas_tests") { sourceSet(gameTest) }
+ runs.configureEach { loadedMods.set(setOf(mainMod)) }
+ runs.register("gameTest") {
+ type = "gameTestServer"
+ sourceSet = gameTest
+ loadedMods.set(setOf(mainMod, testMod))
+ gameDirectory = layout.buildDirectory.dir("runs/hfas/gameTest")
+ programArguments.addAll("--tests", "ww_hfas_tests:*", "--report", "results.xml")
+ taskBefore(prepareGameTest)
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..247830d
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,21 @@
+pluginManagement {
+ repositories {
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id("org.gradle.toolchains.foojay-resolver-convention") version("1.0.0")
+}
+
+val let = listOf("hfas")
+let.forEach {
+ include(it)
+
+ var f = file("modules/$it")
+ f.mkdirs()
+ project(":$it").projectDir = f
+}
+
+
+rootProject.name = "ww"
diff --git a/src/hfas/gametest/config/ww_hfas-common.toml b/src/hfas/gametest/config/ww_hfas-common.toml
new file mode 100644
index 0000000..5d8abe1
--- /dev/null
+++ b/src/hfas/gametest/config/ww_hfas-common.toml
@@ -0,0 +1,2 @@
+[worldgen]
+replaceVanillaFletchingTable = false
diff --git a/src/hfas/gametest/java/org/polaris2023/wildwind/hfas/test/AzaleaAndArrowGameTests.java b/src/hfas/gametest/java/org/polaris2023/wildwind/hfas/test/AzaleaAndArrowGameTests.java
new file mode 100644
index 0000000..9009c98
--- /dev/null
+++ b/src/hfas/gametest/java/org/polaris2023/wildwind/hfas/test/AzaleaAndArrowGameTests.java
@@ -0,0 +1,243 @@
+package org.polaris2023.wildwind.hfas.test;
+
+import java.util.ArrayList;
+import java.util.List;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.Direction;
+import net.minecraft.core.component.DataComponents;
+import net.minecraft.core.registries.Registries;
+import net.minecraft.gametest.framework.GameTestHelper;
+import net.minecraft.network.chat.Component;
+import net.minecraft.resources.Identifier;
+import net.minecraft.resources.ResourceKey;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.tags.ItemTags;
+import net.minecraft.tags.TagKey;
+import net.minecraft.world.InteractionHand;
+import net.minecraft.world.entity.EntitySpawnReason;
+import net.minecraft.world.entity.EntityTypes;
+import net.minecraft.world.entity.LivingEntity;
+import net.minecraft.world.entity.projectile.arrow.AbstractArrow;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.Items;
+import net.minecraft.world.item.DyeColor;
+import net.minecraft.world.item.alchemy.PotionContents;
+import net.minecraft.world.item.alchemy.Potions;
+import net.minecraft.world.item.crafting.CraftingInput;
+import net.minecraft.world.item.crafting.RecipeType;
+import net.minecraft.world.level.GameType;
+import net.minecraft.world.level.Level;
+import net.minecraft.world.level.block.Block;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.level.block.ShelfBlock;
+import net.minecraft.world.level.block.DispenserBlock;
+import net.minecraft.world.level.block.entity.BlockEntity;
+import net.minecraft.world.level.block.entity.ShelfBlockEntity;
+import net.minecraft.world.level.block.entity.SignBlockEntity;
+import net.minecraft.world.level.block.entity.SignText;
+import net.minecraft.world.level.block.entity.HopperBlockEntity;
+import net.minecraft.world.level.block.state.properties.BlockStateProperties;
+import net.minecraft.world.level.storage.loot.LootParams;
+import net.minecraft.world.level.storage.loot.LootContext;
+import net.minecraft.world.level.storage.loot.parameters.LootContextParamSets;
+import net.minecraft.world.level.storage.loot.parameters.LootContextParams;
+import net.minecraft.world.phys.BlockHitResult;
+import net.minecraft.world.phys.Vec3;
+import org.polaris2023.wildwind.hfas.HFASMod;
+import org.polaris2023.wildwind.hfas.block.ModBlocks;
+import org.polaris2023.wildwind.hfas.entity.ReachArrow;
+import org.polaris2023.wildwind.hfas.registry.ModEntities;
+import org.polaris2023.wildwind.hfas.registry.ModItems;
+import org.polaris2023.wildwind.hfas.registry.ModMobEffects;
+import org.polaris2023.wildwind.hfas.registry.ModPotions;
+
+final class AzaleaAndArrowGameTests {
+ private static void check(GameTestHelper h, boolean value, String message) {
+ h.assertTrue(value, Component.literal(message));
+ }
+
+ static void signs(GameTestHelper h) {
+ var level = h.getLevel();
+ var blocks = List.of(ModBlocks.AZALEA_SIGN.get(), ModBlocks.AZALEA_WALL_SIGN.get(),
+ ModBlocks.AZALEA_HANGING_SIGN.get(), ModBlocks.AZALEA_WALL_HANGING_SIGN.get());
+ int x = 1;
+ for (var block : blocks) {
+ var rel = new BlockPos(x++, 2, 2);
+ var pos = h.absolutePos(rel);
+ var state = block.defaultBlockState();
+ level.setBlock(pos, state, 2);
+ check(h, level.getBlockEntity(pos) instanceof SignBlockEntity, "All sign variants must create native sign entities");
+ var sign = (SignBlockEntity) level.getBlockEntity(pos);
+ check(h, sign.getType().onlyOpCanSetNbt(), "Sign custom data must retain native administrator-only protection");
+ check(h, sign.getType().isValid(state), "Native sign type must accept azalea blocks");
+ sign.setText(new SignText().setMessage(0, Component.literal("Front")).setColor(DyeColor.RED).setHasGlowingText(true), true);
+ sign.setText(new SignText().setMessage(3, Component.literal("Back")), false);
+ sign.setWaxed(true);
+ var saved = sign.saveWithFullMetadata(level.registryAccess());
+ var loaded = (SignBlockEntity) BlockEntity.loadStatic(pos, state, saved, level.registryAccess());
+ check(h, loaded != null && loaded.isWaxed() && loaded.getFrontText().hasGlowingText()
+ && loaded.getFrontText().getColor() == DyeColor.RED
+ && loaded.getBackText().getMessage(3, false).getString().equals("Back"), "Both faces, dye, glow and wax must survive saving");
+ check(h, state.getPistonPushReaction() == net.minecraft.world.level.material.PushReaction.BLOCK, "Signs must block pistons");
+ check(h, state.getDestroySpeed(level, pos) == 1 && block.getExplosionResistance() == 1, "Sign hardness/resistance must be one");
+ check(h, state.getFlammability(level, pos, Direction.UP) == 0, "Signs must not burn in fire");
+ check(h, !Block.getDrops(state, level, pos, sign).isEmpty(), "Every sign variant must drop its item");
+ check(h, state.hasProperty(BlockStateProperties.WATERLOGGED), "Signs must support waterlogging");
+ }
+ check(h, ModBlocks.AZALEA_SIGN.get().asItem().getDefaultInstance().getMaxStackSize() == 16
+ && ModBlocks.AZALEA_HANGING_SIGN.get().asItem().getDefaultInstance().getMaxStackSize() == 16, "Signs stack to 16");
+ check(h, new ItemStack(ModBlocks.AZALEA_SIGN.get()).getBurnTime(null, level.fuelValues()) == 200
+ && new ItemStack(ModBlocks.AZALEA_HANGING_SIGN.get()).getBurnTime(null, level.fuelValues()) == 800, "Sign fuel values must match specification");
+ h.succeed();
+ }
+
+ static void shelf(GameTestHelper h) {
+ var level = h.getLevel();
+ var rel = new BlockPos(3, 2, 3);
+ var pos = h.absolutePos(rel);
+ var state = ModBlocks.AZALEA_SHELF.get().defaultBlockState().setValue(ShelfBlock.FACING, Direction.NORTH);
+ level.setBlock(pos, state, 3);
+ var shelf = (ShelfBlockEntity) level.getBlockEntity(pos);
+ check(h, shelf != null && shelf.getType().isValid(state), "Shelf must use a valid native container");
+ check(h, !shelf.getAlignItemsToBottom(), "Shelf display alignment must default to centered");
+ for (int mask = 0; mask < 8; mask++) {
+ for (int slot = 0; slot < 3; slot++) shelf.setItem(slot, (mask & 1 << slot) == 0 ? ItemStack.EMPTY : new ItemStack(Items.DIAMOND, 64));
+ check(h, state.getAnalogOutputSignal(level, pos, Direction.SOUTH) == mask, "Shelf comparator must encode all eight occupancy patterns");
+ check(h, state.getAnalogOutputSignal(level, pos, Direction.NORTH) == 0, "Comparator only reads from the rear");
+ }
+ shelf.clearContent();
+ var player = h.makeMockPlayer(GameType.SURVIVAL);
+ player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.EMERALD, 32));
+ h.useBlock(rel, player, new BlockHitResult(new Vec3(pos.getX() + 0.8, pos.getY() + 0.5, pos.getZ()), Direction.NORTH, pos, false));
+ check(h, shelf.getItem(0).is(Items.EMERALD) && shelf.getItem(0).getCount() == 32 && player.getMainHandItem().isEmpty(), "Clicking left slot must swap the entire main-hand stack");
+ player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.DIAMOND, 7));
+ h.useBlock(rel, player, new BlockHitResult(new Vec3(pos.getX() + 0.8, pos.getY() + 0.5, pos.getZ()), Direction.NORTH, pos, false));
+ check(h, shelf.getItem(0).is(Items.DIAMOND) && player.getMainHandItem().is(Items.EMERALD), "Occupied slot must return its previous stack");
+ var saved = shelf.saveWithFullMetadata(level.registryAccess());
+ saved.putBoolean("align_items_to_bottom", true);
+ var loaded = (ShelfBlockEntity) BlockEntity.loadStatic(pos, state, saved, level.registryAccess());
+ check(h, loaded != null && loaded.getAlignItemsToBottom() && loaded.getItem(0).getCount() == 7, "Alignment and contents must persist");
+ h.setBlock(3, 1, 3, Blocks.HOPPER);
+ var hopper = (HopperBlockEntity) level.getBlockEntity(pos.below());
+ check(h, HopperBlockEntity.suckInItems(level, hopper) && hopper.getItem(0).is(Items.DIAMOND), "Hopper must extract leftmost occupied slot");
+ check(h, level.getCapability(net.neoforged.neoforge.capabilities.Capabilities.Item.BLOCK, pos, Direction.UP) != null, "Shelf must expose native item capability");
+ check(h, state.getFlammability(level, pos, Direction.UP) == 20 && state.getFireSpreadSpeed(level, pos, Direction.UP) == 30, "Shelf fire values must be 30/20");
+ check(h, new ItemStack(ModBlocks.AZALEA_SHELF.get()).getBurnTime(null, level.fuelValues()) == 300, "Shelf fuel value must be 300");
+ h.succeed();
+ }
+
+ static void shelfChain(GameTestHelper h) {
+ var level = h.getLevel();
+ for (int count = 1; count <= 3; count++) {
+ for (int x = 1; x <= 4; x++) {
+ h.setBlock(x, 2, 3, Blocks.AIR);
+ h.setBlock(x, 1, 3, Blocks.AIR);
+ }
+ for (int x = 1; x <= count; x++) {
+ h.setBlock(x, 1, 3, Blocks.REDSTONE_BLOCK);
+ h.setBlock(x, 2, 3, ModBlocks.AZALEA_SHELF.get().defaultBlockState()
+ .setValue(ShelfBlock.FACING, Direction.NORTH).setValue(ShelfBlock.POWERED, true));
+ }
+ var rel = new BlockPos(1, 2, 3);
+ var pos = h.absolutePos(rel);
+ check(h, ModBlocks.AZALEA_SHELF.get().getAllBlocksConnectedTo(level, pos).size() == count, "Powered shelf chain must connect up to three shelves");
+ var player = h.makeMockPlayer(GameType.SURVIVAL);
+ for (int slot = 0; slot < 9; slot++) player.getInventory().setItem(slot, new ItemStack(Items.DIAMOND, slot + 1));
+ h.useBlock(rel, player, new BlockHitResult(new Vec3(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ()), Direction.NORTH, pos, false));
+ for (int slot = 0; slot < 9; slot++) check(h, player.getInventory().getItem(slot).isEmpty() == (slot >= 9 - 3 * count), "Powered shelves must swap rightmost 3/6/9 hotbar slots");
+ h.useBlock(rel, player, new BlockHitResult(new Vec3(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ()), Direction.NORTH, pos, false));
+ for (int slot = 0; slot < 9; slot++) check(h, player.getInventory().getItem(slot).getCount() == slot + 1, "Second swap must restore exact hotbar stacks");
+ }
+ h.succeed();
+ }
+
+ static void arrows(GameTestHelper h) {
+ var level = h.getLevel();
+ var owner = h.makeMockPlayer(GameType.SURVIVAL);
+ var target = EntityTypes.COW.create(level, EntitySpawnReason.COMMAND);
+ for (var potion : List.of(ModPotions.REACH, ModPotions.LONG_REACH)) {
+ var slots = new ArrayList();
+ for (int i = 0; i < 9; i++) slots.add(new ItemStack(Items.ARROW));
+ slots.set(4, PotionContents.createItemStack(Items.LINGERING_POTION, potion));
+ var input = CraftingInput.of(3, 3, slots);
+ var recipe = level.recipeAccess().getRecipeFor(RecipeType.CRAFTING, input, level).orElseThrow();
+ var result = recipe.value().assemble(input);
+ check(h, result.is(ModItems.TIPPED_ARROW.get()) && result.getCount() == 8, "Vanilla surround recipe must produce eight mod Reach arrows");
+ check(h, result.is(ItemTags.ARROWS), "Reach arrows must be valid bow and crossbow ammunition");
+ var arrow = ModItems.TIPPED_ARROW.get().createArrow(level, result, owner, new ItemStack(Items.BOW));
+ check(h, arrow.getType() == ModEntities.REACH_ARROW.get() && arrow.getOwner() == owner, "Bow must spawn the custom arrow type with owner");
+ check(h, !ModItems.TIPPED_ARROW.get().isInfinite(result, new ItemStack(Items.BOW), owner), "Infinity must not save potion arrows");
+ var hitArrow = new TestArrow(level, h.absoluteVec(new Vec3(2.5, 2.5, 2.5)), result);
+ target.removeAllEffects();
+ hitArrow.hit(target);
+ int expected = potion == ModPotions.REACH ? 450 : 1200;
+ check(h, target.getEffect(ModMobEffects.REACH).getDuration() == expected, "Arrow must apply exactly 450/1200 ticks of Reach");
+ h.setBlock(2, 2, 2, Blocks.STONE);
+ for (int tick = 0; tick < 599; tick++) hitArrow.tick();
+ check(h, hitArrow.getPickupItemStackOrigin().is(ModItems.TIPPED_ARROW.get()), "Potion must remain before 600 ground ticks");
+ hitArrow.tick();
+ check(h, hitArrow.getPickupItemStackOrigin().is(Items.ARROW) && hitArrow.getColor() == -1, "At 600 ground ticks pickup must become an ordinary arrow");
+ }
+ check(h, DispenserBlock.DISPENSER_REGISTRY.containsKey(ModItems.TIPPED_ARROW.get()), "Dispenser projectile behavior must be registered");
+ var dispensed = ModItems.TIPPED_ARROW.get().asProjectile(level, Vec3.ZERO, new ItemStack(ModItems.TIPPED_ARROW.get()), Direction.NORTH);
+ check(h, dispensed.getType() == ModEntities.REACH_ARROW.get() && ((AbstractArrow) dispensed).pickup == AbstractArrow.Pickup.ALLOWED, "Dispensed arrows must be recoverable custom arrows");
+ var poison = PotionContents.createItemStack(Items.TIPPED_ARROW, Potions.POISON);
+ check(h, org.polaris2023.wildwind.hfas.arrow.ReachArrows.convert(poison) == poison, "Other potion arrows must remain vanilla");
+ h.succeed();
+ }
+
+ static void acquisition(GameTestHelper h) {
+ var level = h.getLevel();
+ var villager = EntityTypes.VILLAGER.create(level, EntitySpawnReason.COMMAND);
+ var tag = TagKey.create(Registries.POTION, Identifier.withDefaultNamespace("tradeable"));
+ var potions = level.registryAccess().lookupOrThrow(Registries.POTION).getOrThrow(tag);
+ check(h, potions.contains(ModPotions.REACH) && potions.contains(ModPotions.LONG_REACH), "Both Reach variants must join the survival potion trade pool");
+ var params = new LootParams.Builder(level).withParameter(LootContextParams.ORIGIN, Vec3.ZERO)
+ .withParameter(LootContextParams.THIS_ENTITY, villager)
+ .withParameter(LootContextParams.ADDITIONAL_COST_COMPONENT_ALLOWED, net.minecraft.util.Unit.INSTANCE)
+ .create(LootContextParamSets.VILLAGER_TRADE);
+ var context = new LootContext.Builder(params).withOptionalRandomSeed(271828L).create(java.util.Optional.empty());
+ var trades = level.registryAccess().lookupOrThrow(Registries.VILLAGER_TRADE);
+ var trade = trades.getOrThrow(ResourceKey.create(Registries.VILLAGER_TRADE,
+ Identifier.withDefaultNamespace("fletcher/5/arrow_and_emerald_tipped_arrow"))).value();
+ var set = level.registryAccess().lookupOrThrow(Registries.TRADE_SET).getOrThrow(ResourceKey.create(Registries.TRADE_SET,
+ Identifier.withDefaultNamespace("fletcher/level_5"))).value();
+ check(h, set.calculateNumberOfTrades(context) == 2 && set.getTrades().size() == 3 && !set.allowDuplicates(),
+ "Master fletcher must keep vanilla two-of-three trade selection");
+ boolean normal = false, longReach = false, other = false;
+ for (int i = 0; i < 2048; i++) {
+ var offer = trade.getOffer(context);
+ check(h, offer != null && offer.getBaseCostA().is(Items.EMERALD) && offer.getBaseCostA().getCount() == 2
+ && offer.getCostB().is(Items.ARROW) && offer.getCostB().getCount() == 5 && offer.getResult().getCount() == 5,
+ "Fletcher potion-arrow cost must remain two emeralds plus five arrows for five arrows");
+ var result = offer.getResult();
+ if (result.is(ModItems.TIPPED_ARROW.get())) {
+ var potion = result.get(DataComponents.POTION_CONTENTS).potion().orElseThrow();
+ normal |= potion.is(ModPotions.REACH.getKey());
+ longReach |= potion.is(ModPotions.LONG_REACH.getKey());
+ } else other |= result.is(Items.TIPPED_ARROW);
+ }
+ check(h, normal && longReach && other, "Random trade must offer both custom Reach arrows and vanilla alternatives");
+ var gift = level.getServer().reloadableRegistries().getLootTable(ResourceKey.create(Registries.LOOT_TABLE,
+ Identifier.withDefaultNamespace("gameplay/hero_of_the_village/fletcher_gift")));
+ var giftParams = new LootParams.Builder(level).withParameter(LootContextParams.ORIGIN, Vec3.ZERO)
+ .withParameter(LootContextParams.THIS_ENTITY, villager).create(LootContextParamSets.GIFT);
+ var random = net.minecraft.util.RandomSource.create(314159L);
+ boolean reachGift = false, vanillaGift = false;
+ for (int i = 0; i < 1024; i++) {
+ var results = gift.getRandomItems(giftParams, random);
+ check(h, results.size() <= 1, "Reach gift must join the existing random choice, not add an extra gift");
+ for (var result : results) {
+ reachGift |= result.is(ModItems.TIPPED_ARROW.get());
+ vanillaGift |= result.is(Items.ARROW) || result.is(Items.TIPPED_ARROW);
+ }
+ }
+ check(h, reachGift && vanillaGift, "Hero gifts must include Reach and preserve vanilla choices");
+ h.succeed();
+ }
+
+ private static class TestArrow extends ReachArrow {
+ TestArrow(Level level, Vec3 pos, ItemStack stack) { super(level, pos.x, pos.y, pos.z, stack, null); }
+ void hit(LivingEntity target) { doPostHurtEffects(target); }
+ }
+}
diff --git a/src/hfas/gametest/java/org/polaris2023/wildwind/hfas/test/FluidEntityRulesGameTests.java b/src/hfas/gametest/java/org/polaris2023/wildwind/hfas/test/FluidEntityRulesGameTests.java
new file mode 100644
index 0000000..aadd37d
--- /dev/null
+++ b/src/hfas/gametest/java/org/polaris2023/wildwind/hfas/test/FluidEntityRulesGameTests.java
@@ -0,0 +1,218 @@
+package org.polaris2023.wildwind.hfas.test;
+
+import java.util.List;
+import java.util.Set;
+import java.util.HashSet;
+import net.minecraft.tags.TagKey;
+import net.minecraft.core.registries.BuiltInRegistries;
+import net.minecraft.core.registries.Registries;
+import net.minecraft.gametest.framework.GameTestHelper;
+import net.minecraft.network.chat.Component;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.tags.EntityTypeTags;
+import net.minecraft.tags.FluidTags;
+import net.minecraft.world.effect.MobEffectInstance;
+import net.minecraft.world.effect.MobEffects;
+import net.minecraft.world.entity.EntitySpawnReason;
+import net.minecraft.world.entity.EntityType;
+import net.minecraft.world.entity.EntityTypes;
+import net.minecraft.world.entity.LivingEntity;
+import net.minecraft.world.entity.Mob;
+import net.minecraft.world.entity.MoverType;
+import net.minecraft.world.level.GameType;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.phys.Vec3;
+import net.neoforged.neoforge.common.CommonHooks;
+import org.polaris2023.wildwind.hfas.effect.FluidEffects;
+import org.polaris2023.wildwind.hfas.effect.ModEffects;
+import org.polaris2023.wildwind.hfas.entity.ModEntityTypeTags;
+
+final class FluidEntityRulesGameTests {
+ private FluidEntityRulesGameTests() {}
+
+ private static void check(GameTestHelper h, boolean condition, String message) {
+ h.assertTrue(condition, Component.literal(message));
+ }
+
+ private static LivingEntity submerged(GameTestHelper h, EntityType> type) {
+ var entity = type == EntityTypes.PLAYER ? h.makeMockServerPlayer(GameType.SURVIVAL)
+ : (LivingEntity) type.create(h.getLevel(), EntitySpawnReason.COMMAND);
+ check(h, entity != null, "Must create test entity " + type);
+ if (entity instanceof ServerPlayer player) {
+ var connection = new net.minecraft.network.Connection(net.minecraft.network.protocol.PacketFlow.SERVERBOUND);
+ new io.netty.channel.embedded.EmbeddedChannel(connection);
+ h.getLevel().getServer().getPlayerList().placeNewPlayer(connection, player,
+ net.minecraft.server.network.CommonListenerCookie.createInitial(player.getGameProfile(), false));
+ }
+ if (entity instanceof Mob mob) mob.setNoAi(true);
+ entity.setNoGravity(true);
+ entity.setPos(h.absoluteVec(new Vec3(3.5, 1.5, 3.5)));
+ // Initialize actual fluid contact and eye immersion without adding mobs to the world.
+ entity.baseTick();
+ check(h, entity.isEyeInFluid(FluidTags.WATER), "Test entity must be submerged: " + type);
+ return entity;
+ }
+
+ static void run(GameTestHelper h) {
+ for (int x = 1; x <= 6; x++) for (int y = 1; y <= 4; y++) for (int z = 1; z <= 6; z++) {
+ h.setBlock(x, y, z, Blocks.WATER);
+ }
+ var registry = h.getLevel().registryAccess().lookupOrThrow(Registries.ENTITY_TYPE);
+ var protectedTypes = registry.getOrThrow(ModEntityTypeTags.WATER_BREATHING);
+ check(h, protectedTypes.size() >= 25, "All 25 distinct requested vanilla water-breathing types must load");
+ for (var holder : protectedTypes) {
+ var entity = submerged(h, holder.value());
+ check(h, entity.is(EntityTypeTags.CAN_BREATHE_UNDER_WATER), "Vanilla breathing tag must include " + holder);
+ entity.addEffect(new MobEffectInstance(ModEffects.CHOKE, 200, 3));
+ entity.setAirSupply(-20);
+ float health = entity.getHealth();
+ CommonHooks.onLivingBreathe(entity, h.getLevel(), 1, 4);
+ check(h, entity.getAirSupply() == entity.getMaxAirSupply() && entity.getHealth() == health,
+ "Protected type must recover empty air and resist Choke without drowning: " + holder);
+ entity.addEffect(new MobEffectInstance(ModEffects.SINK, 200));
+ check(h, !FluidEffects.cannotRise(entity), "Unlisted water-breather must not be affected by Sink: " + holder);
+ }
+
+ var affectedTypes = registry.getOrThrow(ModEntityTypeTags.SINK_AFFECTED);
+ assertWhitelist(h, ModEntityTypeTags.SINK_AFFECTED, """
+ player fox camel camel_husk chicken mooshroom cow donkey horse mule parrot pig rabbit sheep
+ sniffer tadpole turtle wandering_trader goat villager axolotl cat frog ocelot snow_golem bee
+ panda iron_golem llama polar_bear trader_llama wolf evoker hoglin piglin_brute pillager
+ ravager vindicator warden witch piglin
+ """);
+ assertWhitelist(h, ModEntityTypeTags.CHOKE_AFFECTED, """
+ player fox camel camel_husk chicken mooshroom cow donkey horse mule parrot pig rabbit sheep
+ sniffer turtle wandering_trader goat villager cat frog ocelot snow_golem bee panda iron_golem
+ llama polar_bear trader_llama wolf evoker hoglin piglin_brute pillager ravager vindicator
+ warden witch creeper cave_spider spider enderman piglin
+ """);
+ for (var holder : affectedTypes) {
+ var entity = submerged(h, holder.value());
+ entity.addEffect(new MobEffectInstance(ModEffects.SINK, 200));
+ double y = entity.getY();
+ entity.move(MoverType.SELF, new Vec3(0, 0.1, 0));
+ check(h, entity.getY() == y, "Sink must prevent actual upward motion for " + holder);
+ entity.removeEffect(ModEffects.SINK);
+ entity.move(MoverType.SELF, new Vec3(0, 0.1, 0));
+ check(h, entity.getY() > y, "Removing Sink must restore upward motion for " + holder);
+ }
+
+ for (var type : List.of(EntityTypes.BAT, EntityTypes.CREEPER, EntityTypes.SPIDER,
+ EntityTypes.PHANTOM, EntityTypes.COD)) {
+ var entity = submerged(h, type);
+ entity.addEffect(new MobEffectInstance(ModEffects.SINK, 200));
+ double y = entity.getY();
+ entity.move(MoverType.SELF, new Vec3(0, 0.1, 0));
+ check(h, entity.getY() > y, "Unlisted type/subclass must retain upward movement: " + type);
+ }
+
+ for (var holder : registry.getOrThrow(ModEntityTypeTags.CHOKE_AFFECTED)) {
+ var type = holder.value();
+ var entity = submerged(h, type);
+ entity.addEffect(new MobEffectInstance(ModEffects.CHOKE, 200));
+ entity.setAirSupply(200);
+ entity.baseTick();
+ check(h, entity.getAirSupply() == 198, "Choke I must double actual underwater air loss: " + type);
+ entity.setAirSupply(200);
+ CommonHooks.onLivingBreathe(entity, h.getLevel(), 0, 4);
+ check(h, entity.getAirSupply() == 200, "Choke must preserve Respiration's skipped consumption: " + type);
+ entity.addEffect(new MobEffectInstance(ModEffects.CHOKE, 200, 1));
+ entity.baseTick();
+ check(h, entity.getAirSupply() == 197, "Choke II must triple underwater air loss: " + type);
+ entity.addEffect(new MobEffectInstance(MobEffects.WATER_BREATHING, 200));
+ entity.setAirSupply(200);
+ entity.baseTick();
+ check(h, entity.getAirSupply() >= 200, "Water Breathing must protect against Choke: " + type);
+ entity.removeEffect(MobEffects.WATER_BREATHING);
+ entity.setPos(h.absoluteVec(new Vec3(3.5, 6, 3.5)));
+ entity.baseTick();
+ entity.setAirSupply(200);
+ entity.baseTick();
+ check(h, entity.getAirSupply() == 204, "Ordinary Choke targets must recover air on land: " + type);
+ }
+ for (var type : List.of(EntityTypes.BAT, EntityTypes.ALLAY)) {
+ var entity = submerged(h, type);
+ entity.addEffect(new MobEffectInstance(ModEffects.CHOKE, 200, 2));
+ entity.setAirSupply(200);
+ entity.baseTick();
+ check(h, entity.getAirSupply() == 199, "Unlisted types must retain normal air consumption: " + type);
+ }
+ var phantom = submerged(h, EntityTypes.PHANTOM);
+ phantom.addEffect(new MobEffectInstance(ModEffects.SINK, 200));
+ phantom.addEffect(new MobEffectInstance(ModEffects.CHOKE, 200));
+ phantom.setAirSupply(0);
+ CommonHooks.onLivingBreathe(phantom, h.getLevel(), 1, 4);
+ check(h, !FluidEffects.cannotRise(phantom) && phantom.getAirSupply() > 0,
+ "Phantom must now resist both effects");
+ var player = (ServerPlayer) submerged(h, EntityTypes.PLAYER);
+ // The mock fixes gameMode() to its constructor value; set the abilities used by the breathing hook.
+ GameType.CREATIVE.updatePlayerAbilities(player.getAbilities());
+ player.addEffect(new MobEffectInstance(ModEffects.CHOKE, 200));
+ player.setAirSupply(200);
+ player.baseTick();
+ check(h, player.getAirSupply() >= 200, "Creative invulnerability must protect against Choke: air="
+ + player.getAirSupply() + ", invulnerable=" + player.getAbilities().invulnerable);
+ h.succeed();
+ }
+
+ private static void assertWhitelist(GameTestHelper h, TagKey> tag, String vanillaNames) {
+ Set expected = new HashSet<>();
+ for (String name : vanillaNames.strip().split("\\s+")) expected.add("minecraft:" + name);
+ expected.add("ww_hfas:muddy_pig");
+ Set actual = new HashSet<>();
+ for (var holder : h.getLevel().registryAccess().lookupOrThrow(Registries.ENTITY_TYPE).getOrThrow(tag)) {
+ actual.add(BuiltInRegistries.ENTITY_TYPE.getKey(holder.value()).toString());
+ }
+ check(h, actual.equals(expected), "Exact whitelist must match " + tag + ": " + actual);
+ }
+
+ static void landChoke(GameTestHelper h) {
+ for (int x = 1; x <= 6; x++) for (int y = 1; y <= 4; y++) for (int z = 1; z <= 6; z++) {
+ h.setBlock(x, y, z, Blocks.WATER);
+ }
+ // Keep the dry position sheltered so rain cannot rehydrate the axolotl.
+ h.setBlock(3, 8, 3, Blocks.STONE);
+ for (var type : List.of(EntityTypes.TADPOLE, EntityTypes.AXOLOTL)) {
+ var entity = submerged(h, type);
+ ((Mob) entity).setNoAi(false);
+ check(h, !entity.is(ModEntityTypeTags.CHOKE_AFFECTED), "Land-only types must not enter the water Choke tag");
+ entity.addEffect(new MobEffectInstance(ModEffects.CHOKE, 200));
+ entity.setAirSupply(200);
+ entity.baseTick();
+ check(h, entity.getAirSupply() == entity.getMaxAirSupply(), "Choke must not affect submerged " + type);
+ entity.setPos(h.absoluteVec(new Vec3(3.5, 6, 3.5)));
+ entity.baseTick();
+ check(h, !entity.isInWater(), "Special Choke test must be on dry land");
+ entity.setAirSupply(200);
+ entity.baseTick();
+ check(h, entity.getAirSupply() == 198, "Choke I must double real land drying: " + type);
+ entity.addEffect(new MobEffectInstance(ModEffects.CHOKE, 200, 1));
+ entity.setAirSupply(200);
+ entity.baseTick();
+ check(h, entity.getAirSupply() == 197, "Choke II must triple real land drying: " + type);
+ entity.removeEffect(ModEffects.CHOKE);
+ entity.setAirSupply(200);
+ entity.baseTick();
+ check(h, entity.getAirSupply() == 199, "Removing Choke must restore vanilla drying: " + type);
+ entity.addEffect(new MobEffectInstance(ModEffects.CHOKE, 200));
+ entity.setAirSupply(-18);
+ entity.invulnerableTime = 0;
+ float health = entity.getHealth();
+ entity.baseTick();
+ check(h, entity.getHealth() == health - 2 && entity.getAirSupply() == 0,
+ "Land Choke must use vanilla drying damage exactly once: " + type
+ + ", health " + health + " -> " + entity.getHealth() + ", air=" + entity.getAirSupply());
+ entity.setPos(h.absoluteVec(new Vec3(3.5, 1.5, 3.5)));
+ entity.baseTick();
+ check(h, entity.getAirSupply() == entity.getMaxAirSupply(), "Returning to water must stop Choke and refill air: " + type);
+ }
+ var cod = submerged(h, EntityTypes.COD);
+ cod.addEffect(new MobEffectInstance(ModEffects.CHOKE, 200, 2));
+ cod.setPos(h.absoluteVec(new Vec3(3.5, 6, 3.5)));
+ cod.baseTick();
+ cod.setAirSupply(200);
+ cod.baseTick();
+ check(h, cod.getAirSupply() == 199, "Shared WaterAnimal mixin must not accelerate other fish drying");
+ h.succeed();
+ }
+}
diff --git a/src/hfas/gametest/java/org/polaris2023/wildwind/hfas/test/GameplayGameTests.java b/src/hfas/gametest/java/org/polaris2023/wildwind/hfas/test/GameplayGameTests.java
new file mode 100644
index 0000000..872ff61
--- /dev/null
+++ b/src/hfas/gametest/java/org/polaris2023/wildwind/hfas/test/GameplayGameTests.java
@@ -0,0 +1,270 @@
+package org.polaris2023.wildwind.hfas.test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.component.DataComponents;
+import net.minecraft.core.registries.Registries;
+import net.minecraft.gametest.framework.GameTestHelper;
+import net.minecraft.network.chat.Component;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.tags.ItemTags;
+import net.minecraft.world.effect.MobEffectInstance;
+import net.minecraft.world.effect.MobEffects;
+import net.minecraft.world.entity.Entity;
+import net.minecraft.world.entity.EntityTypes;
+import net.minecraft.world.entity.MoverType;
+import net.minecraft.world.entity.item.ItemEntity;
+import net.minecraft.world.inventory.ContainerLevelAccess;
+import net.minecraft.world.inventory.CraftingMenu;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.Items;
+import net.minecraft.world.item.crafting.CraftingInput;
+import net.minecraft.world.item.crafting.RecipeType;
+import net.minecraft.world.item.enchantment.Enchantments;
+import net.minecraft.world.level.GameType;
+import net.minecraft.world.level.block.Block;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.level.block.entity.CrafterBlockEntity;
+import net.minecraft.world.phys.AABB;
+import net.minecraft.world.phys.Vec3;
+import net.neoforged.bus.api.IEventBus;
+import net.neoforged.neoforge.common.CommonHooks;
+import net.neoforged.neoforge.common.NeoForge;
+import net.neoforged.neoforge.event.level.BlockDropsEvent;
+import net.neoforged.neoforge.registries.DeferredRegister;
+import org.polaris2023.wildwind.hfas.effect.ModEffects;
+import org.polaris2023.wildwind.hfas.enchantment.MeltingEnchantment;
+import org.polaris2023.wildwind.hfas.event.CobwebBreaking;
+import org.polaris2023.wildwind.hfas.recipe.WoolToStringRecipe;
+
+public final class GameplayGameTests {
+ private GameplayGameTests() {}
+ public static void register(IEventBus bus) {
+ DeferredRegister> tests = DeferredRegister.create(Registries.TEST_FUNCTION, "ww_hfas_tests");
+ tests.register("wool_recipe", () -> GameplayGameTests::woolRecipe);
+ tests.register("crafter_shears", () -> GameplayGameTests::crafterShears);
+ tests.register("fluid_effects", () -> GameplayGameTests::fluidEffects);
+ tests.register("fluid_entity_rules", () -> FluidEntityRulesGameTests::run);
+ tests.register("land_choke", () -> FluidEntityRulesGameTests::landChoke);
+ tests.register("azalea_signs", () -> AzaleaAndArrowGameTests::signs);
+ tests.register("azalea_shelf", () -> AzaleaAndArrowGameTests::shelf);
+ tests.register("azalea_shelf_chain", () -> AzaleaAndArrowGameTests::shelfChain);
+ tests.register("reach_arrow_acquisition", () -> AzaleaAndArrowGameTests::acquisition);
+ tests.register("reach_arrows", () -> AzaleaAndArrowGameTests::arrows);
+ tests.register("kiln", () -> KilnGameTests::cooking);
+ tests.register("kiln_inventory", () -> KilnGameTests::inventory);
+ tests.register("kiln_villages", () -> KilnGameTests::villages);
+ tests.register("upstream_integration", () -> UpstreamIntegrationGameTests::run);
+ tests.register("cobweb_timers", () -> GameplayGameTests::cobwebTimers);
+ tests.register("cobweb_tick_event", () -> GameplayGameTests::cobwebTickEvent);
+ tests.register("melting", () -> GameplayGameTests::melting);
+ tests.register("skeleton_spawns", () -> SkeletonAndFarmlandGameTests::skeletonSpawns);
+ tests.register("skeleton_melee", () -> SkeletonAndFarmlandGameTests::skeletonMelee);
+ tests.register("farmland_protection", () -> SkeletonAndFarmlandGameTests::farmland);
+ tests.register("creative_tab", () -> SkeletonAndFarmlandGameTests::creativeTab);
+ tests.register(bus);
+ }
+ private static void check(GameTestHelper h, boolean ok, String message) {
+ h.assertTrue(ok, Component.literal(message));
+ }
+
+ private static void woolRecipe(GameTestHelper h) {
+ var level = h.getLevel();
+ var recipe = WoolToStringRecipe.INSTANCE;
+ var shears = new ItemStack(Items.SHEARS);
+ shears.set(DataComponents.CUSTOM_NAME, Component.literal("Test shears"));
+ for (var wool : level.registryAccess().lookupOrThrow(Registries.ITEM).getOrThrow(ItemTags.WOOL)) {
+ for (int woolSlot = 0; woolSlot < 4; woolSlot++) {
+ var stacks = new ArrayList<>(List.of(ItemStack.EMPTY, ItemStack.EMPTY, ItemStack.EMPTY, ItemStack.EMPTY));
+ stacks.set(woolSlot, new ItemStack(wool));
+ stacks.set((woolSlot + 1) % 4, shears.copy());
+ var input = CraftingInput.of(2, 2, stacks);
+ var found = level.recipeAccess().getRecipeFor(RecipeType.CRAFTING, input, level);
+ check(h, found.isPresent() && found.get().value() instanceof WoolToStringRecipe, "All wool colors and slot orders must match registered recipe");
+ check(h, recipe.assemble(input).is(Items.STRING) && recipe.assemble(input).getCount() == 2, "One wool must give two string");
+ var remainder = recipe.remainingItems(input, level).stream().filter(s -> !s.isEmpty()).findFirst().orElseThrow();
+ check(h, remainder.getDamageValue() == 1 && remainder.getHoverName().getString().equals("Test shears"), "Shears must retain data and take one damage");
+ check(h, shears.getDamageValue() == 0, "Crafting must not mutate ingredient during preview");
+ }
+ }
+ check(h, !recipe.matches(CraftingInput.of(2, 2, List.of(new ItemStack(Items.WOOL.white()), shears, new ItemStack(Items.DIRT), ItemStack.EMPTY)), level), "Extra ingredients must fail");
+ check(h, !recipe.matches(CraftingInput.of(2, 1, List.of(new ItemStack(Items.WOOL.white()), new ItemStack(Items.STRING))), level), "Wrong tool must fail");
+ var broken = shears.copy(); broken.setDamageValue(broken.getMaxDamage() - 1);
+ check(h, recipe.remainingItems(CraftingInput.of(2, 1, List.of(new ItemStack(Items.WOOL.white()), broken)), level).stream().allMatch(ItemStack::isEmpty), "Last durability must break the shears");
+ var enchanted = shears.copy();
+ var unbreaking = level.registryAccess().lookupOrThrow(Registries.ENCHANTMENT).getOrThrow(Enchantments.UNBREAKING);
+ enchanted.enchant(unbreaking, 3);
+ int damage = 0;
+ for (int i = 0; i < 256; i++) {
+ var remainder = recipe.remainingItems(CraftingInput.of(2, 1, List.of(new ItemStack(Items.WOOL.white()), enchanted)), level).get(1);
+ check(h, remainder.getEnchantments().getLevel(unbreaking) == 3, "Unbreaking must survive crafting");
+ damage += remainder.getDamageValue();
+ }
+ check(h, damage > 0 && damage < 160, "Unbreaking III must probabilistically reduce crafting damage");
+ var player = (ServerPlayer) h.makeMockServerPlayer(GameType.SURVIVAL);
+ var connection = new net.minecraft.network.Connection(net.minecraft.network.protocol.PacketFlow.SERVERBOUND);
+ new io.netty.channel.embedded.EmbeddedChannel(connection);
+ level.getServer().getPlayerList().placeNewPlayer(connection, player,
+ net.minecraft.server.network.CommonListenerCookie.createInitial(player.getGameProfile(), false));
+ var menu = new CraftingMenu(0, player.getInventory(), ContainerLevelAccess.create(level, h.absolutePos(new BlockPos(2, 1, 2))));
+ menu.getSlot(1).set(new ItemStack(Items.WOOL.white(), 3));
+ menu.getSlot(2).set(shears.copy());
+ // Invoke the real result-slot path twice, including CommonHooks crafting-player context.
+ for (int i = 0; i < 2; i++) {
+ var result = menu.getSlot(0).remove(2);
+ check(h, result.is(Items.STRING) && result.getCount() == 2, "Crafting menu must offer the registered output");
+ menu.getSlot(0).onTake(player, result);
+ }
+ check(h, menu.getSlot(1).getItem().getCount() == 1 && menu.getSlot(2).getItem().getDamageValue() == 2, "Repeated crafts must consume one wool and one durability each");
+ menu.getSlot(1).set(new ItemStack(Items.WOOL.white(), 3));
+ for (int i = 0; i < 3; i++) menu.quickMoveStack(player, 0);
+ check(h, menu.getSlot(1).getItem().isEmpty() && menu.getSlot(2).getItem().getDamageValue() == 5,
+ "Shift-click crafts must return shears and charge durability for every wool");
+ check(h, player.getInventory().countItem(Items.STRING) == 6, "Shift-click must transfer all six string");
+ h.succeed();
+ }
+
+ private static void crafterShears(GameTestHelper h) {
+ var pos = new BlockPos(3, 2, 3);
+ h.setBlock(pos, Blocks.CRAFTER);
+ var crafter = (CrafterBlockEntity) h.getLevel().getBlockEntity(h.absolutePos(pos));
+ crafter.setItem(0, new ItemStack(Items.WOOL.white()));
+ crafter.setItem(1, new ItemStack(Items.SHEARS));
+ h.setBlock(pos.below(), Blocks.REDSTONE_BLOCK);
+ h.runAfterDelay(10, () -> {
+ var drops = h.getLevel().getEntitiesOfClass(ItemEntity.class, new AABB(h.absolutePos(pos)).inflate(4));
+ check(h, drops.stream().anyMatch(e -> e.getItem().is(Items.STRING) && e.getItem().getCount() == 2), "Crafter must eject two string");
+ check(h, drops.stream().anyMatch(e -> e.getItem().is(Items.SHEARS) && e.getItem().getDamageValue() == 1), "Crafter must eject damaged shears using server durability rules");
+ h.succeed();
+ });
+ }
+
+ private static void fluidEffects(GameTestHelper h) {
+ for (int x = 1; x <= 3; x++) for (int y = 1; y <= 4; y++) for (int z = 1; z <= 3; z++) h.setBlock(x, y, z, Blocks.WATER);
+ for (int x = 5; x <= 6; x++) for (int y = 1; y <= 4; y++) for (int z = 1; z <= 3; z++) h.setBlock(x, y, z, Blocks.LAVA);
+ var pig = h.spawn(EntityTypes.PIG, 2, 2, 2); pig.setNoAi(true); pig.setNoGravity(true);
+ var lavaPig = h.spawn(EntityTypes.PIG, 5, 2, 2); lavaPig.setNoAi(true); lavaPig.setNoGravity(true); lavaPig.setInvulnerable(true);
+ pig.addEffect(new MobEffectInstance(ModEffects.SINK, 200));
+ pig.addEffect(new MobEffectInstance(ModEffects.CHOKE, 200));
+ lavaPig.addEffect(new MobEffectInstance(ModEffects.SINK, 200));
+ h.runAfterDelay(3, () -> {
+ double y = pig.getY(); pig.move(MoverType.SELF, new Vec3(0, 0.25, 0));
+ check(h, pig.getY() == y, "Sink must stop actual upward water movement");
+ double lavaY = lavaPig.getY(); lavaPig.move(MoverType.SELF, new Vec3(0, 0.25, 0));
+ check(h, lavaPig.getY() == lavaY, "Sink must also stop lava buoyancy");
+ pig.move(MoverType.SELF, new Vec3(0.05, -0.05, 0));
+ check(h, pig.getY() < y, "Sink must allow downward and horizontal motion");
+ pig.setAirSupply(200); CommonHooks.onLivingBreathe(pig, h.getLevel(), 1, 4);
+ check(h, pig.getAirSupply() == 198, "Choke I must consume two air per normal breathing tick");
+ pig.addEffect(new MobEffectInstance(ModEffects.CHOKE, 200, 1));
+ pig.setAirSupply(200); CommonHooks.onLivingBreathe(pig, h.getLevel(), 1, 4);
+ check(h, pig.getAirSupply() == 197, "Choke II must consume three air");
+ pig.setAirSupply(200); CommonHooks.onLivingBreathe(pig, h.getLevel(), 0, 4);
+ check(h, pig.getAirSupply() == 200, "Respiration's skipped consumption must remain skipped");
+ pig.addEffect(new MobEffectInstance(MobEffects.WATER_BREATHING, 200));
+ CommonHooks.onLivingBreathe(pig, h.getLevel(), 1, 4);
+ check(h, pig.getAirSupply() == 200, "Water breathing must continue to protect oxygen");
+ pig.removeEffect(ModEffects.SINK); double before = pig.getY(); pig.move(MoverType.SELF, new Vec3(0, 0.1, 0));
+ check(h, pig.getY() > before, "Removing Sink must restore upward movement");
+ h.succeed();
+ });
+ }
+
+ private static void moveWeb(Entity entity, int ticks, boolean falling) {
+ for (int i = 0; i < ticks; i++) {
+ entity.setPos(entity.getX() + (falling ? 0 : (i % 2 == 0 ? 0.001 : -0.001)), entity.getY() - (falling ? 0.001 : 0), entity.getZ());
+ CobwebBreaking.tick(entity);
+ }
+ }
+ private static void cobwebTimers(GameTestHelper h) {
+ var pos = new BlockPos(3, 2, 3);
+ h.setBlock(pos, Blocks.COBWEB);
+ var pig = h.spawn(EntityTypes.PIG, 3.5F, 2.3F, 3.5F); pig.setNoAi(true); pig.setNoGravity(true); pig.setOnGround(true);
+ CobwebBreaking.tick(pig); moveWeb(pig, 29, false);
+ h.assertBlockPresent(Blocks.COBWEB, pos);
+ CobwebBreaking.tick(pig); // No displacement resets the counter.
+ moveWeb(pig, 29, false); h.assertBlockPresent(Blocks.COBWEB, pos);
+ moveWeb(pig, 1, false); h.assertBlockPresent(Blocks.AIR, pos);
+ h.setBlock(pos, Blocks.COBWEB); pig.setOnGround(false);
+ moveWeb(pig, 159, true); h.assertBlockPresent(Blocks.COBWEB, pos);
+ moveWeb(pig, 1, true); h.assertBlockPresent(Blocks.AIR, pos);
+ h.setBlock(pos, Blocks.COBWEB);
+ var spider = h.spawn(EntityTypes.SPIDER, 3.5F, 2.3F, 3.5F);
+ var caveSpider = h.spawn(EntityTypes.CAVE_SPIDER, 3.5F, 2.3F, 3.5F);
+ for (var entity : List.of(spider, caveSpider)) { CobwebBreaking.tick(entity); moveWeb(entity, 200, false); }
+ h.assertBlockPresent(Blocks.COBWEB, pos);
+ for (var mode : List.of(GameType.SPECTATOR, GameType.CREATIVE)) {
+ var player = h.makeMockPlayer(mode); player.getAbilities().flying = true;
+ player.setPos(pig.position()); CobwebBreaking.tick(player); moveWeb(player, 200, false);
+ h.assertBlockPresent(Blocks.COBWEB, pos);
+ }
+ // Counter belongs to the touched block, not a global web timer.
+ pig.setOnGround(true); CobwebBreaking.tick(pig); moveWeb(pig, 20, false);
+ pig.setPos(h.absoluteVec(new Vec3(6.5, 2.3, 3.5))); CobwebBreaking.tick(pig);
+ pig.setPos(h.absoluteVec(new Vec3(3.5, 2.3, 3.5))); CobwebBreaking.tick(pig);
+ moveWeb(pig, 29, false); h.assertBlockPresent(Blocks.COBWEB, pos);
+ moveWeb(pig, 1, false); h.assertBlockPresent(Blocks.AIR, pos);
+ h.succeed();
+ }
+
+ private static void cobwebTickEvent(GameTestHelper h) {
+ var pos = new BlockPos(3, 2, 3); h.setBlock(pos, Blocks.COBWEB);
+ var pig = h.spawn(EntityTypes.PIG, 3.5F, 2.3F, 3.5F); pig.setNoAi(true); pig.setNoGravity(true);
+ // Actual entity tick events must drive the timer, even with no explicit calls to the handler.
+ for (int i = 1; i <= 35; i++) {
+ final int tick = i;
+ h.runAfterDelay(i, () -> pig.setPos(pig.getX() + (tick % 2 == 0 ? 0.001 : -0.001), pig.getY(), pig.getZ()));
+ }
+ h.runAfterDelay(37, () -> { h.assertBlockPresent(Blocks.AIR, pos); h.succeed(); });
+ }
+
+ private static BlockDropsEvent drops(GameTestHelper h, ServerPlayer player, ItemStack tool, List stacks) {
+ var entities = new ArrayList();
+ for (var stack : stacks) entities.add(new ItemEntity(h.getLevel(), 0, 0, 0, stack));
+ var event = new BlockDropsEvent(h.getLevel(), h.absolutePos(new BlockPos(3, 1, 3)), Blocks.IRON_ORE.defaultBlockState(), null, entities, player, tool);
+ NeoForge.EVENT_BUS.post(event);
+ return event;
+ }
+ private static int count(BlockDropsEvent event, net.minecraft.world.item.Item item) {
+ return event.getDrops().stream().map(ItemEntity::getItem).filter(s -> s.is(item)).mapToInt(ItemStack::getCount).sum();
+ }
+ private static void melting(GameTestHelper h) {
+ var level = h.getLevel();
+ var player = (ServerPlayer) h.makeMockServerPlayer(GameType.SURVIVAL);
+ var registry = level.registryAccess().lookupOrThrow(Registries.ENCHANTMENT);
+ var tool = new ItemStack(Items.DIAMOND_PICKAXE); tool.enchant(registry.getOrThrow(MeltingEnchantment.MELTING), 1);
+ player.setExperienceLevels(0); player.setExperiencePoints(2); player.totalExperience = 9999;
+ var event = drops(h, player, tool, List.of(new ItemStack(Items.RAW_IRON, 3)));
+ check(h, count(event, Items.RAW_IRON) == 3 && MeltingEnchantment.availableExperience(player) == 2, "Insufficient current XP must preserve entire drop; lifetime total is not fuel");
+ player.setExperiencePoints(5);
+ event = drops(h, player, tool, List.of(new ItemStack(Items.RAW_IRON, 3), new ItemStack(Items.DIAMOND, 2)));
+ check(h, count(event, Items.IRON_INGOT) == 3 && count(event, Items.DIAMOND) == 2 && MeltingEnchantment.availableExperience(player) == 2, "Melt only smeltable drops and charge one point per input");
+ var silk = tool.copy(); silk.enchant(registry.getOrThrow(Enchantments.SILK_TOUCH), 1);
+ event = drops(h, player, silk, List.of(new ItemStack(Items.IRON_ORE)));
+ check(h, count(event, Items.IRON_ORE) == 1 && MeltingEnchantment.availableExperience(player) == 2, "Silk Touch must take precedence without XP use");
+ player.setExperienceLevels(1); player.setExperiencePoints(0);
+ event = drops(h, player, tool, List.of(new ItemStack(Items.RAW_IRON)));
+ check(h, count(event, Items.IRON_INGOT) == 1 && player.experienceLevel == 0 && MeltingEnchantment.availableExperience(player) == 6, "Spend exactly one point across level boundary");
+ var fortune = tool.copy(); fortune.enchant(registry.getOrThrow(Enchantments.FORTUNE), 3);
+ player.setExperienceLevels(10); player.setExperiencePoints(0);
+ var loot = Block.getDrops(Blocks.IRON_ORE.defaultBlockState(), level, h.absolutePos(new BlockPos(3, 1, 3)), null, player, fortune);
+ int inputCount = loot.stream().filter(s -> s.is(Items.RAW_IRON)).mapToInt(ItemStack::getCount).sum();
+ long before = MeltingEnchantment.availableExperience(player);
+ event = drops(h, player, fortune, loot);
+ check(h, inputCount > 0 && count(event, Items.IRON_INGOT) == inputCount && MeltingEnchantment.availableExperience(player) == before - inputCount, "Fortune loot must be determined first and fully charged");
+ for (int currentLevel : new int[]{16, 17, 30, 31, 32}) {
+ player.setExperienceLevels(currentLevel); player.setExperiencePoints(0); before = MeltingEnchantment.availableExperience(player);
+ event = drops(h, player, tool, List.of(new ItemStack(Items.RAW_IRON)));
+ check(h, count(event, Items.IRON_INGOT) == 1 && MeltingEnchantment.availableExperience(player) == before - 1, "XP formula at level " + currentLevel);
+ }
+ // Exercise vanilla block dropping, rather than only posting the event directly.
+ var pos = h.absolutePos(new BlockPos(5, 2, 5)); before = MeltingEnchantment.availableExperience(player);
+ Block.dropResources(Blocks.IRON_ORE.defaultBlockState(), level, pos, null, player, tool);
+ check(h, MeltingEnchantment.availableExperience(player) == before - 1
+ && level.getEntitiesOfClass(ItemEntity.class, new AABB(pos).inflate(1)).stream().anyMatch(e -> e.getItem().is(Items.IRON_INGOT)),
+ "Vanilla block loot path must invoke Melting");
+ h.succeed();
+ }
+}
diff --git a/src/hfas/gametest/java/org/polaris2023/wildwind/hfas/test/KilnGameTests.java b/src/hfas/gametest/java/org/polaris2023/wildwind/hfas/test/KilnGameTests.java
new file mode 100644
index 0000000..ee01637
--- /dev/null
+++ b/src/hfas/gametest/java/org/polaris2023/wildwind/hfas/test/KilnGameTests.java
@@ -0,0 +1,231 @@
+package org.polaris2023.wildwind.hfas.test;
+
+import java.util.List;
+import java.util.Arrays;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.Direction;
+import net.minecraft.core.component.DataComponents;
+import net.minecraft.core.registries.Registries;
+import net.minecraft.advancements.predicates.ItemPredicate;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.world.LockCode;
+import net.minecraft.world.InteractionHand;
+import net.minecraft.world.entity.ExperienceOrb;
+import net.minecraft.world.entity.item.ItemEntity;
+import net.minecraft.world.inventory.AbstractContainerMenu;
+import net.minecraft.world.level.GameType;
+import net.minecraft.world.level.block.Block;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.level.block.HopperBlock;
+import net.minecraft.world.level.block.Rotation;
+import net.minecraft.world.level.block.entity.HopperBlockEntity;
+import net.minecraft.world.level.levelgen.structure.pools.StructurePoolElement;
+import net.minecraft.world.level.levelgen.structure.pools.StructureTemplatePool;
+import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings;
+import net.minecraft.world.level.levelgen.structure.templatesystem.LiquidSettings;
+import net.minecraft.world.phys.AABB;
+import net.minecraft.world.phys.Vec3;
+import org.polaris2023.wildwind.hfas.worldgen.MasonKilnProcessor;
+import net.minecraft.gametest.framework.GameTestHelper;
+import net.minecraft.network.chat.Component;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.Items;
+import net.minecraft.world.level.block.AbstractFurnaceBlock;
+import net.minecraft.world.level.block.entity.BlockEntity;
+import org.polaris2023.wildwind.hfas.block.ModBlocks;
+import org.polaris2023.wildwind.hfas.block.entity.KilnBlockEntity;
+import org.polaris2023.wildwind.hfas.recipe.KilnRecipes;
+
+final class KilnGameTests {
+ private KilnGameTests() {}
+
+ private static void check(GameTestHelper h, boolean ok, String message) {
+ h.assertTrue(ok, Component.literal(message));
+ }
+
+ static void cooking(GameTestHelper h) {
+ var pos = h.absolutePos(new net.minecraft.core.BlockPos(2, 1, 2));
+ h.setBlock(2, 1, 2, ModBlocks.KILN.get());
+ BlockEntity raw = h.getLevel().getBlockEntity(pos);
+ check(h, raw instanceof KilnBlockEntity, "Kiln must create its block entity");
+ var kiln = (KilnBlockEntity) raw;
+ var state = kiln.getBlockState();
+ check(h, state.getDestroySpeed(h.getLevel(), pos) == 3.5F && ModBlocks.KILN.get().getExplosionResistance() == 3.5F,
+ "Kiln hardness and blast resistance must both be 3.5");
+ check(h, state.getPistonPushReaction() == net.minecraft.world.level.material.PushReaction.BLOCK
+ && state.isRedstoneConductor(h.getLevel(), pos) && state.isSuffocating(h.getLevel(), pos),
+ "Kiln must be a solid conductor that pistons cannot move");
+ var recipeInput = net.minecraft.world.item.crafting.CraftingInput.of(3, 3, List.of(
+ ItemStack.EMPTY, new ItemStack(Items.BRICK), ItemStack.EMPTY,
+ new ItemStack(Items.BRICK), new ItemStack(Items.FURNACE), new ItemStack(Items.BRICK),
+ new ItemStack(Items.PACKED_MUD), new ItemStack(Items.PACKED_MUD), new ItemStack(Items.PACKED_MUD)));
+ check(h, h.getLevel().recipeAccess().getRecipeFor(net.minecraft.world.item.crafting.RecipeType.CRAFTING, recipeInput, h.getLevel())
+ .orElseThrow().value().assemble(recipeInput).is(ModBlocks.KILN.get().asItem()), "Requested kiln crafting pattern must work");
+ kiln.setItem(0, new ItemStack(Items.CACTUS, 2));
+ kiln.setItem(1, new ItemStack(Items.COAL));
+ check(h, KilnRecipes.find(h.getLevel(), kiln.getItem(0)).isPresent(), "Cactus must be kiln-smeltable");
+ check(h, KilnRecipes.find(h.getLevel(), new ItemStack(Items.IRON_ORE)).isEmpty(), "Ore must be excluded");
+ for (var denied : List.of(Items.RAW_IRON, Items.IRON_PICKAXE, Items.GOLDEN_CHESTPLATE, Items.ANCIENT_DEBRIS)) {
+ check(h, KilnRecipes.find(h.getLevel(), new ItemStack(denied)).isEmpty(), "Metal/mineral must be excluded: " + denied);
+ check(h, !kiln.canPlaceItem(0, new ItemStack(denied)), "Automated input must reject excluded materials");
+ }
+ for (var allowed : List.of(Items.SAND, Items.CLAY_BALL, Items.COBBLESTONE, Items.OAK_LOG, Items.WET_SPONGE, Items.POTATO)) {
+ check(h, KilnRecipes.find(h.getLevel(), new ItemStack(allowed)).isPresent(), "Non-mineral smelting recipe must work: " + allowed);
+ }
+ tick(h, kiln, 99);
+ check(h, kiln.getItem(2).isEmpty(), "Nothing may finish before tick 100");
+ var ticker = ModBlocks.KILN.get().getTicker(h.getLevel(), state,
+ org.polaris2023.wildwind.hfas.block.entity.ModBlockEntities.KILN.get());
+ check(h, ticker != null, "Kiln must have a registered server ticker");
+ ticker.tick(h.getLevel(), pos, h.getLevel().getBlockState(pos), kiln);
+ check(h, kiln.getItem(0).getCount() == 1, "Kiln must consume exactly one input after 100 ticks");
+ check(h, kiln.getItem(2).is(Items.DYE.green()) && kiln.getItem(2).getCount() == 1,
+ "Kiln must produce one cactus green dye");
+ check(h, kiln.getItem(1).isEmpty(), "One coal must be consumed when the kiln starts");
+ check(h, h.getLevel().getBlockState(pos).getValue(AbstractFurnaceBlock.LIT),
+ "Kiln must be lit while its fuel remains");
+ check(h, h.getLevel().getBlockState(pos).getLightEmission() == 13, "Burning kiln must emit light 13");
+ var saved = kiln.saveWithFullMetadata(h.getLevel().registryAccess());
+ var loaded = (KilnBlockEntity) BlockEntity.loadStatic(pos, kiln.getBlockState(), saved, h.getLevel().registryAccess());
+ check(h, loaded != null && loaded.getItem(2).is(Items.DYE.green()), "Contents must survive reload");
+ loaded.setLevel(h.getLevel());
+ tick(h, loaded, 100);
+ check(h, loaded.getItem(2).getCount() == 2 && loaded.getItem(0).isEmpty(), "Fuel/progress must continue after reload");
+ kiln.setItem(0, new ItemStack(Items.CACTUS));
+ kiln.setItem(2, new ItemStack(Items.DYE.green(), 64));
+ tick(h, kiln, 100);
+ check(h, kiln.getItem(0).getCount() == 1 && kiln.getItem(2).getCount() == 64, "Full output must prevent consumption");
+ kiln.setItem(2, new ItemStack(Items.STONE));
+ tick(h, kiln, 100);
+ check(h, kiln.getItem(0).getCount() == 1 && kiln.getItem(2).is(Items.STONE), "Different output must prevent cooking");
+ kiln.setItem(0, new ItemStack(Items.IRON_ORE));
+ kiln.setItem(2, ItemStack.EMPTY);
+ tick(h, kiln, 100);
+ check(h, kiln.getItem(2).isEmpty() && kiln.getItem(0).is(Items.IRON_ORE), "Forced excluded input must not cook");
+ kiln.setItem(0, new ItemStack(Items.WET_SPONGE));
+ kiln.setItem(1, new ItemStack(Items.BUCKET));
+ tick(h, kiln, 100);
+ check(h, kiln.getItem(2).is(Items.SPONGE) && kiln.getItem(1).is(Items.WATER_BUCKET), "Wet sponge must fill the fuel-slot bucket");
+ var longFuel = kiln.saveWithFullMetadata(h.getLevel().registryAccess());
+ longFuel.putInt("lit_time_remaining", 50000);
+ longFuel.putInt("lit_total_time", 50000);
+ var longBurn = (KilnBlockEntity) BlockEntity.loadStatic(pos, kiln.getBlockState(), longFuel, h.getLevel().registryAccess());
+ longBurn.setLevel(h.getLevel());
+ tick(h, longBurn, 1);
+ check(h, longBurn.saveCustomOnly(h.getLevel().registryAccess()).getIntOr("lit_time_remaining", 0) == 49999,
+ "Long modded fuel timers must not be truncated by network short scaling");
+ h.succeed();
+ }
+
+ private static void tick(GameTestHelper h, KilnBlockEntity kiln, int ticks) {
+ for (int i = 0; i < ticks; i++) KilnBlockEntity.tick(h.getLevel(), kiln.getBlockPos(), h.getLevel().getBlockState(kiln.getBlockPos()), kiln);
+ }
+
+ static void inventory(GameTestHelper h) {
+ var level = h.getLevel();
+ var pos = h.absolutePos(new BlockPos(3, 2, 3));
+ h.setBlock(3, 2, 3, ModBlocks.KILN.get());
+ var kiln = (KilnBlockEntity) level.getBlockEntity(pos);
+ check(h, Arrays.equals(kiln.getSlotsForFace(Direction.UP), new int[]{0})
+ && Arrays.equals(kiln.getSlotsForFace(Direction.DOWN), new int[]{2, 1})
+ && Arrays.equals(kiln.getSlotsForFace(Direction.EAST), new int[]{1}), "Sided slots must match the specification");
+ h.setBlock(3, 3, 3, Blocks.HOPPER);
+ var top = (HopperBlockEntity) level.getBlockEntity(pos.above());
+ top.setItem(0, new ItemStack(Items.CACTUS));
+ HopperBlockEntity.pushItemsTick(level, top.getBlockPos(), top.getBlockState(), top);
+ check(h, kiln.getItem(0).is(Items.CACTUS), "Top hopper must insert ingredients");
+ h.setBlock(4, 2, 3, Blocks.HOPPER.defaultBlockState().setValue(HopperBlock.FACING, Direction.WEST));
+ var side = (HopperBlockEntity) level.getBlockEntity(pos.east());
+ side.setItem(0, new ItemStack(Items.LAVA_BUCKET));
+ HopperBlockEntity.pushItemsTick(level, side.getBlockPos(), side.getBlockState(), side);
+ check(h, kiln.getItem(1).is(Items.LAVA_BUCKET), "Side hopper must insert fuel");
+ tick(h, kiln, 100);
+ check(h, kiln.getItem(1).is(Items.BUCKET), "Lava fuel must return an empty bucket");
+ h.setBlock(3, 1, 3, Blocks.HOPPER);
+ var bottom = (HopperBlockEntity) level.getBlockEntity(pos.below());
+ check(h, HopperBlockEntity.suckInItems(level, bottom), "Bottom hopper must pull output");
+ check(h, bottom.getItem(0).is(Items.DYE.green()) && kiln.getItem(1).is(Items.BUCKET), "Output must be pulled before the bucket");
+ check(h, HopperBlockEntity.suckInItems(level, bottom) && kiln.getItem(1).isEmpty(), "Bottom hopper must next pull the bucket");
+ check(h, kiln.saveCustomOnly(level.registryAccess()).getCompound("RecipesUsed").orElseThrow().size() > 0,
+ "Hopper extraction must retain stored experience");
+ kiln.clearContent();
+ check(h, AbstractContainerMenu.getRedstoneSignalFromBlockEntity(kiln) == 0, "Empty kiln comparator must be zero");
+ kiln.setItem(0, new ItemStack(Items.CACTUS, 64));
+ kiln.setItem(1, new ItemStack(Items.COAL, 64));
+ kiln.setItem(2, new ItemStack(Items.DYE.green(), 64));
+ check(h, kiln.getBlockState().getAnalogOutputSignal(level, pos, Direction.NORTH) == 15, "Comparator must count all three slots");
+ kiln.clearContent();
+ var player = connectedPlayer(h);
+ var named = new ItemStack(ModBlocks.KILN.get());
+ named.set(DataComponents.CUSTOM_NAME, Component.literal("Test kiln"));
+ named.set(DataComponents.LOCK, new LockCode(ItemPredicate.Builder.item()
+ .of(level.registryAccess().lookupOrThrow(Registries.ITEM), Items.BRICK).build()));
+ kiln.applyComponentsFromItemStack(named);
+ check(h, kiln.getName().getString().equals("Test kiln") && !kiln.canOpen(player), "Custom name and lock must apply");
+ player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.BRICK));
+ check(h, kiln.canOpen(player), "Matching key item must unlock kiln");
+ var menu = kiln.createMenu(1, player.getInventory(), player);
+ check(h, menu != null && !menu.getSlot(0).mayPlace(new ItemStack(Items.IRON_ORE)), "GUI must reject excluded input");
+ check(h, !menu.getSlot(2).mayPlace(new ItemStack(Items.STONE)), "Output slot must reject insertion");
+ kiln.setItem(0, new ItemStack(Items.CACTUS));
+ tick(h, kiln, 100);
+ var outputSlot = menu.getSlot(2);
+ var result = outputSlot.remove(1);
+ outputSlot.onTake(player, result);
+ check(h, kiln.saveCustomOnly(level.registryAccess()).getCompound("RecipesUsed").orElseThrow().isEmpty(),
+ "Manual extraction must release and clear stored experience");
+ check(h, !level.getEntitiesOfClass(ExperienceOrb.class, new AABB(player.blockPosition()).inflate(2)).isEmpty(), "Manual extraction must spawn stored experience");
+ kiln.setItem(0, new ItemStack(Items.CACTUS));
+ tick(h, kiln, 100);
+ var state = kiln.getBlockState();
+ check(h, Block.getDrops(state, level, pos, kiln, player, new ItemStack(Items.WOODEN_PICKAXE)).stream()
+ .anyMatch(stack -> stack.is(ModBlocks.KILN.get().asItem())), "Wooden pickaxe must allow kiln loot");
+ check(h, !player.hasCorrectToolForDrops(state), "A held brick must not qualify for kiln drops");
+ player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.WOODEN_PICKAXE));
+ check(h, player.hasCorrectToolForDrops(state), "Wooden pickaxe must qualify for kiln drops");
+ level.destroyBlock(pos, true, player);
+ var dropped = level.getEntitiesOfClass(ItemEntity.class, new AABB(pos).inflate(2));
+ check(h, dropped.stream().anyMatch(e -> e.getItem().is(ModBlocks.KILN.get().asItem()))
+ && dropped.stream().anyMatch(e -> e.getItem().is(Items.DYE.green())), "Breaking must drop the kiln and its inventory");
+ check(h, !level.getEntitiesOfClass(ExperienceOrb.class, new AABB(pos).inflate(2)).isEmpty(), "Breaking must release stored experience");
+ h.succeed();
+ }
+
+ private static ServerPlayer connectedPlayer(GameTestHelper h) {
+ var player = (ServerPlayer) h.makeMockServerPlayer(GameType.SURVIVAL);
+ var connection = new net.minecraft.network.Connection(net.minecraft.network.protocol.PacketFlow.SERVERBOUND);
+ new io.netty.channel.embedded.EmbeddedChannel(connection);
+ h.getLevel().getServer().getPlayerList().placeNewPlayer(connection, player,
+ net.minecraft.server.network.CommonListenerCookie.createInitial(player.getGameProfile(), false));
+ player.setPos(h.absoluteVec(new Vec3(6.5, 2, 6.5)));
+ return player;
+ }
+
+ static void villages(GameTestHelper h) {
+ var level = h.getLevel();
+ var templates = level.getStructureManager();
+ int index = 0;
+ for (var house : MasonKilnProcessor.HOUSES.entrySet()) {
+ for (var rotation : Rotation.values()) {
+ var origin = h.absolutePos(new BlockPos(30 + index++ * 20, 3, 30));
+ var template = templates.getOrCreate(house.getKey());
+ var settings = new StructurePlaceSettings().setRotation(rotation);
+ var box = template.getBoundingBox(settings, origin);
+ var pool = StructurePoolElement.legacy(house.getKey().toString()).apply(StructureTemplatePool.Projection.RIGID);
+ check(h, pool.place(templates, level, level.structureManager(), level.getChunkSource().getGenerator(),
+ origin, origin, rotation, box, level.getRandom(), LiquidSettings.IGNORE_WATERLOGGING, false),
+ "Mason house must place through the real pool path: " + house.getKey());
+ var kilnPos = net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate
+ .calculateRelativePosition(settings, house.getValue().target()).offset(origin);
+ check(h, level.getBlockState(kilnPos).is(ModBlocks.KILN.get()) && level.getBlockEntity(kilnPos) instanceof KilnBlockEntity,
+ "Every mason house rotation must contain a functional kiln: " + house.getKey() + " " + rotation);
+ check(h, level.getBlockState(kilnPos).getValue(AbstractFurnaceBlock.FACING) == rotation.rotate(house.getValue().facing()),
+ "Kiln facing must rotate with its house");
+ for (var cutter : template.filterBlocks(origin, settings, Blocks.STONECUTTER)) {
+ check(h, level.getBlockState(cutter.pos()).is(Blocks.STONECUTTER), "Mason workstation must be preserved");
+ }
+ }
+ }
+ h.succeed();
+ }
+}
diff --git a/src/hfas/gametest/java/org/polaris2023/wildwind/hfas/test/MuddyPigGameTests.java b/src/hfas/gametest/java/org/polaris2023/wildwind/hfas/test/MuddyPigGameTests.java
new file mode 100644
index 0000000..e7dab63
--- /dev/null
+++ b/src/hfas/gametest/java/org/polaris2023/wildwind/hfas/test/MuddyPigGameTests.java
@@ -0,0 +1,106 @@
+package org.polaris2023.wildwind.hfas.test;
+
+import java.util.function.Consumer;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.registries.Registries;
+import net.minecraft.gametest.framework.GameTestHelper;
+import net.minecraft.network.chat.Component;
+import net.minecraft.world.entity.EntitySpawnReason;
+import net.minecraft.world.entity.EntityTypes;
+import net.minecraft.world.entity.MobCategory;
+import net.minecraft.world.entity.SpawnPlacements;
+import net.minecraft.world.entity.ai.attributes.Attributes;
+import net.minecraft.world.item.Items;
+import net.minecraft.world.item.SpawnEggItem;
+import net.minecraft.world.level.biome.Biomes;
+import net.minecraft.world.level.block.Blocks;
+import net.neoforged.bus.api.IEventBus;
+import net.neoforged.fml.common.Mod;
+import net.neoforged.neoforge.registries.DeferredRegister;
+import org.polaris2023.wildwind.hfas.registry.ModEntities;
+import org.polaris2023.wildwind.structures.test.StructureGameTests;
+
+@Mod("ww_hfas_tests")
+public final class MuddyPigGameTests {
+ public MuddyPigGameTests(IEventBus bus) {
+ StructureGameTests.register(bus);
+ GameplayGameTests.register(bus);
+ DeferredRegister> functions = DeferredRegister.create(Registries.TEST_FUNCTION, "ww_hfas_tests");
+ functions.register("entity", () -> MuddyPigGameTests::entity);
+ functions.register("spawning", () -> MuddyPigGameTests::spawning);
+ functions.register(bus);
+ }
+
+ private static void check(GameTestHelper helper, boolean condition, String message) {
+ helper.assertTrue(condition, Component.literal(message));
+ }
+
+ private static void entity(GameTestHelper helper) {
+ var level = helper.getLevel();
+ var type = ModEntities.MUDDY_PIG.get();
+ var pig = helper.spawn(type, 2, 2, 2);
+ var partner = helper.spawn(type, 4, 2, 2);
+ check(helper, pig.getMaxHealth() == 10 && pig.getAttributeValue(Attributes.MOVEMENT_SPEED) == 0.25,
+ "Muddy pig must receive vanilla pig attributes");
+ check(helper, pig.isFood(Items.CARROT.getDefaultInstance()) && !pig.isFood(Items.WHEAT.getDefaultInstance()),
+ "Muddy pig must use vanilla pig food tag");
+ check(helper, SpawnEggItem.getType(org.polaris2023.wildwind.hfas.registry.ModItems.MUDDY_PIG_SPAWN_EGG.toStack()) == type,
+ "Spawn egg entity component must point to muddy pig");
+ var baby = pig.getBreedOffspring(level, partner);
+ check(helper, baby != null && baby.getType() == type, "Breeding must create a muddy pig rather than vanilla pig");
+ baby.setBaby(true);
+ check(helper, baby.isBaby(), "Offspring must support baby state");
+ pig.setInLove(null);
+ partner.setInLove(null);
+ check(helper, pig.canMate(partner) && !pig.canMate(pig), "Two adult muddy pigs must be able to mate");
+ var vanilla = helper.spawn(EntityTypes.PIG, 6, 2, 2);
+ vanilla.setInLove(null);
+ check(helper, !pig.canMate(vanilla) && !vanilla.canMate(pig), "Vanilla pigs must remain a separate breeding species");
+ var eggBaby = SpawnEggItem.spawnOffspringFromSpawnEgg(helper.makeMockServerPlayerInLevel(), pig,
+ type, level, pig.position(), org.polaris2023.wildwind.hfas.registry.ModItems.MUDDY_PIG_SPAWN_EGG.toStack());
+ check(helper, eggBaby.isPresent() && eggBaby.get().isBaby() && eggBaby.get().getType() == type,
+ "Using the spawn egg on an adult must create a muddy pig baby");
+ check(helper, level.getServer().reloadableRegistries().getLootTable(pig.getLootTable().orElseThrow())
+ != net.minecraft.world.level.storage.loot.LootTable.EMPTY, "Muddy pig loot table must be loaded");
+ helper.succeed();
+ }
+
+ private static void spawning(GameTestHelper helper) {
+ var level = helper.getLevel();
+ var type = ModEntities.MUDDY_PIG.get();
+ var biomes = level.registryAccess().lookupOrThrow(Registries.BIOME);
+ for (var biome : biomes.listElements().toList()) {
+ var entries = biome.value().getMobSettings().getMobs(MobCategory.CREATURE).unwrap();
+ var matches = entries.stream().filter(entry -> entry.value().type() == type).toList();
+ boolean expected = biome.is(Biomes.SWAMP) || biome.is(Biomes.MANGROVE_SWAMP);
+ check(helper, matches.size() == (expected ? 1 : 0), "Biome spawn membership: " + biome.key().identifier());
+ if (expected) {
+ var entry = matches.getFirst();
+ check(helper, entry.weight() == 10 && entry.value().minCount() == 2 && entry.value().maxCount() == 4,
+ "Spawn weight and group bounds must be 10 / 2-4");
+ }
+ }
+ var pos = new BlockPos(3, 2, 3);
+ var absolute = helper.absolutePos(pos);
+ helper.setBlock(pos, Blocks.AIR);
+ helper.setBlock(pos.above(), Blocks.AIR);
+ helper.setBlock(pos.above(2), Blocks.GLOWSTONE);
+ helper.runAfterDelay(5, () -> {
+ for (var block : new net.minecraft.world.level.block.Block[]{Blocks.GRASS_BLOCK, Blocks.MUD, Blocks.MUDDY_MANGROVE_ROOTS}) {
+ helper.setBlock(pos.below(), block);
+ check(helper, SpawnPlacements.isSpawnPositionOk(type, level, absolute)
+ && SpawnPlacements.checkSpawnRules(type, level, EntitySpawnReason.NATURAL, absolute, level.getRandom()),
+ "Natural spawning must accept " + block);
+ }
+ helper.setBlock(pos.below(), Blocks.STONE);
+ check(helper, !SpawnPlacements.checkSpawnRules(type, level, EntitySpawnReason.NATURAL, absolute, level.getRandom()),
+ "Natural spawning must reject stone");
+ helper.setBlock(pos.below(), Blocks.MUD);
+ helper.setBlock(pos, Blocks.WATER);
+ check(helper, !SpawnPlacements.isSpawnPositionOk(type, level, absolute), "Natural spawning must reject water");
+ helper.setBlock(pos, Blocks.STONE);
+ check(helper, !SpawnPlacements.isSpawnPositionOk(type, level, absolute), "Natural spawning must reject obstructed space");
+ helper.succeed();
+ });
+ }
+}
diff --git a/src/hfas/gametest/java/org/polaris2023/wildwind/hfas/test/SkeletonAndFarmlandGameTests.java b/src/hfas/gametest/java/org/polaris2023/wildwind/hfas/test/SkeletonAndFarmlandGameTests.java
new file mode 100644
index 0000000..bec52e5
--- /dev/null
+++ b/src/hfas/gametest/java/org/polaris2023/wildwind/hfas/test/SkeletonAndFarmlandGameTests.java
@@ -0,0 +1,156 @@
+package org.polaris2023.wildwind.hfas.test;
+
+import java.util.List;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.Holder;
+import net.minecraft.core.component.DataComponents;
+import net.minecraft.core.registries.BuiltInRegistries;
+import net.minecraft.core.registries.Registries;
+import net.minecraft.gametest.framework.GameTestHelper;
+import net.minecraft.network.chat.Component;
+import net.minecraft.world.effect.MobEffect;
+import net.minecraft.world.effect.MobEffectInstance;
+import net.minecraft.world.effect.MobEffects;
+import net.minecraft.world.entity.EntitySpawnReason;
+import net.minecraft.world.entity.EntityTypes;
+import net.minecraft.world.entity.EquipmentSlot;
+import net.minecraft.world.entity.ai.goal.MeleeAttackGoal;
+import net.minecraft.world.entity.monster.skeleton.AbstractSkeleton;
+import net.minecraft.world.item.CreativeModeTab;
+import net.minecraft.world.item.CreativeModeTabs;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.Items;
+import net.minecraft.world.item.alchemy.PotionContents;
+import net.minecraft.world.item.enchantment.Enchantments;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.level.block.FarmlandBlock;
+import org.polaris2023.wildwind.hfas.HFASMod;
+import org.polaris2023.wildwind.hfas.block.ModBlocks;
+import org.polaris2023.wildwind.hfas.registry.ModCreativeTabs;
+import org.polaris2023.wildwind.hfas.registry.ModItems;
+import org.polaris2023.wildwind.hfas.registry.ModPotions;
+
+final class SkeletonAndFarmlandGameTests {
+ private SkeletonAndFarmlandGameTests() {}
+
+ private static void check(GameTestHelper h, boolean ok, String message) {
+ h.assertTrue(ok, Component.literal(message));
+ }
+
+ static void skeletonSpawns(GameTestHelper h) {
+ var level = h.getLevel();
+ level.getRandom().setSeed(428136L);
+ for (var type : List.of(EntityTypes.SKELETON, EntityTypes.STRAY, EntityTypes.BOGGED, EntityTypes.PARCHED)) {
+ int swords = 0;
+ for (int i = 0; i < 200; i++) {
+ // Use the real initialization path used by spawn eggs and newly spawned mobs.
+ var skeleton = type.create(level, null, h.absolutePos(new BlockPos(2, 2, 2)), EntitySpawnReason.NATURAL, false, false);
+ check(h, skeleton != null, "Skeleton variant must initialize");
+ var weapon = skeleton.getMainHandItem();
+ check(h, weapon.is(Items.BOW) || weapon.is(Items.STONE_SWORD), "Spawn weapon must be bow or stone sword");
+ boolean melee = skeleton.getGoalSelector().getAvailableGoals().stream()
+ .anyMatch(goal -> goal.getGoal() instanceof MeleeAttackGoal);
+ check(h, melee == weapon.is(Items.STONE_SWORD), "Weapon must select the matching combat AI");
+ if (weapon.is(Items.STONE_SWORD)) swords++;
+ skeleton.discard();
+ }
+ check(h, swords >= 5 && swords <= 45, "Fixed-seed spawn sample must contain roughly ten percent swords: " + type + " = " + swords);
+ }
+ var wither = EntityTypes.WITHER_SKELETON.create(level, null, h.absolutePos(new BlockPos(2, 2, 2)), EntitySpawnReason.NATURAL, false, false);
+ check(h, wither.getMainHandItem().is(Items.STONE_SWORD), "Wither skeleton must retain its vanilla equipment");
+ wither.discard();
+ h.succeed();
+ }
+
+ static void skeletonMelee(GameTestHelper h) {
+ checkHit(h, h.spawn(EntityTypes.STRAY, 2, 2, 2), MobEffects.SLOWNESS, 600);
+ checkHit(h, h.spawn(EntityTypes.BOGGED, 3, 2, 2), MobEffects.POISON, 80);
+ checkHit(h, h.spawn(EntityTypes.PARCHED, 4, 2, 2), MobEffects.WEAKNESS, 600);
+ var skeleton = h.spawn(EntityTypes.SKELETON, 5, 2, 2);
+ skeleton.setNoAi(true);
+ skeleton.setItemSlot(EquipmentSlot.MAINHAND, new ItemStack(Items.STONE_SWORD));
+ var target = h.spawn(EntityTypes.COW, 5, 2, 4);
+ target.setNoAi(true);
+ check(h, skeleton.doHurtTarget(h.getLevel(), target) && target.getActiveEffects().isEmpty(), "Normal skeleton melee must not add a special effect");
+ h.succeed();
+ }
+
+ private static void checkHit(GameTestHelper h, AbstractSkeleton skeleton, Holder effect, int ticks) {
+ skeleton.setNoAi(true);
+ skeleton.setItemSlot(EquipmentSlot.MAINHAND, new ItemStack(Items.STONE_SWORD));
+ var target = h.spawn(EntityTypes.COW, 2, 2, 5);
+ target.setNoAi(true);
+ check(h, skeleton.doHurtTarget(h.getLevel(), target), "Stone sword must successfully damage target");
+ var applied = target.getEffect(effect);
+ check(h, applied != null && applied.getDuration() == ticks && applied.getAmplifier() == 0,
+ "Melee must apply exactly the requested duration and level");
+ var invulnerable = h.spawn(EntityTypes.COW, 3, 2, 5);
+ invulnerable.setNoAi(true);
+ invulnerable.setInvulnerable(true);
+ check(h, !skeleton.doHurtTarget(h.getLevel(), invulnerable) && !invulnerable.hasEffect(effect),
+ "Failed melee hits must not apply an effect");
+ }
+
+ static void farmland(GameTestHelper h) {
+ var level = h.getLevel();
+ var cow = h.spawn(EntityTypes.COW, 2, 2, 2);
+ cow.setNoAi(true);
+ var pos = h.absolutePos(new BlockPos(3, 1, 3));
+ var farmland = (FarmlandBlock) Blocks.FARMLAND;
+ var featherFalling = level.registryAccess().lookupOrThrow(Registries.ENCHANTMENT).getOrThrow(Enchantments.FEATHER_FALLING);
+ var boots = new ItemStack(Items.DIAMOND_BOOTS);
+ boots.enchant(featherFalling, 1);
+ cow.setItemSlot(EquipmentSlot.FEET, boots);
+ level.setBlockAndUpdate(pos, Blocks.FARMLAND.defaultBlockState());
+ farmland.fallOn(level, level.getBlockState(pos), pos, cow, 6);
+ check(h, level.getBlockState(pos).is(Blocks.FARMLAND), "Worn Feather Falling I must protect farmland for non-player entities");
+ check(h, cow.getHealth() < cow.getMaxHealth(), "Farmland protection must not cancel remaining fall damage");
+ cow.setItemSlot(EquipmentSlot.FEET, ItemStack.EMPTY);
+ cow.setItemSlot(EquipmentSlot.MAINHAND, boots);
+ farmland.fallOn(level, level.getBlockState(pos), pos, cow, 6);
+ check(h, level.getBlockState(pos).is(Blocks.DIRT), "Holding enchanted armor must not protect farmland");
+ level.setBlockAndUpdate(pos, Blocks.FARMLAND.defaultBlockState());
+ cow.addEffect(new MobEffectInstance(MobEffects.SLOW_FALLING, 200));
+ farmland.fallOn(level, level.getBlockState(pos), pos, cow, 6);
+ check(h, level.getBlockState(pos).is(Blocks.FARMLAND), "Slow Falling must protect farmland without worn armor");
+ cow.removeEffect(MobEffects.SLOW_FALLING);
+ farmland.fallOn(level, level.getBlockState(pos), pos, cow, 6);
+ check(h, level.getBlockState(pos).is(Blocks.DIRT), "Removing protection must restore trampling");
+ h.succeed();
+ }
+
+ static void creativeTab(GameTestHelper h) {
+ var params = new CreativeModeTab.ItemDisplayParameters(h.getLevel().enabledFeatures(), true, h.getLevel().registryAccess());
+ var tab = ModCreativeTabs.WILD_WIND.get();
+ tab.buildContents(params);
+ for (var register : List.of(ModBlocks.ITEMS, ModItems.ITEMS)) {
+ for (var item : register.getEntries()) {
+ check(h, tab.getDisplayItems().stream().filter(stack -> stack.is(item.get())).count() == (item == ModItems.TIPPED_ARROW ? 2 : 1),
+ "Every registered mod item/arrow variant must appear once in its own tab: " + item.getId());
+ }
+ }
+ for (var potion : ModPotions.POTIONS.getEntries()) {
+ for (var item : List.of(Items.POTION, Items.SPLASH_POTION, Items.LINGERING_POTION, Items.TIPPED_ARROW)) {
+ check(h, tab.contains(org.polaris2023.wildwind.hfas.arrow.ReachArrows.convert(PotionContents.createItemStack(item, potion))), "Mod tab must include each Reach potion and arrow variant");
+ }
+ }
+ for (var key : List.of(CreativeModeTabs.BUILDING_BLOCKS, CreativeModeTabs.NATURAL_BLOCKS,
+ CreativeModeTabs.INGREDIENTS, CreativeModeTabs.SPAWN_EGGS, CreativeModeTabs.FOOD_AND_DRINKS, CreativeModeTabs.COMBAT)) {
+ var vanilla = BuiltInRegistries.CREATIVE_MODE_TAB.getValueOrThrow(key);
+ vanilla.buildContents(params);
+ check(h, vanilla.getDisplayItems().stream().noneMatch(stack ->
+ BuiltInRegistries.ITEM.getKey(stack.getItem()).getNamespace().equals(HFASMod.MOD_ID)),
+ "Vanilla categories must not contain mod items");
+ check(h, vanilla.getDisplayItems().stream().noneMatch(stack -> {
+ var contents = stack.get(DataComponents.POTION_CONTENTS);
+ return contents != null && contents.potion().isPresent()
+ && contents.potion().get().unwrapKey().orElseThrow().identifier().getNamespace().equals(HFASMod.MOD_ID);
+ }), "Vanilla categories must not duplicate mod potions");
+ }
+ CreativeModeTabs.searchTab().buildContents(params);
+ check(h, CreativeModeTabs.searchTab().contains(ModItems.MUDDY_PIG_SPAWN_EGG.toStack())
+ && CreativeModeTabs.searchTab().contains(PotionContents.createItemStack(Items.POTION, ModPotions.REACH)),
+ "Global creative search must still discover mod contents");
+ h.succeed();
+ }
+}
diff --git a/src/hfas/gametest/java/org/polaris2023/wildwind/hfas/test/UpstreamIntegrationGameTests.java b/src/hfas/gametest/java/org/polaris2023/wildwind/hfas/test/UpstreamIntegrationGameTests.java
new file mode 100644
index 0000000..ff3b748
--- /dev/null
+++ b/src/hfas/gametest/java/org/polaris2023/wildwind/hfas/test/UpstreamIntegrationGameTests.java
@@ -0,0 +1,84 @@
+package org.polaris2023.wildwind.hfas.test;
+
+import java.util.List;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.component.DataComponents;
+import net.minecraft.core.registries.Registries;
+import net.minecraft.gametest.framework.GameTestHelper;
+import net.minecraft.network.chat.Component;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.world.InteractionHand;
+import net.minecraft.world.effect.MobEffectInstance;
+import net.minecraft.world.entity.item.ItemEntity;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.Items;
+import net.minecraft.world.item.crafting.RecipeType;
+import net.minecraft.world.item.crafting.SingleRecipeInput;
+import net.minecraft.world.level.GameType;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.phys.AABB;
+import net.minecraft.world.phys.Vec3;
+import org.polaris2023.wildwind.hfas.component.OmniClawTools;
+import org.polaris2023.wildwind.hfas.enchantment.MeltingEnchantment;
+import org.polaris2023.wildwind.hfas.entity.animal.MudcrabVariant;
+import org.polaris2023.wildwind.hfas.registry.*;
+
+final class UpstreamIntegrationGameTests {
+ private UpstreamIntegrationGameTests() {}
+
+ private static void check(GameTestHelper h, boolean ok, String message) {
+ h.assertTrue(ok, Component.literal(message));
+ }
+
+ static void run(GameTestHelper h) {
+ var level = h.getLevel();
+ var crab = h.spawn(ModEntities.MUDCRAB.get(), 2, 2, 2);
+ crab.setNoAi(true);
+ check(h, crab.getMaxHealth() == 10 && crab.getVariant() != null,
+ "Merged mudcrab must spawn with attributes and a registered variant");
+ check(h, level.registryAccess().lookupOrThrow(DatadrivenRegistryKey.MUDCRAB_VARIANT).size() == 3,
+ "All three mudcrab variants must be present");
+ crab.setVariant(MudcrabVariant.lookup(level.registryAccess(), MudcrabVariant.WARM));
+ check(h, crab.getVariant().is(MudcrabVariant.WARM)
+ && crab.getBucketItemStack().is(ModItems.MUDCRAB_BUCKET.get()), "Mudcrab variant and bucket must work");
+ var fish = h.spawn(ModEntities.PIRANHA.get(), 4, 2, 2);
+ fish.setNoAi(true);
+ check(h, fish.getBucketItemStack().is(ModItems.PIRANHA_BUCKET.get()), "Piranha bucket must remain registered");
+ var cooking = level.recipeAccess().getRecipeFor(RecipeType.SMELTING,
+ new SingleRecipeInput(ModItems.PIRANHA.toStack()), level);
+ check(h, cooking.isPresent() && cooking.get().value().assemble(new SingleRecipeInput(ModItems.PIRANHA.toStack()))
+ .is(ModItems.COOKED_PIRANHA.get()), "Upstream piranha smelting recipe must load");
+ for (var material : ModItems.MATERIALS) {
+ check(h, !material.toStack().has(DataComponents.FOOD), "The seven local materials must stay ordinary items");
+ }
+ check(h, !ModPotions.REACH.value().getEffects().isEmpty(), "Upstream Reach potion must remain registered");
+
+ var player = (ServerPlayer) h.makeMockServerPlayer(GameType.SURVIVAL);
+ var connection = new net.minecraft.network.Connection(net.minecraft.network.protocol.PacketFlow.SERVERBOUND);
+ new io.netty.channel.embedded.EmbeddedChannel(connection);
+ level.getServer().getPlayerList().placeNewPlayer(connection, player,
+ net.minecraft.server.network.CommonListenerCookie.createInitial(player.getGameProfile(), false));
+ player.gameMode.changeGameModeForPlayer(GameType.SURVIVAL);
+ player.setPos(h.absoluteVec(new Vec3(6.5, 2, 6.5)));
+ player.addEffect(new MobEffectInstance(ModMobEffects.REACH, 200));
+ check(h, player.getAttributeValue(ModAttributes.EXTRA_ITEM_PICKUP_RANGE) == 2,
+ "Upstream Reach attribute must work with merged player code");
+ player.removeEffect(ModMobEffects.REACH);
+
+ var pickaxe = new ItemStack(Items.DIAMOND_PICKAXE);
+ pickaxe.enchant(level.registryAccess().lookupOrThrow(Registries.ENCHANTMENT).getOrThrow(MeltingEnchantment.MELTING), 1);
+ var claw = ModItems.OMNI_CLAW.toStack();
+ claw.set(ModDataComponents.OMNI_CLAW_TOOLS.get(), new OmniClawTools(List.of(pickaxe), 0));
+ player.setItemInHand(InteractionHand.MAIN_HAND, claw);
+ player.setExperienceLevels(1);
+ player.setExperiencePoints(0);
+ var pos = new BlockPos(5, 2, 5);
+ h.setBlock(pos, Blocks.IRON_ORE);
+ check(h, player.gameMode.destroyBlock(h.absolutePos(pos)), "Omni Claw must break a real block through the player path");
+ check(h, MeltingEnchantment.availableExperience(player) == 6,
+ "Melting on an Omni Claw's internal pickaxe must consume exactly one XP point");
+ check(h, level.getEntitiesOfClass(ItemEntity.class, new AABB(h.absolutePos(pos)).inflate(1)).stream()
+ .anyMatch(e -> e.getItem().is(Items.IRON_INGOT)), "Omni Claw and Melting must produce smelted loot together");
+ h.succeed();
+ }
+}
diff --git a/src/hfas/gametest/resources/META-INF/neoforge.mods.toml b/src/hfas/gametest/resources/META-INF/neoforge.mods.toml
new file mode 100644
index 0000000..f27c8a3
--- /dev/null
+++ b/src/hfas/gametest/resources/META-INF/neoforge.mods.toml
@@ -0,0 +1,14 @@
+license="All Rights Reserved"
+[[mods]]
+modId="ww_hfas_tests"
+version="1.0"
+displayName="Wild Wind Integration Tests"
+[[dependencies.ww_hfas_tests]]
+modId="ww_hfas"
+type="required"
+versionRange="[1.0.0,)"
+ordering="AFTER"
+side="BOTH"
+
+[[mixins]]
+config="ww_hfas_tests.mixins.json"
diff --git a/src/hfas/gametest/resources/data/ww_hfas_tests/structure/empty.nbt b/src/hfas/gametest/resources/data/ww_hfas_tests/structure/empty.nbt
new file mode 100644
index 0000000000000000000000000000000000000000..1b63d36657f1a88c48210084cfcced954ce71090
GIT binary patch
literal 113
zcmV-%0FM73iwFP!00002|0T+?3V<*W1JH;?TgAoCadvieKjnl&t
z8lXfwlG4l}%H@InbudvLE|EvTrc}8d>YEN$ new FletchingTableMenu(containerId, playerInventory),
+ CONTAINER_TITLE
+ );
+ }
+
+ @Nullable
+ @Override
+ public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
+ return new FletchingTableBlockEntity(pos, state);
+ }
+}
diff --git a/src/hfas/java/org/polaris2023/wildwind/hfas/block/KilnBlock.java b/src/hfas/java/org/polaris2023/wildwind/hfas/block/KilnBlock.java
new file mode 100644
index 0000000..7f7768e
--- /dev/null
+++ b/src/hfas/java/org/polaris2023/wildwind/hfas/block/KilnBlock.java
@@ -0,0 +1,59 @@
+package org.polaris2023.wildwind.hfas.block;
+
+import com.mojang.serialization.MapCodec;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.Direction;
+import net.minecraft.core.particles.ParticleTypes;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.sounds.SoundEvents;
+import net.minecraft.sounds.SoundSource;
+import net.minecraft.util.RandomSource;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.level.Level;
+import net.minecraft.world.level.block.AbstractFurnaceBlock;
+import net.minecraft.world.level.block.entity.BlockEntity;
+import net.minecraft.world.level.block.entity.BlockEntityTicker;
+import net.minecraft.world.level.block.entity.BlockEntityType;
+import net.minecraft.world.level.block.state.BlockState;
+import org.jspecify.annotations.Nullable;
+import org.polaris2023.wildwind.hfas.block.entity.KilnBlockEntity;
+import org.polaris2023.wildwind.hfas.block.entity.ModBlockEntities;
+
+public class KilnBlock extends AbstractFurnaceBlock {
+ public static final MapCodec CODEC = simpleCodec(KilnBlock::new);
+
+ public KilnBlock(Properties properties) { super(properties); }
+
+ @Override
+ public MapCodec codec() { return CODEC; }
+
+ @Override
+ public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { return new KilnBlockEntity(pos, state); }
+
+ @Override
+ public @Nullable BlockEntityTicker getTicker(Level level, BlockState state, BlockEntityType type) {
+ return level instanceof ServerLevel server ? createTickerHelper(type, ModBlockEntities.KILN.get(),
+ (ignored, pos, blockState, kiln) -> KilnBlockEntity.tick(server, pos, blockState, kiln)) : null;
+ }
+
+ @Override
+ protected void openContainer(Level level, BlockPos pos, Player player) {
+ if (level.getBlockEntity(pos) instanceof KilnBlockEntity kiln) player.openMenu(kiln);
+ }
+
+ @Override
+ public void animateTick(BlockState state, Level level, BlockPos pos, RandomSource random) {
+ if (!state.getValue(LIT)) return;
+ double x = pos.getX() + 0.5, y = pos.getY(), z = pos.getZ() + 0.5;
+ if (random.nextDouble() < 0.1) {
+ level.playLocalSound(x, y, z, SoundEvents.FURNACE_FIRE_CRACKLE, SoundSource.BLOCKS, 1, 1, false);
+ }
+ Direction facing = state.getValue(FACING);
+ double offset = random.nextDouble() * 0.6 - 0.3;
+ double dx = facing.getAxis() == Direction.Axis.X ? facing.getStepX() * 0.52 : offset;
+ double dz = facing.getAxis() == Direction.Axis.Z ? facing.getStepZ() * 0.52 : offset;
+ double dy = random.nextDouble() * 6 / 16;
+ level.addParticle(ParticleTypes.SMOKE, x + dx, y + dy, z + dz, 0, 0, 0);
+ level.addParticle(ParticleTypes.FLAME, x + dx, y + dy, z + dz, 0, 0, 0);
+ }
+}
diff --git a/src/hfas/java/org/polaris2023/wildwind/hfas/block/ModBlocks.java b/src/hfas/java/org/polaris2023/wildwind/hfas/block/ModBlocks.java
new file mode 100644
index 0000000..c74db5d
--- /dev/null
+++ b/src/hfas/java/org/polaris2023/wildwind/hfas/block/ModBlocks.java
@@ -0,0 +1,611 @@
+package org.polaris2023.wildwind.hfas.block;
+
+import net.minecraft.world.item.BlockItem;
+import net.minecraft.world.item.Item;
+import net.minecraft.world.level.block.*;
+import net.minecraft.world.level.block.state.BlockBehaviour;
+import net.minecraft.world.level.material.MapColor;
+import net.minecraft.world.level.material.PushReaction;
+import net.minecraft.world.level.block.state.properties.BlockSetType;
+import net.minecraft.world.level.block.state.properties.WoodType;
+import net.neoforged.bus.api.IEventBus;
+import net.neoforged.neoforge.registries.DeferredBlock;
+import net.neoforged.neoforge.registries.DeferredItem;
+import net.neoforged.neoforge.registries.DeferredRegister;
+import org.polaris2023.wildwind.hfas.HFASMod;
+import org.polaris2023.wildwind.hfas.worldgen.ModTreeGrowers;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Supplier;
+
+/**
+ * 方块注册类
+ * 使用NeoForge 26.1的DeferredRegister方式注册方块
+ * 注意:方块和物品分开注册,避免延迟注册bug
+ *
+ * @author baka4n
+ * @since 2026/04/15
+ */
+//TODO 考虑迁移至registry目录下,或将registry目录下的注册条目分散到各目录下
+public class ModBlocks {
+ // 方块注册器
+ public static final DeferredRegister.Blocks BLOCKS = DeferredRegister.createBlocks(HFASMod.MOD_ID);
+ // 物品注册器
+ public static final DeferredRegister.Items ITEMS = DeferredRegister.createItems(HFASMod.MOD_ID);
+
+ public static final DeferredBlock KILN = BLOCKS.registerBlock("kiln", p -> new KilnBlock(p
+ .mapColor(MapColor.STONE).instrument(net.minecraft.world.level.block.state.properties.NoteBlockInstrument.BASEDRUM)
+ .requiresCorrectToolForDrops().strength(3.5F, 3.5F).sound(SoundType.STONE)
+ .lightLevel(state -> state.getValue(KilnBlock.LIT) ? 13 : 0).pushReaction(PushReaction.BLOCK)));
+
+ // ==================== 方块类型定义 ====================
+
+ // 焚烬木方块类型
+ public static final BlockSetType BLAZE_BLOCK_SET = BlockSetType.register(new BlockSetType("blaze"));
+ // 焚烬木木材类型
+ public static final WoodType BLAZE_WOOD_TYPE = WoodType.register(new WoodType("blaze", BLAZE_BLOCK_SET));
+
+ // 灵焰木方块类型
+ public static final BlockSetType SOUL_BLOCK_SET = BlockSetType.register(new BlockSetType("soul"));
+ // 灵焰木木材类型
+ public static final WoodType SOUL_WOOD_TYPE = WoodType.register(new WoodType("soul", SOUL_BLOCK_SET));
+
+ // 杜鹃木方块类型
+ public static final BlockSetType AZALEA_BLOCK_SET = BlockSetType.register(new BlockSetType("azalea"));
+ // 杜鹃木木材类型
+ public static final WoodType AZALEA_WOOD_TYPE = WoodType.register(new WoodType("ww_hfas:azalea", AZALEA_BLOCK_SET));
+
+ // ==================== 焚烬木套件 (blaze_wood) ====================
+
+ // 焚烬木原木
+ public static final DeferredBlock BLAZE_LOG = BLOCKS.registerBlock(
+ "blaze_log",
+ p -> new RotatedPillarBlock(p
+ .mapColor(MapColor.COLOR_ORANGE)
+ .strength(2.0F)
+ .sound(SoundType.WOOD))
+ );
+
+ // 焚烬木
+ public static final DeferredBlock BLAZE_WOOD = BLOCKS.registerBlock(
+ "blaze_wood",
+ p -> new RotatedPillarBlock(p
+ .mapColor(MapColor.COLOR_ORANGE)
+ .strength(2.0F)
+ .sound(SoundType.WOOD))
+ );
+
+ // 去皮焚烬木原木
+ public static final DeferredBlock STRIPPED_BLAZE_LOG = BLOCKS.registerBlock(
+ "stripped_blaze_log",
+ p -> new RotatedPillarBlock(p
+ .mapColor(MapColor.COLOR_ORANGE)
+ .strength(2.0F)
+ .sound(SoundType.WOOD))
+ );
+
+ // 去皮焚烬木
+ public static final DeferredBlock STRIPPED_BLAZE_WOOD = BLOCKS.registerBlock(
+ "stripped_blaze_wood",
+ p -> new RotatedPillarBlock(p
+ .mapColor(MapColor.COLOR_ORANGE)
+ .strength(2.0F)
+ .sound(SoundType.WOOD))
+ );
+
+ // 焚烬木木板
+ public static final DeferredBlock BLAZE_PLANKS = BLOCKS.registerBlock(
+ "blaze_planks",
+ p -> new Block(p
+ .mapColor(MapColor.COLOR_ORANGE)
+ .strength(2.0F)
+ .sound(SoundType.WOOD))
+ );
+
+ // 焚烬木楼梯
+ public static final DeferredBlock BLAZE_STAIRS = BLOCKS.registerBlock(
+ "blaze_stairs",
+ p -> new StairBlock(BLAZE_PLANKS.get().defaultBlockState(), p
+ .mapColor(MapColor.COLOR_ORANGE)
+ .strength(2.0F)
+ .sound(SoundType.WOOD))
+ );
+
+ // 焚烬木台阶
+ public static final DeferredBlock BLAZE_SLAB = BLOCKS.registerBlock(
+ "blaze_slab",
+ p -> new SlabBlock(p
+ .mapColor(MapColor.COLOR_ORANGE)
+ .strength(2.0F)
+ .sound(SoundType.WOOD))
+ );
+
+ // 焚烬木栅栏
+ public static final DeferredBlock BLAZE_FENCE = BLOCKS.registerBlock(
+ "blaze_fence",
+ p -> new FenceBlock(p
+ .mapColor(MapColor.COLOR_ORANGE)
+ .strength(2.0F)
+ .sound(SoundType.WOOD))
+ );
+
+ // 焚烬木栅栏门
+ public static final DeferredBlock BLAZE_FENCE_GATE = BLOCKS.registerBlock(
+ "blaze_fence_gate",
+ p -> new FenceGateBlock(BLAZE_WOOD_TYPE, p
+ .mapColor(MapColor.COLOR_ORANGE)
+ .strength(2.0F)
+ .sound(SoundType.WOOD))
+ );
+
+ // 焚烬木门
+ public static final DeferredBlock BLAZE_DOOR = BLOCKS.registerBlock(
+ "blaze_door",
+ p -> new DoorBlock(BLAZE_BLOCK_SET, p
+ .mapColor(MapColor.COLOR_ORANGE)
+ .strength(2.0F)
+ .sound(SoundType.WOOD))
+ );
+
+ // 焚烬木活板门
+ public static final DeferredBlock BLAZE_TRAPDOOR = BLOCKS.registerBlock(
+ "blaze_trapdoor",
+ p -> new TrapDoorBlock(BLAZE_BLOCK_SET, p
+ .mapColor(MapColor.COLOR_ORANGE)
+ .strength(2.0F)
+ .sound(SoundType.WOOD))
+ );
+
+ // 焚烬木压力板
+ public static final DeferredBlock BLAZE_PRESSURE_PLATE = BLOCKS.registerBlock(
+ "blaze_pressure_plate",
+ p -> new PressurePlateBlock(BLAZE_BLOCK_SET, p
+ .mapColor(MapColor.COLOR_ORANGE)
+ .strength(0.5F)
+ .sound(SoundType.WOOD))
+ );
+
+ // 焚烬木按钮
+ public static final DeferredBlock BLAZE_BUTTON = BLOCKS.registerBlock(
+ "blaze_button",
+ p -> new ButtonBlock(BLAZE_BLOCK_SET, 30, p
+ .mapColor(MapColor.COLOR_ORANGE)
+ .strength(0.5F)
+ .sound(SoundType.WOOD))
+ );
+
+ // 焚烬木树叶
+ public static final DeferredBlock BLAZE_LEAVES = BLOCKS.registerBlock(
+ "blaze_leaves",
+ p -> new TintedParticleLeavesBlock(0.01F,p
+ .mapColor(MapColor.COLOR_ORANGE)
+ .strength(0.2F)
+ .sound(SoundType.GRASS)
+ .noOcclusion()
+ .randomTicks())
+ );
+
+ // 焚烬木树苗
+ public static final DeferredBlock BLAZE_SAPLING = BLOCKS.registerBlock(
+ "blaze_sapling",
+ p -> new SaplingBlock(ModTreeGrowers.BLAZE, p
+ .mapColor(MapColor.COLOR_ORANGE)
+ .noCollision()
+ .instabreak()
+ .sound(SoundType.GRASS)
+ .randomTicks())
+ );
+
+ // ==================== 灵焰木套件 (soul_wood) ====================
+
+ // 灵焰木原木
+ public static final DeferredBlock SOUL_LOG = BLOCKS.registerBlock(
+ "soul_log",
+ p -> new RotatedPillarBlock(p
+ .mapColor(MapColor.COLOR_CYAN)
+ .strength(2.0F)
+ .sound(SoundType.WOOD))
+ );
+
+ // 灵焰木
+ public static final DeferredBlock SOUL_WOOD = BLOCKS.registerBlock(
+ "soul_wood",
+ p -> new RotatedPillarBlock(p
+ .mapColor(MapColor.COLOR_CYAN)
+ .strength(2.0F)
+ .sound(SoundType.WOOD))
+ );
+
+ // 去皮灵焰木原木
+ public static final DeferredBlock STRIPPED_SOUL_LOG = BLOCKS.registerBlock(
+ "stripped_soul_log",
+ p -> new RotatedPillarBlock(p
+ .mapColor(MapColor.COLOR_CYAN)
+ .strength(2.0F)
+ .sound(SoundType.WOOD))
+ );
+
+ // 去皮灵焰木
+ public static final DeferredBlock STRIPPED_SOUL_WOOD = BLOCKS.registerBlock(
+ "stripped_soul_wood",
+ p -> new RotatedPillarBlock(p
+ .mapColor(MapColor.COLOR_CYAN)
+ .strength(2.0F)
+ .sound(SoundType.WOOD))
+ );
+
+ // 灵焰木木板
+ public static final DeferredBlock SOUL_PLANKS = BLOCKS.registerBlock(
+ "soul_planks",
+ p -> new Block(p
+ .mapColor(MapColor.COLOR_CYAN)
+ .strength(2.0F)
+ .sound(SoundType.WOOD))
+ );
+
+ // 灵焰木楼梯
+ public static final DeferredBlock