diff --git a/src/datagen/java/ca/teamdman/sfm/datagen/SFMBlockStatesAndModelsDatagen.java b/src/datagen/java/ca/teamdman/sfm/datagen/SFMBlockStatesAndModelsDatagen.java index db483ef8c..19e093f14 100644 --- a/src/datagen/java/ca/teamdman/sfm/datagen/SFMBlockStatesAndModelsDatagen.java +++ b/src/datagen/java/ca/teamdman/sfm/datagen/SFMBlockStatesAndModelsDatagen.java @@ -53,6 +53,7 @@ protected void registerStatesAndModels() { registerWaterTank(); registerTestBarrel(); registerBuffer(); + registerLibrary(); } private void registerTestBarrel() { @@ -283,4 +284,36 @@ private void registerBuffer() { }); } + + private void registerLibrary() { + if (SFMBlocks.LIBRARY_BLOCK == null) return; + + // Create a model with different textures for front, back, and sides + ModelFile libraryModel = models().cube( + SFMBlocks.LIBRARY_BLOCK.getPath(), + modLoc("block/library_bot"), // down + modLoc("block/library_top"), // up + modLoc("block/library_front"), // north (front) + modLoc("block/library_back"), // south (back) + modLoc("block/library_side"), // west + modLoc("block/library_side") // east + ).texture("particle", modLoc("block/library_top")); + + // Create variants for each horizontal facing direction + getVariantBuilder(SFMBlocks.LIBRARY_BLOCK.get()) + .forAllStates(state -> { + Direction facing = state.getValue(ca.teamdman.sfm.common.block.LibraryBlock.FACING); + int yRot = switch (facing) { + case NORTH -> 0; + case EAST -> 90; + case SOUTH -> 180; + case WEST -> 270; + default -> 0; + }; + return ConfiguredModel.builder() + .modelFile(libraryModel) + .rotationY(yRot) + .build(); + }); + } } diff --git a/src/datagen/java/ca/teamdman/sfm/datagen/SFMItemModelsDatagen.java b/src/datagen/java/ca/teamdman/sfm/datagen/SFMItemModelsDatagen.java index dd8137cc7..d2e353f2c 100644 --- a/src/datagen/java/ca/teamdman/sfm/datagen/SFMItemModelsDatagen.java +++ b/src/datagen/java/ca/teamdman/sfm/datagen/SFMItemModelsDatagen.java @@ -39,6 +39,9 @@ protected void registerModels() { justParent(SFMItems.PRINTING_PRESS_ITEM, SFMBlocks.PRINTING_PRESS_BLOCK); justParent(SFMItems.WATER_TANK_ITEM, SFMBlocks.WATER_TANK_BLOCK, "_active"); justParent(SFMItems.BUFFER_ITEM, SFMBlocks.BUFFER_BLOCK, "_item"); + if (SFMItems.LIBRARY_ITEM != null) { + justParent(SFMItems.LIBRARY_ITEM, SFMBlocks.LIBRARY_BLOCK); + } basicItem(SFMItems.DISK_ITEM); basicItem(SFMItems.LABEL_GUN_ITEM); basicItem(SFMItems.EXPERIENCE_GOOP_ITEM); diff --git a/src/datagen/java/ca/teamdman/sfm/datagen/SFMLootTablesDatagen.java b/src/datagen/java/ca/teamdman/sfm/datagen/SFMLootTablesDatagen.java index 4e4ff656d..2981841af 100644 --- a/src/datagen/java/ca/teamdman/sfm/datagen/SFMLootTablesDatagen.java +++ b/src/datagen/java/ca/teamdman/sfm/datagen/SFMLootTablesDatagen.java @@ -40,6 +40,9 @@ protected void populate(BlockLootWriter writer) { writer.dropOther(SFMBlocks.FANCY_CABLE_FACADE_BLOCK, SFMBlocks.FANCY_CABLE_BLOCK); writer.dropSelf(SFMBlocks.PRINTING_PRESS_BLOCK); writer.dropSelf(SFMBlocks.WATER_TANK_BLOCK); + if (SFMBlocks.LIBRARY_BLOCK != null) { + writer.dropSelf(SFMBlocks.LIBRARY_BLOCK); + } } @Override diff --git a/src/datagen/java/ca/teamdman/sfm/datagen/SFMRecipesDatagen.java b/src/datagen/java/ca/teamdman/sfm/datagen/SFMRecipesDatagen.java index 1bd1df15e..e6dd6faa3 100644 --- a/src/datagen/java/ca/teamdman/sfm/datagen/SFMRecipesDatagen.java +++ b/src/datagen/java/ca/teamdman/sfm/datagen/SFMRecipesDatagen.java @@ -5,6 +5,7 @@ import ca.teamdman.sfm.common.registry.SFMBlocks; import ca.teamdman.sfm.common.registry.SFMItems; import ca.teamdman.sfm.common.registry.SFMRecipeSerializers; +import ca.teamdman.sfm.common.util.SFMEnvironmentUtils; import ca.teamdman.sfm.common.util.SFMResourceLocation; import ca.teamdman.sfm.datagen.version_plumbing.MCVersionAgnosticRecipeDataGen; import net.minecraft.data.recipes.FinishedRecipe; @@ -196,6 +197,18 @@ protected void populate(Consumer writer) { .pattern("gxg") .save(writer); + if (SFMEnvironmentUtils.isInIDE() && SFMBlocks.LIBRARY_BLOCK != null) { + beginShaped(SFMBlocks.LIBRARY_BLOCK.get(), 1) + .define('M', SFMBlocks.MANAGER_BLOCK.get()) + .define('B', Blocks.BOOKSHELF) + .define('L', Blocks.LECTERN) + .unlockedBy("has_manager", RecipeProvider.has(SFMBlocks.MANAGER_BLOCK.get())) + .pattern("MBM") + .pattern("BLB") + .pattern("MBM") + .save(writer); + } + addPrintingPressRecipe( writer, SFMResourceLocation.fromSFMPath("written_book_copy"), diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/SFMGameTestGenerator.java b/src/gametest/java/ca/teamdman/sfm/gametest/SFMGameTestGenerator.java index 045814e07..db5f0198a 100644 --- a/src/gametest/java/ca/teamdman/sfm/gametest/SFMGameTestGenerator.java +++ b/src/gametest/java/ca/teamdman/sfm/gametest/SFMGameTestGenerator.java @@ -1,15 +1,15 @@ -package ca.teamdman.sfm.gametest; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Classes annotated with this that extend {@link SFMGameTestGeneratorBase} will have their - * {@link SFMGameTestGeneratorBase#generateTests} method invoked to produce game test definitions. - */ -@Retention(RetentionPolicy.RUNTIME) -@Target(ElementType.TYPE) -public @interface SFMGameTestGenerator { -} +package ca.teamdman.sfm.gametest; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Classes annotated with this that extend {@link SFMGameTestGeneratorBase} will have their + * {@link SFMGameTestGeneratorBase#generateTests} method invoked to produce game test definitions. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface SFMGameTestGenerator { +} diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/SFMGameTestGeneratorBase.java b/src/gametest/java/ca/teamdman/sfm/gametest/SFMGameTestGeneratorBase.java index 23bb471e9..742b20a1b 100644 --- a/src/gametest/java/ca/teamdman/sfm/gametest/SFMGameTestGeneratorBase.java +++ b/src/gametest/java/ca/teamdman/sfm/gametest/SFMGameTestGeneratorBase.java @@ -1,18 +1,18 @@ -package ca.teamdman.sfm.gametest; - -import java.util.function.Consumer; - -/** - * Base class for game test generators. Subclasses annotated with - * {@link SFMGameTestGenerator} will have their {@link #generateTests} method - * invoked during test discovery to produce multiple game test definitions. - */ -public abstract class SFMGameTestGeneratorBase { - - /** - * Generates game test definitions and passes them to the provided consumer. - * - * @param testConsumer a consumer that accepts generated test definitions - */ - public abstract void generateTests(Consumer testConsumer); -} +package ca.teamdman.sfm.gametest; + +import java.util.function.Consumer; + +/** + * Base class for game test generators. Subclasses annotated with + * {@link SFMGameTestGenerator} will have their {@link #generateTests} method + * invoked during test discovery to produce multiple game test definitions. + */ +public abstract class SFMGameTestGeneratorBase { + + /** + * Generates game test definitions and passes them to the provided consumer. + * + * @param testConsumer a consumer that accepts generated test definitions + */ + public abstract void generateTests(Consumer testConsumer); +} diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/SFMGameTestHelper.java b/src/gametest/java/ca/teamdman/sfm/gametest/SFMGameTestHelper.java index 7d7090afe..23ef59476 100644 --- a/src/gametest/java/ca/teamdman/sfm/gametest/SFMGameTestHelper.java +++ b/src/gametest/java/ca/teamdman/sfm/gametest/SFMGameTestHelper.java @@ -178,7 +178,7 @@ public void assertExpr( BoolExpr expr = BoolExpr.from(exprString); ProgramContext programContext = new ProgramContext( - new Program(new ASTBuilder(), "temp lol", List.of(), Set.of(), Set.of()), + new Program(new ASTBuilder(), "temp lol", List.of(), List.of(), List.of(), Set.of(), Set.of()), manager, ExecuteProgramBehaviour::new ); diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/declarative/SFMDeclarativeTestBuilder.java b/src/gametest/java/ca/teamdman/sfm/gametest/declarative/SFMDeclarativeTestBuilder.java index 0117d1bc3..9f7d3caf8 100644 --- a/src/gametest/java/ca/teamdman/sfm/gametest/declarative/SFMDeclarativeTestBuilder.java +++ b/src/gametest/java/ca/teamdman/sfm/gametest/declarative/SFMDeclarativeTestBuilder.java @@ -112,7 +112,7 @@ private void runConditions( if (conditions.isEmpty()) return; List expressions = conditions.stream().map(BoolExpr::from).toList(); ProgramContext programContext = new ProgramContext( - new Program(new ASTBuilder(), "temp lol", List.of(), Set.of(), Set.of()), + new Program(new ASTBuilder(), "temp lol", List.of(), List.of(), List.of(), Set.of(), Set.of()), manager, ExecuteProgramBehaviour::new ); diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/tests/cables/CableArrayGameTest.java b/src/gametest/java/ca/teamdman/sfm/gametest/tests/cables/CableArrayGameTest.java index 023ef3ea3..7d76e3b03 100644 --- a/src/gametest/java/ca/teamdman/sfm/gametest/tests/cables/CableArrayGameTest.java +++ b/src/gametest/java/ca/teamdman/sfm/gametest/tests/cables/CableArrayGameTest.java @@ -1,138 +1,138 @@ -package ca.teamdman.sfm.gametest.tests.cables; - -import ca.teamdman.sfm.common.block_network.CableNetwork; -import ca.teamdman.sfm.common.block_network.CableNetworkManager; -import ca.teamdman.sfm.common.blockentity.IFacadeBlockEntity; -import ca.teamdman.sfm.common.facade.FacadeData; -import ca.teamdman.sfm.common.facade.FacadeTextureMode; -import ca.teamdman.sfm.common.registry.SFMBlocks; -import ca.teamdman.sfm.gametest.SFMGameTest; -import ca.teamdman.sfm.gametest.SFMGameTestDefinition; -import ca.teamdman.sfm.gametest.SFMGameTestHelper; -import net.minecraft.core.BlockPos; -import net.minecraft.core.Direction; -import net.minecraft.world.level.block.Block; -import net.minecraft.world.level.block.Blocks; -import org.jetbrains.annotations.NotNull; - -import static ca.teamdman.sfm.gametest.SFMGameTestMethodHelpers.assertTrue; - -@SFMGameTest -public class CableArrayGameTest extends SFMGameTestDefinition { - - @Override - public String template() { - return createVariants().length + "x2x5"; - } - - @Override - public void run(SFMGameTestHelper helper) { - - Variant[] variants = createVariants(); - - // collect absolute positions of all placed cables so we can assert they belong to the same network - java.util.List cablePositions = new java.util.ArrayList<>(); - - for (int x = 0; x < variants.length; x++) { - Variant v = variants[x]; - for (int z = 0; z < 3; z++) { - BlockPos localPos = new BlockPos(x, 2, 1 + z); - helper.setBlock(localPos, v.block); - var absolute = helper.absolutePos(localPos); - - // If this is a facade variant, set the facade data to glowstone - if (v.facade) { - var be = helper.getLevel().getBlockEntity(absolute); - if (be instanceof IFacadeBlockEntity facade) { - facade.updateFacadeData(new FacadeData( - Blocks.GLOWSTONE.defaultBlockState(), - Direction.NORTH, - FacadeTextureMode.FILL - )); - } else { - helper.fail("Expected facade block entity at " + localPos + " for variant " + x); - return; - } - } - - cablePositions.add(absolute); - } - } - - // Verify all blocks are present and that facades have facade data - for (BlockPos absolute : cablePositions) { - if (!helper.getLevel().getBlockState(absolute).is(helper.getLevel().getBlockState(absolute).getBlock())) { - helper.fail("Block at " + absolute + " was not the expected variant block"); - return; - } - - var be = helper.getLevel().getBlockEntity(absolute); - if (be instanceof IFacadeBlockEntity facade) { - if (facade.getFacadeData() == null) { - helper.fail("Facade data was not set at " + absolute); - return; - } - } - } - - // All placed blocks should be cables; assert that first - for (BlockPos p : cablePositions) { - assertTrue( - CableNetwork.isCable(helper.getLevel(), p), - "Placed block at " + p + " should be a cable" - ); - } - - var firstPos = cablePositions.get(0); - - var maybeNetwork = CableNetworkManager.getOrRegisterNetworkFromCablePosition( - helper.getLevel(), - firstPos - ); - assertTrue(maybeNetwork.isPresent(), "Cable network should exist for first cable"); - var network = maybeNetwork.get(); - - // network should contain all the cable positions we placed - for (BlockPos p : cablePositions) { - assertTrue(network.containsCablePosition(p), "Network should contain cable at " + p); - - // For each cable, ensure the manager returns the same network object reference - var opt = CableNetworkManager.getOrRegisterNetworkFromCablePosition(helper.getLevel(), p); - assertTrue( - opt.isPresent() && opt.get() == network, - "Cable at " + p + " did not return the same network instance" - ); - } - - helper.succeed(); - } - - private static Variant @NotNull [] createVariants() { - - return new Variant[]{ - new Variant(SFMBlocks.CABLE_BLOCK.get(), false), - new Variant(SFMBlocks.CABLE_FACADE_BLOCK.get(), true), - new Variant(SFMBlocks.FANCY_CABLE_BLOCK.get(), false), - new Variant(SFMBlocks.FANCY_CABLE_FACADE_BLOCK.get(), true), - new Variant(SFMBlocks.TOUGH_CABLE_BLOCK.get(), false), - new Variant(SFMBlocks.TOUGH_CABLE_FACADE_BLOCK.get(), true), - new Variant(SFMBlocks.TOUGH_FANCY_CABLE_BLOCK.get(), false), - new Variant(SFMBlocks.TOUGH_FANCY_CABLE_FACADE_BLOCK.get(), true), - new Variant(SFMBlocks.TUNNELLED_CABLE_BLOCK.get(), false), - new Variant(SFMBlocks.TUNNELLED_CABLE_FACADE_BLOCK.get(), true), - new Variant(SFMBlocks.TUNNELLED_FANCY_CABLE_BLOCK.get(), false), - new Variant(SFMBlocks.TUNNELLED_FANCY_CABLE_FACADE_BLOCK.get(), true), - new Variant(SFMBlocks.MANAGER_BLOCK.get(), false), - new Variant(SFMBlocks.TUNNELLED_MANAGER_BLOCK.get(), false) - }; - } - - // Define variants: block supplier + whether it's a facade - record Variant( - Block block, - - boolean facade - ) { - } - -} +package ca.teamdman.sfm.gametest.tests.cables; + +import ca.teamdman.sfm.common.block_network.CableNetwork; +import ca.teamdman.sfm.common.block_network.CableNetworkManager; +import ca.teamdman.sfm.common.blockentity.IFacadeBlockEntity; +import ca.teamdman.sfm.common.facade.FacadeData; +import ca.teamdman.sfm.common.facade.FacadeTextureMode; +import ca.teamdman.sfm.common.registry.SFMBlocks; +import ca.teamdman.sfm.gametest.SFMGameTest; +import ca.teamdman.sfm.gametest.SFMGameTestDefinition; +import ca.teamdman.sfm.gametest.SFMGameTestHelper; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import org.jetbrains.annotations.NotNull; + +import static ca.teamdman.sfm.gametest.SFMGameTestMethodHelpers.assertTrue; + +@SFMGameTest +public class CableArrayGameTest extends SFMGameTestDefinition { + + @Override + public String template() { + return createVariants().length + "x2x5"; + } + + @Override + public void run(SFMGameTestHelper helper) { + + Variant[] variants = createVariants(); + + // collect absolute positions of all placed cables so we can assert they belong to the same network + java.util.List cablePositions = new java.util.ArrayList<>(); + + for (int x = 0; x < variants.length; x++) { + Variant v = variants[x]; + for (int z = 0; z < 3; z++) { + BlockPos localPos = new BlockPos(x, 2, 1 + z); + helper.setBlock(localPos, v.block); + var absolute = helper.absolutePos(localPos); + + // If this is a facade variant, set the facade data to glowstone + if (v.facade) { + var be = helper.getLevel().getBlockEntity(absolute); + if (be instanceof IFacadeBlockEntity facade) { + facade.updateFacadeData(new FacadeData( + Blocks.GLOWSTONE.defaultBlockState(), + Direction.NORTH, + FacadeTextureMode.FILL + )); + } else { + helper.fail("Expected facade block entity at " + localPos + " for variant " + x); + return; + } + } + + cablePositions.add(absolute); + } + } + + // Verify all blocks are present and that facades have facade data + for (BlockPos absolute : cablePositions) { + if (!helper.getLevel().getBlockState(absolute).is(helper.getLevel().getBlockState(absolute).getBlock())) { + helper.fail("Block at " + absolute + " was not the expected variant block"); + return; + } + + var be = helper.getLevel().getBlockEntity(absolute); + if (be instanceof IFacadeBlockEntity facade) { + if (facade.getFacadeData() == null) { + helper.fail("Facade data was not set at " + absolute); + return; + } + } + } + + // All placed blocks should be cables; assert that first + for (BlockPos p : cablePositions) { + assertTrue( + CableNetwork.isCable(helper.getLevel(), p), + "Placed block at " + p + " should be a cable" + ); + } + + var firstPos = cablePositions.get(0); + + var maybeNetwork = CableNetworkManager.getOrRegisterNetworkFromCablePosition( + helper.getLevel(), + firstPos + ); + assertTrue(maybeNetwork.isPresent(), "Cable network should exist for first cable"); + var network = maybeNetwork.get(); + + // network should contain all the cable positions we placed + for (BlockPos p : cablePositions) { + assertTrue(network.containsCablePosition(p), "Network should contain cable at " + p); + + // For each cable, ensure the manager returns the same network object reference + var opt = CableNetworkManager.getOrRegisterNetworkFromCablePosition(helper.getLevel(), p); + assertTrue( + opt.isPresent() && opt.get() == network, + "Cable at " + p + " did not return the same network instance" + ); + } + + helper.succeed(); + } + + private static Variant @NotNull [] createVariants() { + + return new Variant[]{ + new Variant(SFMBlocks.CABLE_BLOCK.get(), false), + new Variant(SFMBlocks.CABLE_FACADE_BLOCK.get(), true), + new Variant(SFMBlocks.FANCY_CABLE_BLOCK.get(), false), + new Variant(SFMBlocks.FANCY_CABLE_FACADE_BLOCK.get(), true), + new Variant(SFMBlocks.TOUGH_CABLE_BLOCK.get(), false), + new Variant(SFMBlocks.TOUGH_CABLE_FACADE_BLOCK.get(), true), + new Variant(SFMBlocks.TOUGH_FANCY_CABLE_BLOCK.get(), false), + new Variant(SFMBlocks.TOUGH_FANCY_CABLE_FACADE_BLOCK.get(), true), + new Variant(SFMBlocks.TUNNELLED_CABLE_BLOCK.get(), false), + new Variant(SFMBlocks.TUNNELLED_CABLE_FACADE_BLOCK.get(), true), + new Variant(SFMBlocks.TUNNELLED_FANCY_CABLE_BLOCK.get(), false), + new Variant(SFMBlocks.TUNNELLED_FANCY_CABLE_FACADE_BLOCK.get(), true), + new Variant(SFMBlocks.MANAGER_BLOCK.get(), false), + new Variant(SFMBlocks.TUNNELLED_MANAGER_BLOCK.get(), false) + }; + } + + // Define variants: block supplier + whether it's a facade + record Variant( + Block block, + + boolean facade + ) { + } + +} diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/tests/cables/ToughCableExplosionGameTest.java b/src/gametest/java/ca/teamdman/sfm/gametest/tests/cables/ToughCableExplosionGameTest.java index 9e1794a78..6f669449e 100644 --- a/src/gametest/java/ca/teamdman/sfm/gametest/tests/cables/ToughCableExplosionGameTest.java +++ b/src/gametest/java/ca/teamdman/sfm/gametest/tests/cables/ToughCableExplosionGameTest.java @@ -1,54 +1,54 @@ -package ca.teamdman.sfm.gametest.tests.cables; - -import ca.teamdman.sfm.common.facade.FacadeData; -import ca.teamdman.sfm.common.facade.FacadeTextureMode; -import ca.teamdman.sfm.common.registry.SFMBlocks; -import ca.teamdman.sfm.gametest.SFMGameTest; -import ca.teamdman.sfm.gametest.SFMGameTestDefinition; -import ca.teamdman.sfm.gametest.SFMGameTestHelper; -import net.minecraft.core.BlockPos; -import net.minecraft.core.Direction; -import net.minecraft.world.level.block.Blocks; - -@SFMGameTest -public class ToughCableExplosionGameTest extends SFMGameTestDefinition { - - @Override - public String template() { - // 3x4x3 has a hollow center suitable for explosions - return "3x4x3"; - } - - @Override - public void run(SFMGameTestHelper helper) { - BlockPos localPos = new BlockPos(1, 2, 1); - BlockPos absolute = helper.absolutePos(localPos); - - // place tough cable facade - helper.getLevel().setBlock(absolute, SFMBlocks.TOUGH_CABLE_FACADE_BLOCK.get().defaultBlockState(), 3); - - // set facade to mimic obsidian - var be = helper.getLevel().getBlockEntity(absolute); - if (be instanceof ca.teamdman.sfm.common.blockentity.IFacadeBlockEntity facade) { - facade.updateFacadeData(new FacadeData(Blocks.OBSIDIAN.defaultBlockState(), Direction.NORTH, FacadeTextureMode.FILL)); - } else { - helper.fail("Block entity at test position was not a facade BE"); - return; - } - - // spawn a lit TNT in the hollow center - var spawnVec = helper.absoluteVec(new net.minecraft.world.phys.Vec3(1.5, 2.5, 1.5)); - net.minecraft.world.entity.item.PrimedTnt primed = new net.minecraft.world.entity.item.PrimedTnt(helper.getLevel(), spawnVec.x, spawnVec.y, spawnVec.z, null); - primed.setFuse((short)20); - helper.getLevel().addFreshEntity(primed); - - // check after 40 ticks that the block still exists (i.e., was not destroyed by the explosion) - helper.runAfterDelay(40, () -> { - if (!helper.getLevel().getBlockState(absolute).is(SFMBlocks.TOUGH_CABLE_FACADE_BLOCK.get())) { - helper.fail("Tough cable facade was destroyed by TNT explosion"); - } else { - helper.succeed(); - } - }); - } -} +package ca.teamdman.sfm.gametest.tests.cables; + +import ca.teamdman.sfm.common.facade.FacadeData; +import ca.teamdman.sfm.common.facade.FacadeTextureMode; +import ca.teamdman.sfm.common.registry.SFMBlocks; +import ca.teamdman.sfm.gametest.SFMGameTest; +import ca.teamdman.sfm.gametest.SFMGameTestDefinition; +import ca.teamdman.sfm.gametest.SFMGameTestHelper; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.level.block.Blocks; + +@SFMGameTest +public class ToughCableExplosionGameTest extends SFMGameTestDefinition { + + @Override + public String template() { + // 3x4x3 has a hollow center suitable for explosions + return "3x4x3"; + } + + @Override + public void run(SFMGameTestHelper helper) { + BlockPos localPos = new BlockPos(1, 2, 1); + BlockPos absolute = helper.absolutePos(localPos); + + // place tough cable facade + helper.getLevel().setBlock(absolute, SFMBlocks.TOUGH_CABLE_FACADE_BLOCK.get().defaultBlockState(), 3); + + // set facade to mimic obsidian + var be = helper.getLevel().getBlockEntity(absolute); + if (be instanceof ca.teamdman.sfm.common.blockentity.IFacadeBlockEntity facade) { + facade.updateFacadeData(new FacadeData(Blocks.OBSIDIAN.defaultBlockState(), Direction.NORTH, FacadeTextureMode.FILL)); + } else { + helper.fail("Block entity at test position was not a facade BE"); + return; + } + + // spawn a lit TNT in the hollow center + var spawnVec = helper.absoluteVec(new net.minecraft.world.phys.Vec3(1.5, 2.5, 1.5)); + net.minecraft.world.entity.item.PrimedTnt primed = new net.minecraft.world.entity.item.PrimedTnt(helper.getLevel(), spawnVec.x, spawnVec.y, spawnVec.z, null); + primed.setFuse((short)20); + helper.getLevel().addFreshEntity(primed); + + // check after 40 ticks that the block still exists (i.e., was not destroyed by the explosion) + helper.runAfterDelay(40, () -> { + if (!helper.getLevel().getBlockState(absolute).is(SFMBlocks.TOUGH_CABLE_FACADE_BLOCK.get())) { + helper.fail("Tough cable facade was destroyed by TNT explosion"); + } else { + helper.succeed(); + } + }); + } +} diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/tests/cables/ToughFancyCableExplosionGameTest.java b/src/gametest/java/ca/teamdman/sfm/gametest/tests/cables/ToughFancyCableExplosionGameTest.java index af8a74c3c..a1747c265 100644 --- a/src/gametest/java/ca/teamdman/sfm/gametest/tests/cables/ToughFancyCableExplosionGameTest.java +++ b/src/gametest/java/ca/teamdman/sfm/gametest/tests/cables/ToughFancyCableExplosionGameTest.java @@ -1,54 +1,54 @@ -package ca.teamdman.sfm.gametest.tests.cables; - -import ca.teamdman.sfm.common.facade.FacadeData; -import ca.teamdman.sfm.common.facade.FacadeTextureMode; -import ca.teamdman.sfm.common.registry.SFMBlocks; -import ca.teamdman.sfm.gametest.SFMGameTest; -import ca.teamdman.sfm.gametest.SFMGameTestDefinition; -import ca.teamdman.sfm.gametest.SFMGameTestHelper; -import net.minecraft.core.BlockPos; -import net.minecraft.core.Direction; -import net.minecraft.world.entity.item.PrimedTnt; -import net.minecraft.world.level.block.Blocks; - -@SFMGameTest -public class ToughFancyCableExplosionGameTest extends SFMGameTestDefinition { - - @Override - public String template() { - return "3x4x3"; - } - - @Override - public void run(SFMGameTestHelper helper) { - BlockPos localPos = new BlockPos(1, 2, 1); - BlockPos absolute = helper.absolutePos(localPos); - - // place tough fancy cable facade - helper.getLevel().setBlock(absolute, SFMBlocks.TOUGH_FANCY_CABLE_FACADE_BLOCK.get().defaultBlockState(), 3); - - // set facade to mimic obsidian - var be = helper.getLevel().getBlockEntity(absolute); - if (be instanceof ca.teamdman.sfm.common.blockentity.IFacadeBlockEntity facade) { - facade.updateFacadeData(new FacadeData(Blocks.OBSIDIAN.defaultBlockState(), Direction.NORTH, FacadeTextureMode.FILL)); - } else { - helper.fail("Block entity at test position was not a facade BE"); - return; - } - - // spawn a lit TNT in the hollow center - var spawnVec = helper.absoluteVec(new net.minecraft.world.phys.Vec3(1.5, 2.5, 1.5)); - PrimedTnt primed = new PrimedTnt(helper.getLevel(), spawnVec.x, spawnVec.y, spawnVec.z, null); - primed.setFuse((short)20); - helper.getLevel().addFreshEntity(primed); - - // check after 40 ticks that the block still exists - helper.runAfterDelay(40, () -> { - if (!helper.getLevel().getBlockState(absolute).is(SFMBlocks.TOUGH_FANCY_CABLE_FACADE_BLOCK.get())) { - helper.fail("Tough fancy cable facade was destroyed by TNT explosion"); - } else { - helper.succeed(); - } - }); - } -} +package ca.teamdman.sfm.gametest.tests.cables; + +import ca.teamdman.sfm.common.facade.FacadeData; +import ca.teamdman.sfm.common.facade.FacadeTextureMode; +import ca.teamdman.sfm.common.registry.SFMBlocks; +import ca.teamdman.sfm.gametest.SFMGameTest; +import ca.teamdman.sfm.gametest.SFMGameTestDefinition; +import ca.teamdman.sfm.gametest.SFMGameTestHelper; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.entity.item.PrimedTnt; +import net.minecraft.world.level.block.Blocks; + +@SFMGameTest +public class ToughFancyCableExplosionGameTest extends SFMGameTestDefinition { + + @Override + public String template() { + return "3x4x3"; + } + + @Override + public void run(SFMGameTestHelper helper) { + BlockPos localPos = new BlockPos(1, 2, 1); + BlockPos absolute = helper.absolutePos(localPos); + + // place tough fancy cable facade + helper.getLevel().setBlock(absolute, SFMBlocks.TOUGH_FANCY_CABLE_FACADE_BLOCK.get().defaultBlockState(), 3); + + // set facade to mimic obsidian + var be = helper.getLevel().getBlockEntity(absolute); + if (be instanceof ca.teamdman.sfm.common.blockentity.IFacadeBlockEntity facade) { + facade.updateFacadeData(new FacadeData(Blocks.OBSIDIAN.defaultBlockState(), Direction.NORTH, FacadeTextureMode.FILL)); + } else { + helper.fail("Block entity at test position was not a facade BE"); + return; + } + + // spawn a lit TNT in the hollow center + var spawnVec = helper.absoluteVec(new net.minecraft.world.phys.Vec3(1.5, 2.5, 1.5)); + PrimedTnt primed = new PrimedTnt(helper.getLevel(), spawnVec.x, spawnVec.y, spawnVec.z, null); + primed.setFuse((short)20); + helper.getLevel().addFreshEntity(primed); + + // check after 40 ticks that the block still exists + helper.runAfterDelay(40, () -> { + if (!helper.getLevel().getBlockState(absolute).is(SFMBlocks.TOUGH_FANCY_CABLE_FACADE_BLOCK.get())) { + helper.fail("Tough fancy cable facade was destroyed by TNT explosion"); + } else { + helper.succeed(); + } + }); + } +} diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryChainedImportsGameTest.java b/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryChainedImportsGameTest.java new file mode 100644 index 000000000..2a71cf97a --- /dev/null +++ b/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryChainedImportsGameTest.java @@ -0,0 +1,218 @@ +package ca.teamdman.sfm.gametest.tests.library; + +import ca.teamdman.sfm.common.blockentity.LibraryBlockEntity; +import ca.teamdman.sfm.common.blockentity.ManagerBlockEntity; +import ca.teamdman.sfm.common.item.DiskItem; +import ca.teamdman.sfm.common.label.LabelPositionHolder; +import ca.teamdman.sfm.common.registry.SFMBlocks; +import ca.teamdman.sfm.common.registry.SFMItems; +import ca.teamdman.sfm.gametest.SFMGameTest; +import ca.teamdman.sfm.gametest.SFMGameTestDefinition; +import ca.teamdman.sfm.gametest.SFMGameTestHelper; +import net.minecraft.core.BlockPos; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.Blocks; + +import static ca.teamdman.sfm.gametest.SFMGameTestCountHelpers.count; +import static ca.teamdman.sfm.gametest.SFMGameTestMethodHelpers.assertTrue; + +/** + * Tests chained library imports where multiple libraries depend on each other. + *

+ * Setup: + * - base_protocols library: defines HasInput and HasOutput protocols + * - struct_lib library: imports base_protocols, defines Furnace struct + * - macro_lib library: imports base_protocols and struct_lib, defines smelt macro + * - Manager: imports all three libraries and uses the smelt macro + *

+ * This tests that: + * 1. Libraries can import other libraries + * 2. Definitions are properly resolved across the chain + * 3. Protocol constraints work with chained imports + */ +@SuppressWarnings({ + "RedundantSuppression", + "DataFlowIssue", + "OptionalGetWithoutIsPresent", + "DuplicatedCode" +}) +@SFMGameTest +public class LibraryChainedImportsGameTest extends SFMGameTestDefinition { + + @Override + public String template() { + return "7x3x3"; + } + + @Override + public int maxTicks() { + return 200; + } + + @Override + public void run(SFMGameTestHelper helper) { + // Layout (y=2 front row): [ore] - [baseLib] - [structLib] - [Manager] - [macroLib] - [furnace] - [ingots] + // Layout (y=2 back row): [cable] - [cable] - [cable] - [cable] - [cable] - [cable] - [cable] + BlockPos orePos = new BlockPos(0, 2, 0); + BlockPos baseLibPos = new BlockPos(1, 2, 0); + BlockPos structLibPos = new BlockPos(2, 2, 0); + BlockPos managerPos = new BlockPos(3, 2, 0); + BlockPos macroLibPos = new BlockPos(4, 2, 0); + BlockPos furnacePos = new BlockPos(5, 2, 0); + BlockPos ingotsPos = new BlockPos(6, 2, 0); + + // Cable row behind to connect everything + BlockPos cable0Pos = new BlockPos(0, 2, 1); + BlockPos cable1Pos = new BlockPos(1, 2, 1); + BlockPos cable2Pos = new BlockPos(2, 2, 1); + BlockPos cable3Pos = new BlockPos(3, 2, 1); + BlockPos cable4Pos = new BlockPos(4, 2, 1); + BlockPos cable5Pos = new BlockPos(5, 2, 1); + BlockPos cable6Pos = new BlockPos(6, 2, 1); + + // Place main blocks + helper.setBlock(orePos, SFMBlocks.TEST_BARREL_BLOCK.get()); + helper.setBlock(baseLibPos, SFMBlocks.LIBRARY_BLOCK.get()); + helper.setBlock(structLibPos, SFMBlocks.LIBRARY_BLOCK.get()); + helper.setBlock(managerPos, SFMBlocks.MANAGER_BLOCK.get()); + helper.setBlock(macroLibPos, SFMBlocks.LIBRARY_BLOCK.get()); + helper.setBlock(furnacePos, SFMBlocks.TEST_BARREL_BLOCK.get()); + helper.setBlock(ingotsPos, SFMBlocks.TEST_BARREL_BLOCK.get()); + + // Place cable row behind + helper.setBlock(cable0Pos, SFMBlocks.CABLE_BLOCK.get()); + helper.setBlock(cable1Pos, SFMBlocks.CABLE_BLOCK.get()); + helper.setBlock(cable2Pos, SFMBlocks.CABLE_BLOCK.get()); + helper.setBlock(cable3Pos, SFMBlocks.CABLE_BLOCK.get()); + helper.setBlock(cable4Pos, SFMBlocks.CABLE_BLOCK.get()); + helper.setBlock(cable5Pos, SFMBlocks.CABLE_BLOCK.get()); + helper.setBlock(cable6Pos, SFMBlocks.CABLE_BLOCK.get()); + + // Get block entities + LibraryBlockEntity baseLib = (LibraryBlockEntity) helper.getBlockEntity(baseLibPos); + LibraryBlockEntity structLib = (LibraryBlockEntity) helper.getBlockEntity(structLibPos); + LibraryBlockEntity macroLib = (LibraryBlockEntity) helper.getBlockEntity(macroLibPos); + ManagerBlockEntity manager = (ManagerBlockEntity) helper.getBlockEntity(managerPos); + + // Create base_protocols library disk + ItemStack baseLibDisk = new ItemStack(SFMItems.DISK_ITEM.get()); + DiskItem.setProgram(baseLibDisk, """ + NAME "base_protocols" + + protocol HasInput + input: sidequalifier slotqualifier + end + + protocol HasOutput + output: sidequalifier slotqualifier + end + """); + baseLib.setItem(0, baseLibDisk); + + // Create struct_lib library disk + ItemStack structLibDisk = new ItemStack(SFMItems.DISK_ITEM.get()); + DiskItem.setProgram(structLibDisk, """ + NAME "struct_lib" + use library "base_protocols" + + struct Furnace : HasInput, HasOutput + input: EACH SIDE SLOTS 0-8 + output: EACH SIDE SLOTS 9-17 + end + """); + structLib.setItem(0, structLibDisk); + + // Create macro_lib library disk + ItemStack macroLibDisk = new ItemStack(SFMItems.DISK_ITEM.get()); + DiskItem.setProgram(macroLibDisk, """ + NAME "macro_lib" + use library "base_protocols" + use library "struct_lib" + + macro smelt(machine: HasInput, machine2: HasOutput, src, dst) + input from src + output to machine using input + forget + input from machine2 using output + output to dst + end + """); + macroLib.setItem(0, macroLibDisk); + + // Create manager disk that imports all libraries + manager.setItem(0, new ItemStack(SFMItems.DISK_ITEM.get())); + manager.setProgram(""" + NAME "Chained Manager" + use library "base_protocols" + use library "struct_lib" + use library "macro_lib" + + let furnace = Furnace + + every 20 ticks do + DO smelt(furnace, furnace, ore_chest, ingot_chest) + end + """); + + // Setup labels + LabelPositionHolder labelHolder = LabelPositionHolder.empty() + .add("ore_chest", helper.absolutePos(orePos)) + .add("furnace", helper.absolutePos(furnacePos)) + .add("ingot_chest", helper.absolutePos(ingotsPos)); + labelHolder.save(manager.getDisk()); + + // Put ore in source + var oreHandler = helper.getItemHandler(orePos); + oreHandler.insertItem(0, new ItemStack(Blocks.GOLD_ORE, 32), false); + + // Put ingots in furnace output (simulating smelting) + var furnaceHandler = helper.getItemHandler(furnacePos); + furnaceHandler.insertItem(9, new ItemStack(Blocks.GOLD_BLOCK, 4), false); + + // Wait for libraries to compile and manager to run + helper.runAfterDelay(20, () -> { + // Verify all library disks have no errors + assertTrue( + DiskItem.getErrors(baseLib.getItem(0)).isEmpty(), + "base_protocols library should have no errors but had: " + DiskItem.getErrors(baseLib.getItem(0)) + ); + assertTrue( + DiskItem.getErrors(structLib.getItem(0)).isEmpty(), + "struct_lib library should have no errors but had: " + DiskItem.getErrors(structLib.getItem(0)) + ); + assertTrue( + DiskItem.getErrors(macroLib.getItem(0)).isEmpty(), + "macro_lib library should have no errors but had: " + DiskItem.getErrors(macroLib.getItem(0)) + ); + + // Verify manager disk has no errors + assertTrue( + DiskItem.getErrors(manager.getDisk()).isEmpty(), + "Manager disk should have no errors but had: " + DiskItem.getErrors(manager.getDisk()) + ); + + helper.succeedIfManagerDidThingWithoutLagging(manager, () -> { + // Verify ore was moved from source to furnace input + assertTrue( + count(oreHandler, Blocks.GOLD_ORE) == 0, + "Ore chest should be empty but has " + count(oreHandler, Blocks.GOLD_ORE) + " gold ore" + ); + assertTrue( + count(furnaceHandler, Blocks.GOLD_ORE) == 32, + "Furnace should have 32 gold ore in input slots but has " + count(furnaceHandler, Blocks.GOLD_ORE) + ); + + // Verify ingots were moved from furnace output to ingot chest + var ingotsHandler = helper.getItemHandler(ingotsPos); + assertTrue( + count(ingotsHandler, Blocks.GOLD_BLOCK) == 4, + "Ingot chest should have 4 gold blocks but has " + count(ingotsHandler, Blocks.GOLD_BLOCK) + ); + assertTrue( + count(furnaceHandler, Blocks.GOLD_BLOCK) == 0, + "Furnace output should be empty but has " + count(furnaceHandler, Blocks.GOLD_BLOCK) + ); + }); + }); + } +} diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryProtocolConstraintValidationGameTest.java b/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryProtocolConstraintValidationGameTest.java new file mode 100644 index 000000000..f908c06c4 --- /dev/null +++ b/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryProtocolConstraintValidationGameTest.java @@ -0,0 +1,148 @@ +package ca.teamdman.sfm.gametest.tests.library; + +import ca.teamdman.sfm.common.blockentity.LibraryBlockEntity; +import ca.teamdman.sfm.common.blockentity.ManagerBlockEntity; +import ca.teamdman.sfm.common.item.DiskItem; +import ca.teamdman.sfm.common.registry.SFMBlocks; +import ca.teamdman.sfm.common.registry.SFMItems; +import ca.teamdman.sfm.gametest.SFMGameTest; +import ca.teamdman.sfm.gametest.SFMGameTestDefinition; +import ca.teamdman.sfm.gametest.SFMGameTestHelper; +import net.minecraft.core.BlockPos; +import net.minecraft.world.item.ItemStack; + +import static ca.teamdman.sfm.gametest.SFMGameTestMethodHelpers.assertTrue; + +/** + * Tests that protocol constraints in macros are validated at compile time. + *

+ * The library defines: + * - IOCapable protocol with input and output fields + * - ValidDevice struct that implements IOCapable + * - InvalidDevice struct that does NOT implement IOCapable + * - transfer macro with IOCapable constraint + *

+ * Test verifies: + * 1. Using ValidDevice with the macro compiles successfully + * 2. Using InvalidDevice with the macro produces compile errors + */ +@SuppressWarnings({ + "RedundantSuppression", + "DataFlowIssue", + "OptionalGetWithoutIsPresent", + "DuplicatedCode" +}) +@SFMGameTest +public class LibraryProtocolConstraintValidationGameTest extends SFMGameTestDefinition { + + @Override + public String template() { + return "3x3x3"; + } + + @Override + public int maxTicks() { + return 100; + } + + @Override + public void run(SFMGameTestHelper helper) { + // Layout (y=2 front row): [Library] - [Manager] - [empty] + // Layout (y=2 back row): [cable] - [cable] - [empty] + BlockPos libraryPos = new BlockPos(0, 2, 0); + BlockPos managerPos = new BlockPos(1, 2, 0); + + // Cable row behind to connect + BlockPos cable0Pos = new BlockPos(0, 2, 1); + BlockPos cable1Pos = new BlockPos(1, 2, 1); + + // Place main blocks + helper.setBlock(libraryPos, SFMBlocks.LIBRARY_BLOCK.get()); + helper.setBlock(managerPos, SFMBlocks.MANAGER_BLOCK.get()); + + // Place cable row behind + helper.setBlock(cable0Pos, SFMBlocks.CABLE_BLOCK.get()); + helper.setBlock(cable1Pos, SFMBlocks.CABLE_BLOCK.get()); + + // Get block entities + LibraryBlockEntity library = (LibraryBlockEntity) helper.getBlockEntity(libraryPos); + ManagerBlockEntity manager = (ManagerBlockEntity) helper.getBlockEntity(managerPos); + + // Create library disk with protocol, valid struct, invalid struct, and macro + ItemStack libraryDisk = new ItemStack(SFMItems.DISK_ITEM.get()); + DiskItem.setProgram(libraryDisk, """ + NAME "constrained_lib" + + protocol IOCapable + input: sidequalifier slotqualifier + output: sidequalifier slotqualifier + end + + struct ValidDevice : IOCapable + input: EACH SIDE SLOTS 0-8 + output: EACH SIDE SLOTS 9-17 + end + + struct InvalidDevice + storage: SLOTS 0-26 + end + + macro transfer(device: IOCapable, src, dst) + input from src + output to device using input + end + """); + library.setItem(0, libraryDisk); + + // Wait for library to compile + helper.runAfterDelay(10, () -> { + // Verify library disk has no errors + ItemStack compiledLibraryDisk = library.getItem(0); + assertTrue( + DiskItem.getErrors(compiledLibraryDisk).isEmpty(), + "Library disk should have no errors but had: " + DiskItem.getErrors(compiledLibraryDisk) + ); + + // Test 1: Valid usage - struct implements required protocol + manager.setItem(0, new ItemStack(SFMItems.DISK_ITEM.get())); + manager.setProgram(""" + NAME "Valid Usage" + use library "constrained_lib" + + let device = ValidDevice + + every 20 ticks do + DO transfer(device, a, b) + end + """); + + helper.runAfterDelay(5, () -> { + assertTrue( + DiskItem.getErrors(manager.getDisk()).isEmpty(), + "Valid usage should have no errors but had: " + DiskItem.getErrors(manager.getDisk()) + ); + + // Test 2: Invalid usage - struct doesn't implement required protocol + manager.setProgram(""" + NAME "Invalid Usage" + use library "constrained_lib" + + let device = InvalidDevice + + every 20 ticks do + DO transfer(device, a, b) + end + """); + + helper.runAfterDelay(5, () -> { + assertTrue( + !DiskItem.getErrors(manager.getDisk()).isEmpty(), + "Invalid usage should have compile errors for using struct that doesn't implement protocol" + ); + + helper.succeed(); + }); + }); + }); + } +} diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryRecompileOnCableRemovalGameTest.java b/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryRecompileOnCableRemovalGameTest.java new file mode 100644 index 000000000..c3a8f34e4 --- /dev/null +++ b/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryRecompileOnCableRemovalGameTest.java @@ -0,0 +1,111 @@ +package ca.teamdman.sfm.gametest.tests.library; + +import ca.teamdman.sfm.common.blockentity.LibraryBlockEntity; +import ca.teamdman.sfm.common.item.DiskItem; +import ca.teamdman.sfm.common.registry.SFMBlocks; +import ca.teamdman.sfm.common.registry.SFMItems; +import ca.teamdman.sfm.gametest.SFMGameTest; +import ca.teamdman.sfm.gametest.SFMGameTestDefinition; +import ca.teamdman.sfm.gametest.SFMGameTestHelper; +import net.minecraft.core.BlockPos; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.Blocks; + +import static ca.teamdman.sfm.gametest.SFMGameTestMethodHelpers.assertTrue; + +/** + * Tests that library disks are recompiled when the only network connection + * (cable) next to the library is removed. + *

+ * Setup: + * - Library A has a disk that uses library "provider" + * - Library B has a disk with NAME "provider" containing definitions + * - A cable connects them + *

+ * Test: + * 1. Initially, Library A's disk should have no errors (can resolve "provider") + * 2. Remove the cable connecting them + * 3. After the 5-tick notification delay, Library A's disk should have errors + * (cannot resolve "provider" anymore, proving recompilation occurred) + */ +@SuppressWarnings({ + "RedundantSuppression", + "DataFlowIssue", + "OptionalGetWithoutIsPresent", + "DuplicatedCode", + "ArraysAsListWithZeroOrOneArgument" +}) +@SFMGameTest +public class LibraryRecompileOnCableRemovalGameTest extends SFMGameTestDefinition { + + @Override + public String template() { + return "3x2x1"; + } + + @Override + public void run(SFMGameTestHelper helper) { + // Place two library blocks with a cable between them + // Layout: [LibraryA] - [Cable] - [LibraryB] + BlockPos libraryAPos = new BlockPos(0, 2, 0); + BlockPos cablePos = new BlockPos(1, 2, 0); + BlockPos libraryBPos = new BlockPos(2, 2, 0); + + helper.setBlock(libraryAPos, SFMBlocks.LIBRARY_BLOCK.get()); + helper.setBlock(cablePos, SFMBlocks.CABLE_BLOCK.get()); + helper.setBlock(libraryBPos, SFMBlocks.LIBRARY_BLOCK.get()); + + // Get library block entities + LibraryBlockEntity libraryA = (LibraryBlockEntity) helper.getBlockEntity(libraryAPos); + LibraryBlockEntity libraryB = (LibraryBlockEntity) helper.getBlockEntity(libraryBPos); + + // Create disk for Library B with a named library definition + ItemStack providerDisk = new ItemStack(SFMItems.DISK_ITEM.get()); + DiskItem.setProgram(providerDisk, """ + NAME "provider" + + -- This library provides a simple macro + macro greet() + -- just a placeholder + end + """); + + // Create disk for Library A that uses the provider library + ItemStack consumerDisk = new ItemStack(SFMItems.DISK_ITEM.get()); + DiskItem.setProgram(consumerDisk, """ + NAME "consumer" + + USE LIBRARY "provider" + """); + + // Insert disks into libraries + libraryB.setItem(0, providerDisk); + libraryA.setItem(0, consumerDisk); + + // Wait a moment for initial compilation via the batched notification system + helper.runAfterDelay(10, () -> { + // Get the consumer disk and check it has no errors (library was found) + ItemStack diskAfterConnect = libraryA.getItem(0); + assertTrue( + DiskItem.getErrors(diskAfterConnect).isEmpty(), + "Consumer disk should have no errors when connected to provider library" + ); + + // Now remove the cable, disconnecting Library A from Library B + helper.setBlock(cablePos, Blocks.AIR); + + // Wait for the 5-tick notification delay plus a small buffer + helper.runAfterDelay(10, () -> { + // Get the consumer disk and verify it now has errors + // (library "provider" cannot be resolved anymore) + ItemStack diskAfterDisconnect = libraryA.getItem(0); + assertTrue( + !DiskItem.getErrors(diskAfterDisconnect).isEmpty(), + "Consumer disk should have errors after disconnection (library not found)" + ); + + helper.succeed(); + }); + }); + } +} diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryWithCombinedIOProtocolGameTest.java b/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryWithCombinedIOProtocolGameTest.java new file mode 100644 index 000000000..2f330fa93 --- /dev/null +++ b/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryWithCombinedIOProtocolGameTest.java @@ -0,0 +1,178 @@ +package ca.teamdman.sfm.gametest.tests.library; + +import ca.teamdman.sfm.common.blockentity.LibraryBlockEntity; +import ca.teamdman.sfm.common.blockentity.ManagerBlockEntity; +import ca.teamdman.sfm.common.item.DiskItem; +import ca.teamdman.sfm.common.label.LabelPositionHolder; +import ca.teamdman.sfm.common.registry.SFMBlocks; +import ca.teamdman.sfm.common.registry.SFMItems; +import ca.teamdman.sfm.gametest.SFMGameTest; +import ca.teamdman.sfm.gametest.SFMGameTestDefinition; +import ca.teamdman.sfm.gametest.SFMGameTestHelper; +import net.minecraft.core.BlockPos; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.Blocks; + +import static ca.teamdman.sfm.gametest.SFMGameTestCountHelpers.count; +import static ca.teamdman.sfm.gametest.SFMGameTestMethodHelpers.assertTrue; + +/** + * Tests that a library with a combined I/O protocol (both input and output fields) + * can be imported and used with a macro that processes items through a machine. + *

+ * The library defines: + * - Processable protocol with both input and output fields + * - Machine struct implementing Processable + * - process macro with Processable constraint + *

+ * This tests the scenario where a single protocol contains both input and output + * definitions, and a macro uses both fields from the same struct parameter. + */ +@SuppressWarnings({ + "RedundantSuppression", + "DataFlowIssue", + "OptionalGetWithoutIsPresent", + "DuplicatedCode" +}) +@SFMGameTest +public class LibraryWithCombinedIOProtocolGameTest extends SFMGameTestDefinition { + + @Override + public String template() { + return "5x3x3"; + } + + @Override + public int maxTicks() { + return 200; + } + + @Override + public void run(SFMGameTestHelper helper) { + // Layout (y=2 front row): [source] - [Library] - [Manager] - [machine] - [dest] + // Layout (y=2 back row): [cable] - [cable] - [cable] - [cable] - [cable] + BlockPos sourcePos = new BlockPos(0, 2, 0); + BlockPos libraryPos = new BlockPos(1, 2, 0); + BlockPos managerPos = new BlockPos(2, 2, 0); + BlockPos machinePos = new BlockPos(3, 2, 0); + BlockPos destPos = new BlockPos(4, 2, 0); + + // Cable row behind to connect everything + BlockPos cable0Pos = new BlockPos(0, 2, 1); + BlockPos cable1Pos = new BlockPos(1, 2, 1); + BlockPos cable2Pos = new BlockPos(2, 2, 1); + BlockPos cable3Pos = new BlockPos(3, 2, 1); + BlockPos cable4Pos = new BlockPos(4, 2, 1); + + // Place main blocks + helper.setBlock(sourcePos, SFMBlocks.TEST_BARREL_BLOCK.get()); + helper.setBlock(libraryPos, SFMBlocks.LIBRARY_BLOCK.get()); + helper.setBlock(managerPos, SFMBlocks.MANAGER_BLOCK.get()); + helper.setBlock(machinePos, SFMBlocks.TEST_BARREL_BLOCK.get()); + helper.setBlock(destPos, SFMBlocks.TEST_BARREL_BLOCK.get()); + + // Place cable row behind + helper.setBlock(cable0Pos, SFMBlocks.CABLE_BLOCK.get()); + helper.setBlock(cable1Pos, SFMBlocks.CABLE_BLOCK.get()); + helper.setBlock(cable2Pos, SFMBlocks.CABLE_BLOCK.get()); + helper.setBlock(cable3Pos, SFMBlocks.CABLE_BLOCK.get()); + helper.setBlock(cable4Pos, SFMBlocks.CABLE_BLOCK.get()); + + // Get block entities + LibraryBlockEntity library = (LibraryBlockEntity) helper.getBlockEntity(libraryPos); + ManagerBlockEntity manager = (ManagerBlockEntity) helper.getBlockEntity(managerPos); + + // Create library disk with combined protocol, struct, and macro + ItemStack libraryDisk = new ItemStack(SFMItems.DISK_ITEM.get()); + DiskItem.setProgram(libraryDisk, """ + NAME "combined_lib" + + protocol Processable + input: sidequalifier slotqualifier + output: sidequalifier slotqualifier + end + + struct Machine : Processable + input: EACH SIDE SLOTS 0-8 + output: EACH SIDE SLOTS 9-17 + end + + macro process(device: Processable, src, dst) + input from src + output to device using input + forget + input from device using output + output to dst + end + """); + library.setItem(0, libraryDisk); + + // Create manager disk that imports the library + manager.setItem(0, new ItemStack(SFMItems.DISK_ITEM.get())); + manager.setProgram(""" + NAME "Combined Manager" + + use library "combined_lib" + + let machine = Machine + + every 20 ticks do + DO process(machine, source, dest) + end + """); + + // Setup labels + LabelPositionHolder labelHolder = LabelPositionHolder.empty() + .add("source", helper.absolutePos(sourcePos)) + .add("machine", helper.absolutePos(machinePos)) + .add("dest", helper.absolutePos(destPos)); + labelHolder.save(manager.getDisk()); + + // Put items in source + var sourceHandler = helper.getItemHandler(sourcePos); + sourceHandler.insertItem(0, new ItemStack(Blocks.COAL_ORE, 32), false); + + // Put processed items in machine's output slots + var machineHandler = helper.getItemHandler(machinePos); + machineHandler.insertItem(9, new ItemStack(Blocks.COAL_BLOCK, 4), false); + + // Wait for the library to compile and manager to run + helper.runAfterDelay(10, () -> { + // Verify library disk has no errors + ItemStack compiledLibraryDisk = library.getItem(0); + assertTrue( + DiskItem.getErrors(compiledLibraryDisk).isEmpty(), + "Library disk should have no errors but had: " + DiskItem.getErrors(compiledLibraryDisk) + ); + + // Verify manager disk has no errors + assertTrue( + DiskItem.getErrors(manager.getDisk()).isEmpty(), + "Manager disk should have no errors but had: " + DiskItem.getErrors(manager.getDisk()) + ); + + helper.succeedIfManagerDidThingWithoutLagging(manager, () -> { + // Verify items were moved from source to machine's input slots + assertTrue( + count(sourceHandler, Blocks.COAL_ORE) == 0, + "Source should be empty but has " + count(sourceHandler, Blocks.COAL_ORE) + " coal ore" + ); + assertTrue( + count(machineHandler, Blocks.COAL_ORE) == 32, + "Machine should have 32 coal ore in input slots but has " + count(machineHandler, Blocks.COAL_ORE) + ); + + // Verify items were moved from machine's output slots to dest + var destHandler = helper.getItemHandler(destPos); + assertTrue( + count(destHandler, Blocks.COAL_BLOCK) == 4, + "Dest should have 4 coal blocks but has " + count(destHandler, Blocks.COAL_BLOCK) + ); + assertTrue( + count(machineHandler, Blocks.COAL_BLOCK) == 0, + "Machine output slots should be empty but has " + count(machineHandler, Blocks.COAL_BLOCK) + ); + }); + }); + } +} diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryWithStructIOProtocolsGameTest.java b/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryWithStructIOProtocolsGameTest.java new file mode 100644 index 000000000..a8db4a57b --- /dev/null +++ b/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryWithStructIOProtocolsGameTest.java @@ -0,0 +1,176 @@ +package ca.teamdman.sfm.gametest.tests.library; + +import ca.teamdman.sfm.common.blockentity.LibraryBlockEntity; +import ca.teamdman.sfm.common.blockentity.ManagerBlockEntity; +import ca.teamdman.sfm.common.item.DiskItem; +import ca.teamdman.sfm.common.label.LabelPositionHolder; +import ca.teamdman.sfm.common.registry.SFMBlocks; +import ca.teamdman.sfm.common.registry.SFMItems; +import ca.teamdman.sfm.gametest.SFMGameTest; +import ca.teamdman.sfm.gametest.SFMGameTestDefinition; +import ca.teamdman.sfm.gametest.SFMGameTestHelper; +import net.minecraft.core.BlockPos; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.Blocks; + +import static ca.teamdman.sfm.gametest.SFMGameTestCountHelpers.count; +import static ca.teamdman.sfm.gametest.SFMGameTestMethodHelpers.assertTrue; + +/** + * Tests that a library with a struct implementing input/output protocols + * can be imported and used by a manager program at runtime. + *

+ * The library defines: + * - HasInput protocol with input field + * - HasOutput protocol with output field + * - IODevice struct implementing both protocols + *

+ * The manager imports the library, creates an IODevice instance, + * and uses it for item transfer via the struct's input/output fields. + */ +@SuppressWarnings({ + "RedundantSuppression", + "DataFlowIssue", + "OptionalGetWithoutIsPresent", + "DuplicatedCode" +}) +@SFMGameTest +public class LibraryWithStructIOProtocolsGameTest extends SFMGameTestDefinition { + + @Override + public String template() { + return "5x3x3"; + } + + @Override + public int maxTicks() { + return 200; + } + + @Override + public void run(SFMGameTestHelper helper) { + // Layout (y=2 front row): [source] - [Library] - [Manager] - [device] - [dest] + // Layout (y=2 back row): [cable] - [cable] - [cable] - [cable] - [cable] + BlockPos sourcePos = new BlockPos(0, 2, 0); + BlockPos libraryPos = new BlockPos(1, 2, 0); + BlockPos managerPos = new BlockPos(2, 2, 0); + BlockPos devicePos = new BlockPos(3, 2, 0); + BlockPos destPos = new BlockPos(4, 2, 0); + + // Cable row behind to connect everything + BlockPos cable0Pos = new BlockPos(0, 2, 1); + BlockPos cable1Pos = new BlockPos(1, 2, 1); + BlockPos cable2Pos = new BlockPos(2, 2, 1); + BlockPos cable3Pos = new BlockPos(3, 2, 1); + BlockPos cable4Pos = new BlockPos(4, 2, 1); + + // Place main blocks + helper.setBlock(sourcePos, SFMBlocks.TEST_BARREL_BLOCK.get()); + helper.setBlock(libraryPos, SFMBlocks.LIBRARY_BLOCK.get()); + helper.setBlock(managerPos, SFMBlocks.MANAGER_BLOCK.get()); + helper.setBlock(devicePos, SFMBlocks.TEST_BARREL_BLOCK.get()); + helper.setBlock(destPos, SFMBlocks.TEST_BARREL_BLOCK.get()); + + // Place cable row behind + helper.setBlock(cable0Pos, SFMBlocks.CABLE_BLOCK.get()); + helper.setBlock(cable1Pos, SFMBlocks.CABLE_BLOCK.get()); + helper.setBlock(cable2Pos, SFMBlocks.CABLE_BLOCK.get()); + helper.setBlock(cable3Pos, SFMBlocks.CABLE_BLOCK.get()); + helper.setBlock(cable4Pos, SFMBlocks.CABLE_BLOCK.get()); + + // Get block entities + LibraryBlockEntity library = (LibraryBlockEntity) helper.getBlockEntity(libraryPos); + ManagerBlockEntity manager = (ManagerBlockEntity) helper.getBlockEntity(managerPos); + + // Create library disk with protocols and struct + ItemStack libraryDisk = new ItemStack(SFMItems.DISK_ITEM.get()); + DiskItem.setProgram(libraryDisk, """ + NAME "io_lib" + + protocol HasInput + input: sidequalifier slotqualifier + end + + protocol HasOutput + output: sidequalifier slotqualifier + end + + struct IODevice : HasInput, HasOutput + input: EACH SIDE SLOTS 0-8 + output: EACH SIDE SLOTS 9-17 + end + """); + library.setItem(0, libraryDisk); + + // Create manager disk that imports the library + manager.setItem(0, new ItemStack(SFMItems.DISK_ITEM.get())); + manager.setProgram(""" + NAME "IO Manager" + + use library "io_lib" + + let device = IODevice + + every 20 ticks do + -- Move items from source to device's input slots + input from source_chest + output to device using input + forget + + -- Move items from device's output slots to dest + input from device using output + output to dest_chest + end + """); + + // Setup labels + LabelPositionHolder labelHolder = LabelPositionHolder.empty() + .add("source_chest", helper.absolutePos(sourcePos)) + .add("device", helper.absolutePos(devicePos)) + .add("dest_chest", helper.absolutePos(destPos)); + labelHolder.save(manager.getDisk()); + + // Put items in source chest + var sourceHandler = helper.getItemHandler(sourcePos); + sourceHandler.insertItem(0, new ItemStack(Blocks.DIRT, 32), false); + + // Put items in device's output slots (slots 9-17) to be moved to dest + var deviceHandler = helper.getItemHandler(devicePos); + deviceHandler.insertItem(9, new ItemStack(Blocks.STONE, 16), false); + + // Wait for the library to compile and manager to run + helper.runAfterDelay(10, () -> { + // Verify library disk has no errors + ItemStack compiledLibraryDisk = library.getItem(0); + assertTrue( + DiskItem.getErrors(compiledLibraryDisk).isEmpty(), + "Library disk should have no errors but had: " + DiskItem.getErrors(compiledLibraryDisk) + ); + + // Verify manager disk has no errors + assertTrue( + DiskItem.getErrors(manager.getDisk()).isEmpty(), + "Manager disk should have no errors but had: " + DiskItem.getErrors(manager.getDisk()) + ); + + helper.succeedIfManagerDidThingWithoutLagging(manager, () -> { + // Verify items were moved from source to device's input slots + assertTrue( + count(sourceHandler, Blocks.DIRT) == 0, + "Source should be empty but has " + count(sourceHandler, Blocks.DIRT) + " dirt" + ); + assertTrue( + count(deviceHandler, Blocks.DIRT) == 32, + "Device should have 32 dirt in input slots but has " + count(deviceHandler, Blocks.DIRT) + ); + + // Verify items were moved from device's output slots to dest + var destHandler = helper.getItemHandler(destPos); + assertTrue( + count(destHandler, Blocks.STONE) == 16, + "Dest should have 16 stone but has " + count(destHandler, Blocks.STONE) + ); + }); + }); + } +} diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryWithTransferMacroGameTest.java b/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryWithTransferMacroGameTest.java new file mode 100644 index 000000000..0b1516caf --- /dev/null +++ b/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryWithTransferMacroGameTest.java @@ -0,0 +1,181 @@ +package ca.teamdman.sfm.gametest.tests.library; + +import ca.teamdman.sfm.common.blockentity.LibraryBlockEntity; +import ca.teamdman.sfm.common.blockentity.ManagerBlockEntity; +import ca.teamdman.sfm.common.item.DiskItem; +import ca.teamdman.sfm.common.label.LabelPositionHolder; +import ca.teamdman.sfm.common.registry.SFMBlocks; +import ca.teamdman.sfm.common.registry.SFMItems; +import ca.teamdman.sfm.gametest.SFMGameTest; +import ca.teamdman.sfm.gametest.SFMGameTestDefinition; +import ca.teamdman.sfm.gametest.SFMGameTestHelper; +import net.minecraft.core.BlockPos; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.Blocks; + +import static ca.teamdman.sfm.gametest.SFMGameTestCountHelpers.count; +import static ca.teamdman.sfm.gametest.SFMGameTestMethodHelpers.assertTrue; + +/** + * Tests that a library with a macro using protocol constraints + * can be imported and executed by a manager program at runtime. + *

+ * The library defines: + * - HasInput and HasOutput protocols + * - Processor struct implementing both protocols + * - transfer_through macro with protocol-constrained parameters + *

+ * The manager imports the library and uses the macro to transfer items + * through a processor device (input -> processor.input -> processor.output -> output). + */ +@SuppressWarnings({ + "RedundantSuppression", + "DataFlowIssue", + "OptionalGetWithoutIsPresent", + "DuplicatedCode" +}) +@SFMGameTest +public class LibraryWithTransferMacroGameTest extends SFMGameTestDefinition { + + @Override + public String template() { + return "5x3x3"; + } + + @Override + public int maxTicks() { + return 200; + } + + @Override + public void run(SFMGameTestHelper helper) { + // Layout (y=2 front row): [input] - [Library] - [Manager] - [processor] - [output] + // Layout (y=2 back row): [cable] - [cable] - [cable] - [cable] - [cable] + BlockPos inputPos = new BlockPos(0, 2, 0); + BlockPos libraryPos = new BlockPos(1, 2, 0); + BlockPos managerPos = new BlockPos(2, 2, 0); + BlockPos processorPos = new BlockPos(3, 2, 0); + BlockPos outputPos = new BlockPos(4, 2, 0); + + // Cable row behind to connect everything + BlockPos cable0Pos = new BlockPos(0, 2, 1); + BlockPos cable1Pos = new BlockPos(1, 2, 1); + BlockPos cable2Pos = new BlockPos(2, 2, 1); + BlockPos cable3Pos = new BlockPos(3, 2, 1); + BlockPos cable4Pos = new BlockPos(4, 2, 1); + + // Place main blocks + helper.setBlock(inputPos, SFMBlocks.TEST_BARREL_BLOCK.get()); + helper.setBlock(libraryPos, SFMBlocks.LIBRARY_BLOCK.get()); + helper.setBlock(managerPos, SFMBlocks.MANAGER_BLOCK.get()); + helper.setBlock(processorPos, SFMBlocks.TEST_BARREL_BLOCK.get()); + helper.setBlock(outputPos, SFMBlocks.TEST_BARREL_BLOCK.get()); + + // Place cable row behind + helper.setBlock(cable0Pos, SFMBlocks.CABLE_BLOCK.get()); + helper.setBlock(cable1Pos, SFMBlocks.CABLE_BLOCK.get()); + helper.setBlock(cable2Pos, SFMBlocks.CABLE_BLOCK.get()); + helper.setBlock(cable3Pos, SFMBlocks.CABLE_BLOCK.get()); + helper.setBlock(cable4Pos, SFMBlocks.CABLE_BLOCK.get()); + + // Get block entities + LibraryBlockEntity library = (LibraryBlockEntity) helper.getBlockEntity(libraryPos); + ManagerBlockEntity manager = (ManagerBlockEntity) helper.getBlockEntity(managerPos); + + // Create library disk with protocols, struct, and macro + ItemStack libraryDisk = new ItemStack(SFMItems.DISK_ITEM.get()); + DiskItem.setProgram(libraryDisk, """ + NAME "transfer_lib" + + protocol HasInput + input: sidequalifier slotqualifier + end + + protocol HasOutput + output: sidequalifier slotqualifier + end + + struct Processor : HasInput, HasOutput + input: EACH SIDE SLOTS 0-8 + output: EACH SIDE SLOTS 9-17 + end + + macro transfer_through(machine: HasInput, machine2: HasOutput, src, dst) + input from src + output to machine using input + forget + input from machine2 using output + output to dst + end + """); + library.setItem(0, libraryDisk); + + // Create manager disk that imports the library and uses the macro + manager.setItem(0, new ItemStack(SFMItems.DISK_ITEM.get())); + manager.setProgram(""" + NAME "Transfer Manager" + + use library "transfer_lib" + + let processor = Processor + + every 20 ticks do + DO transfer_through(processor, processor, input_chest, output_chest) + end + """); + + // Setup labels + LabelPositionHolder labelHolder = LabelPositionHolder.empty() + .add("input_chest", helper.absolutePos(inputPos)) + .add("processor", helper.absolutePos(processorPos)) + .add("output_chest", helper.absolutePos(outputPos)); + labelHolder.save(manager.getDisk()); + + // Put items in input chest + var inputHandler = helper.getItemHandler(inputPos); + inputHandler.insertItem(0, new ItemStack(Blocks.IRON_ORE, 64), false); + + // Simulate processed items in processor's output slots + var processorHandler = helper.getItemHandler(processorPos); + processorHandler.insertItem(9, new ItemStack(Blocks.IRON_BLOCK, 8), false); + + // Wait for the library to compile and manager to run + helper.runAfterDelay(10, () -> { + // Verify library disk has no errors + ItemStack compiledLibraryDisk = library.getItem(0); + assertTrue( + DiskItem.getErrors(compiledLibraryDisk).isEmpty(), + "Library disk should have no errors but had: " + DiskItem.getErrors(compiledLibraryDisk) + ); + + // Verify manager disk has no errors + assertTrue( + DiskItem.getErrors(manager.getDisk()).isEmpty(), + "Manager disk should have no errors but had: " + DiskItem.getErrors(manager.getDisk()) + ); + + helper.succeedIfManagerDidThingWithoutLagging(manager, () -> { + // Verify items were moved from input to processor's input slots + assertTrue( + count(inputHandler, Blocks.IRON_ORE) == 0, + "Input should be empty but has " + count(inputHandler, Blocks.IRON_ORE) + " iron ore" + ); + assertTrue( + count(processorHandler, Blocks.IRON_ORE) == 64, + "Processor should have 64 iron ore in input slots but has " + count(processorHandler, Blocks.IRON_ORE) + ); + + // Verify items were moved from processor's output slots to output + var outputHandler = helper.getItemHandler(outputPos); + assertTrue( + count(outputHandler, Blocks.IRON_BLOCK) == 8, + "Output should have 8 iron blocks but has " + count(outputHandler, Blocks.IRON_BLOCK) + ); + assertTrue( + count(processorHandler, Blocks.IRON_BLOCK) == 0, + "Processor output slots should be empty but has " + count(processorHandler, Blocks.IRON_BLOCK) + ); + }); + }); + } +} diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/tests/struct/MacroBasicExpansionGameTest.java b/src/gametest/java/ca/teamdman/sfm/gametest/tests/struct/MacroBasicExpansionGameTest.java new file mode 100644 index 000000000..9f626695f --- /dev/null +++ b/src/gametest/java/ca/teamdman/sfm/gametest/tests/struct/MacroBasicExpansionGameTest.java @@ -0,0 +1,49 @@ +package ca.teamdman.sfm.gametest.tests.struct; + +import ca.teamdman.sfm.gametest.LeftRightManagerTest; +import ca.teamdman.sfm.gametest.SFMGameTest; +import ca.teamdman.sfm.gametest.SFMGameTestDefinition; +import ca.teamdman.sfm.gametest.SFMGameTestHelper; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.Blocks; + +import java.util.Arrays; +import java.util.Collections; + +/** + * Tests that macro expansion works at runtime. + * The macro transfers items from 'left' to 'right' using the expand statement. + */ +@SuppressWarnings("ArraysAsListWithZeroOrOneArgument") +@SFMGameTest +public class MacroBasicExpansionGameTest extends SFMGameTestDefinition { + @Override + public String template() { + return "3x2x1"; + } + + @Override + public void run(SFMGameTestHelper helper) { + var test = new LeftRightManagerTest(helper); + test.setProgram(""" + NAME "Macro Basic Expansion Test" + + macro transfer(source, dest) + input from source + output to dest + end + + every 20 ticks do + do transfer(left, right) + end + """); + test.preContents("left", Arrays.asList( + new ItemStack(Blocks.DIRT, 64) + )); + test.postContents("left", Collections.emptyList()); + test.postContents("right", Arrays.asList( + new ItemStack(Blocks.DIRT, 64) + )); + test.run(); + } +} diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/tests/struct/MacroWithStructAccessGameTest.java b/src/gametest/java/ca/teamdman/sfm/gametest/tests/struct/MacroWithStructAccessGameTest.java new file mode 100644 index 000000000..ef026e0cc --- /dev/null +++ b/src/gametest/java/ca/teamdman/sfm/gametest/tests/struct/MacroWithStructAccessGameTest.java @@ -0,0 +1,62 @@ +package ca.teamdman.sfm.gametest.tests.struct; + +import ca.teamdman.sfm.gametest.LeftRightManagerTest; +import ca.teamdman.sfm.gametest.SFMGameTest; +import ca.teamdman.sfm.gametest.SFMGameTestDefinition; +import ca.teamdman.sfm.gametest.SFMGameTestHelper; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.Blocks; + +import java.util.Arrays; +import java.util.Collections; + +/** + * Tests that macro expansion works with struct access using the USING keyword. + * This tests the "machine using field" syntax within macros. + */ +@SuppressWarnings("ArraysAsListWithZeroOrOneArgument") +@SFMGameTest +public class MacroWithStructAccessGameTest extends SFMGameTestDefinition { + @Override + public String template() { + return "3x2x1"; + } + + @Override + public void run(SFMGameTestHelper helper) { + var test = new LeftRightManagerTest(helper); + test.setProgram(""" + NAME "Macro with Struct Access Test" + + protocol Container + main: sidequalifier slotqualifier + end + + struct Chest : Container + main: EACH SIDE SLOTS 0-26 + end + + macro process(machine: Container, source, dest) + input from source + output to machine using main + forget + input from machine using main + output to dest + end + + let left = Chest + + every 20 ticks do + do process(left, left, right) + end + """); + test.preContents("left", Arrays.asList( + new ItemStack(Blocks.DIRT, 64) + )); + test.postContents("left", Collections.emptyList()); + test.postContents("right", Arrays.asList( + new ItemStack(Blocks.DIRT, 64) + )); + test.run(); + } +} diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/tests/struct/package-info.java b/src/gametest/java/ca/teamdman/sfm/gametest/tests/struct/package-info.java new file mode 100644 index 000000000..62183791a --- /dev/null +++ b/src/gametest/java/ca/teamdman/sfm/gametest/tests/struct/package-info.java @@ -0,0 +1,10 @@ +/** + * Game tests for struct and macro functionality. + *

+ * Tests cover: + *

    + *
  • Basic macro expansion with simple parameter passing
  • + *
  • Macro expansion with struct protocol constraints and field access
  • + *
+ */ +package ca.teamdman.sfm.gametest.tests.struct; diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/tests/tunnelled_manager/TunnelledBlockCapabilityGameTestGenerator.java b/src/gametest/java/ca/teamdman/sfm/gametest/tests/tunnelled_manager/TunnelledBlockCapabilityGameTestGenerator.java index 5b16064c1..1583f62fe 100644 --- a/src/gametest/java/ca/teamdman/sfm/gametest/tests/tunnelled_manager/TunnelledBlockCapabilityGameTestGenerator.java +++ b/src/gametest/java/ca/teamdman/sfm/gametest/tests/tunnelled_manager/TunnelledBlockCapabilityGameTestGenerator.java @@ -1,142 +1,142 @@ -package ca.teamdman.sfm.gametest.tests.tunnelled_manager; - -import ca.teamdman.sfm.common.registry.SFMBlocks; -import ca.teamdman.sfm.common.util.SFMDirections; -import ca.teamdman.sfm.gametest.*; -import net.minecraft.core.BlockPos; -import net.minecraft.core.Direction; -import net.minecraft.world.item.ItemStack; -import net.minecraft.world.level.block.Block; -import net.minecraft.world.level.block.Blocks; -import net.minecraftforge.items.IItemHandler; - -import java.util.List; -import java.util.Locale; -import java.util.function.Consumer; -import java.util.function.Supplier; - -import static ca.teamdman.sfm.gametest.SFMGameTestCountHelpers.assertCount; - -/** - * Generates game tests for all tunnelled block variants (manager, cable, fancy cable, and their facade variants) - * testing item capability tunnelling in all 6 directions. - *

- * For each block variant and direction, the test: - *

    - *
  • Places the tunnelled block at a central position
  • - *
  • Places a test barrel adjacent in the specified direction
  • - *
  • Queries the item handler capability from the opposite face of the tunnelled block
  • - *
  • Inserts a cobblestone through that capability
  • - *
  • Verifies the cobblestone appears in the barrel
  • - *
- */ -@SFMGameTestGenerator -public class TunnelledBlockCapabilityGameTestGenerator extends SFMGameTestGeneratorBase { - - /** - * Record representing a tunnelled block variant to test. - */ - private record TunnelledBlockVariant( - String name, - Supplier blockSupplier - ) { - } - - /** - * All tunnelled block variants that should be tested. - */ - private static final List TUNNELLED_BLOCKS = List.of( - new TunnelledBlockVariant("tunnelled_manager", SFMBlocks.TUNNELLED_MANAGER_BLOCK::get), - new TunnelledBlockVariant("tunnelled_cable", SFMBlocks.TUNNELLED_CABLE_BLOCK::get), - new TunnelledBlockVariant("tunnelled_cable_facade", SFMBlocks.TUNNELLED_CABLE_FACADE_BLOCK::get), - new TunnelledBlockVariant("tunnelled_fancy_cable", SFMBlocks.TUNNELLED_FANCY_CABLE_BLOCK::get), - new TunnelledBlockVariant("tunnelled_fancy_cable_facade", SFMBlocks.TUNNELLED_FANCY_CABLE_FACADE_BLOCK::get) - ); - - @Override - public void generateTests(Consumer testConsumer) { - - for (TunnelledBlockVariant variant : TUNNELLED_BLOCKS) { - for (Direction direction : SFMDirections.DIRECTIONS_WITHOUT_NULL) { - testConsumer.accept(new TunnelledBlockCapabilityTest(variant, direction)); - } - } - } - - /** - * A game test definition that tests capability tunnelling for a specific block variant and direction. - */ - private static class TunnelledBlockCapabilityTest extends SFMGameTestDefinition { - private final TunnelledBlockVariant variant; - private final Direction direction; - - public TunnelledBlockCapabilityTest( - TunnelledBlockVariant variant, - Direction direction - ) { - - this.variant = variant; - this.direction = direction; - } - - @Override - public String template() { - - return "3x3x3"; - } - - @Override - public String testName() { - - return variant.name + "_capability_" + direction.name().toLowerCase(Locale.ROOT); - } - - @Override - public int maxTicks() { - - return 1; - } - - @Override - public void run(SFMGameTestHelper helper) { - - // Place the tunnelled block at the center of the test area - BlockPos tunnelledPos = new BlockPos(1, 2, 1); - // Place the barrel adjacent in the specified direction - BlockPos barrelPos = tunnelledPos.relative(direction); - - // Set up blocks - helper.setBlock(tunnelledPos, variant.blockSupplier.get()); - helper.setBlock(barrelPos, SFMBlocks.TEST_BARREL_BLOCK.get()); - - // Get the item handler from the tunnelled block, querying from the opposite face - // (i.e., if barrel is to the EAST, we query the tunnelled block from its WEST face) - Direction queryFace = direction.getOpposite(); - IItemHandler tunnelledHandler = helper.getItemHandler(tunnelledPos, queryFace); - - // Insert a cobblestone through the tunnelled capability - ItemStack toInsert = new ItemStack(Blocks.COBBLESTONE, 1); - ItemStack remainder = tunnelledHandler.insertItem(0, toInsert, false); - - // Verify the insert succeeded (no remainder) - SFMGameTestMethodHelpers.assertTrue( - remainder.isEmpty(), - "Expected cobblestone to be fully inserted through tunnelled block, but had remainder: " + remainder - ); - - // Get the barrel's item handler directly to verify the item arrived - IItemHandler barrelHandler = helper.getItemHandler(barrelPos); - - // Assert the barrel now contains exactly 1 cobblestone - assertCount( - barrelHandler, - Blocks.COBBLESTONE, - 1, - "Barrel should contain 1 cobblestone after insertion through " + variant.name - + " from " + queryFace + " face" - ); - - helper.succeed(); - } - } -} +package ca.teamdman.sfm.gametest.tests.tunnelled_manager; + +import ca.teamdman.sfm.common.registry.SFMBlocks; +import ca.teamdman.sfm.common.util.SFMDirections; +import ca.teamdman.sfm.gametest.*; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraftforge.items.IItemHandler; + +import java.util.List; +import java.util.Locale; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import static ca.teamdman.sfm.gametest.SFMGameTestCountHelpers.assertCount; + +/** + * Generates game tests for all tunnelled block variants (manager, cable, fancy cable, and their facade variants) + * testing item capability tunnelling in all 6 directions. + *

+ * For each block variant and direction, the test: + *

    + *
  • Places the tunnelled block at a central position
  • + *
  • Places a test barrel adjacent in the specified direction
  • + *
  • Queries the item handler capability from the opposite face of the tunnelled block
  • + *
  • Inserts a cobblestone through that capability
  • + *
  • Verifies the cobblestone appears in the barrel
  • + *
+ */ +@SFMGameTestGenerator +public class TunnelledBlockCapabilityGameTestGenerator extends SFMGameTestGeneratorBase { + + /** + * Record representing a tunnelled block variant to test. + */ + private record TunnelledBlockVariant( + String name, + Supplier blockSupplier + ) { + } + + /** + * All tunnelled block variants that should be tested. + */ + private static final List TUNNELLED_BLOCKS = List.of( + new TunnelledBlockVariant("tunnelled_manager", SFMBlocks.TUNNELLED_MANAGER_BLOCK::get), + new TunnelledBlockVariant("tunnelled_cable", SFMBlocks.TUNNELLED_CABLE_BLOCK::get), + new TunnelledBlockVariant("tunnelled_cable_facade", SFMBlocks.TUNNELLED_CABLE_FACADE_BLOCK::get), + new TunnelledBlockVariant("tunnelled_fancy_cable", SFMBlocks.TUNNELLED_FANCY_CABLE_BLOCK::get), + new TunnelledBlockVariant("tunnelled_fancy_cable_facade", SFMBlocks.TUNNELLED_FANCY_CABLE_FACADE_BLOCK::get) + ); + + @Override + public void generateTests(Consumer testConsumer) { + + for (TunnelledBlockVariant variant : TUNNELLED_BLOCKS) { + for (Direction direction : SFMDirections.DIRECTIONS_WITHOUT_NULL) { + testConsumer.accept(new TunnelledBlockCapabilityTest(variant, direction)); + } + } + } + + /** + * A game test definition that tests capability tunnelling for a specific block variant and direction. + */ + private static class TunnelledBlockCapabilityTest extends SFMGameTestDefinition { + private final TunnelledBlockVariant variant; + private final Direction direction; + + public TunnelledBlockCapabilityTest( + TunnelledBlockVariant variant, + Direction direction + ) { + + this.variant = variant; + this.direction = direction; + } + + @Override + public String template() { + + return "3x3x3"; + } + + @Override + public String testName() { + + return variant.name + "_capability_" + direction.name().toLowerCase(Locale.ROOT); + } + + @Override + public int maxTicks() { + + return 1; + } + + @Override + public void run(SFMGameTestHelper helper) { + + // Place the tunnelled block at the center of the test area + BlockPos tunnelledPos = new BlockPos(1, 2, 1); + // Place the barrel adjacent in the specified direction + BlockPos barrelPos = tunnelledPos.relative(direction); + + // Set up blocks + helper.setBlock(tunnelledPos, variant.blockSupplier.get()); + helper.setBlock(barrelPos, SFMBlocks.TEST_BARREL_BLOCK.get()); + + // Get the item handler from the tunnelled block, querying from the opposite face + // (i.e., if barrel is to the EAST, we query the tunnelled block from its WEST face) + Direction queryFace = direction.getOpposite(); + IItemHandler tunnelledHandler = helper.getItemHandler(tunnelledPos, queryFace); + + // Insert a cobblestone through the tunnelled capability + ItemStack toInsert = new ItemStack(Blocks.COBBLESTONE, 1); + ItemStack remainder = tunnelledHandler.insertItem(0, toInsert, false); + + // Verify the insert succeeded (no remainder) + SFMGameTestMethodHelpers.assertTrue( + remainder.isEmpty(), + "Expected cobblestone to be fully inserted through tunnelled block, but had remainder: " + remainder + ); + + // Get the barrel's item handler directly to verify the item arrived + IItemHandler barrelHandler = helper.getItemHandler(barrelPos); + + // Assert the barrel now contains exactly 1 cobblestone + assertCount( + barrelHandler, + Blocks.COBBLESTONE, + 1, + "Barrel should contain 1 cobblestone after insertion through " + variant.name + + " from " + queryFace + " face" + ); + + helper.succeed(); + } + } +} diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/tests/water_tanks/WaterTankCapacityScalingGameTest.java b/src/gametest/java/ca/teamdman/sfm/gametest/tests/water_tanks/WaterTankCapacityScalingGameTest.java index cee08a874..8917c326d 100644 --- a/src/gametest/java/ca/teamdman/sfm/gametest/tests/water_tanks/WaterTankCapacityScalingGameTest.java +++ b/src/gametest/java/ca/teamdman/sfm/gametest/tests/water_tanks/WaterTankCapacityScalingGameTest.java @@ -1,152 +1,152 @@ -package ca.teamdman.sfm.gametest.tests.water_tanks; - -import ca.teamdman.sfm.common.blockentity.WaterTankBlockEntity; -import ca.teamdman.sfm.common.registry.SFMBlocks; -import ca.teamdman.sfm.gametest.SFMGameTest; -import ca.teamdman.sfm.gametest.SFMGameTestDefinition; -import ca.teamdman.sfm.gametest.SFMGameTestHelper; -import net.minecraft.core.BlockPos; -import net.minecraft.world.level.block.Blocks; - -import static ca.teamdman.sfm.gametest.SFMGameTestMethodHelpers.assertTrue; - -/** - * Tests that water tank capacity scales correctly with active member count. - *

- * Capacity formula: 2^(activeMemberCount - 1) * 1000 - * - 0 active: 0 capacity - * - 1 active: 1000 capacity - * - 2 active: 2000 capacity - * - 3 active: 4000 capacity - * - 4 active: 8000 capacity - * - 5 active: 16000 capacity - *

- * This test places 5 water tanks in a row and progressively activates them - * by adding water sources, verifying capacity at each step. - */ -@SuppressWarnings({ - "RedundantSuppression", - "DataFlowIssue", - "OptionalGetWithoutIsPresent", - "DuplicatedCode" -}) -@SFMGameTest -public class WaterTankCapacityScalingGameTest extends SFMGameTestDefinition { - - @Override - public String template() { - // 9 wide x 2 tall x 5 deep to contain water properly - return "9x2x5"; - } - - @Override - public void run(SFMGameTestHelper helper) { - // Build containment walls - // Stone walls on all sides to prevent water flow - for (int x = 0; x < 9; x++) { - helper.setBlock(new BlockPos(x, 2, 0), Blocks.STONE); - helper.setBlock(new BlockPos(x, 2, 4), Blocks.STONE); - } - for (int z = 0; z < 5; z++) { - helper.setBlock(new BlockPos(0, 2, z), Blocks.STONE); - helper.setBlock(new BlockPos(8, 2, z), Blocks.STONE); - } - - // Place 5 water tanks in a row (x=2 to x=6, z=2) - BlockPos[] tankPositions = new BlockPos[5]; - for (int i = 0; i < 5; i++) { - BlockPos pos = new BlockPos(i + 2, 2, 2); - tankPositions[i] = pos; - helper.setBlock(pos, SFMBlocks.WATER_TANK_BLOCK.get()); - } - - // Initially all tanks are inactive, capacity should be 0 - assertAllTanksHaveCapacity(helper, tankPositions, 0, "initial state (0 active)"); - - // Activate first tank by adding 2 water sources around it - helper.setBlock(new BlockPos(2, 2, 1), Blocks.WATER); // above tank 0 - helper.setBlock(new BlockPos(2, 2, 3), Blocks.WATER); // below tank 0 - - // 1 active member: capacity = 2^0 * 1000 = 1000 - assertAllTanksHaveCapacity(helper, tankPositions, 1000, "1 active member"); - assertTankActive(helper, tankPositions[0], true, "tank 0 should be active"); - for (int i = 1; i < 5; i++) { - assertTankActive(helper, tankPositions[i], false, "tank " + i + " should be inactive"); - } - - // Activate second tank - helper.setBlock(new BlockPos(3, 2, 1), Blocks.WATER); // above tank 1 - helper.setBlock(new BlockPos(3, 2, 3), Blocks.WATER); // below tank 1 - - // 2 active members: capacity = 2^1 * 1000 = 2000 - assertAllTanksHaveCapacity(helper, tankPositions, 2000, "2 active members"); - - // Activate third tank - helper.setBlock(new BlockPos(4, 2, 1), Blocks.WATER); // above tank 2 - helper.setBlock(new BlockPos(4, 2, 3), Blocks.WATER); // below tank 2 - - // 3 active members: capacity = 2^2 * 1000 = 4000 - assertAllTanksHaveCapacity(helper, tankPositions, 4000, "3 active members"); - - // Activate fourth tank - helper.setBlock(new BlockPos(5, 2, 1), Blocks.WATER); // above tank 3 - helper.setBlock(new BlockPos(5, 2, 3), Blocks.WATER); // below tank 3 - - // 4 active members: capacity = 2^3 * 1000 = 8000 - assertAllTanksHaveCapacity(helper, tankPositions, 8000, "4 active members"); - - // Activate fifth tank - helper.setBlock(new BlockPos(6, 2, 1), Blocks.WATER); // above tank 4 - helper.setBlock(new BlockPos(6, 2, 3), Blocks.WATER); // below tank 4 - - // 5 active members: capacity = 2^4 * 1000 = 16000 - assertAllTanksHaveCapacity(helper, tankPositions, 16000, "5 active members"); - - // Now deactivate tanks one by one by removing water - // Remove water from tank 4 (last one) - helper.setBlock(new BlockPos(6, 2, 1), Blocks.AIR); - helper.setBlock(new BlockPos(6, 2, 3), Blocks.AIR); - - // Back to 4 active members: capacity = 8000 - assertAllTanksHaveCapacity(helper, tankPositions, 8000, "back to 4 active members"); - - // Remove water from tank 0 (first one) - helper.setBlock(new BlockPos(2, 2, 1), Blocks.AIR); - helper.setBlock(new BlockPos(2, 2, 3), Blocks.AIR); - - // 3 active members: capacity = 4000 - assertAllTanksHaveCapacity(helper, tankPositions, 4000, "3 active members after removing first"); - - helper.succeed(); - } - - private void assertAllTanksHaveCapacity( - SFMGameTestHelper helper, - BlockPos[] tankPositions, - int expectedCapacity, - String context - ) { - for (int i = 0; i < tankPositions.length; i++) { - WaterTankBlockEntity tank = (WaterTankBlockEntity) helper.getBlockEntity(tankPositions[i]); - assertTrue( - tank != null, - "Tank " + i + " should exist (" + context + ")" - ); - assertTrue( - tank.TANK.getCapacity() == expectedCapacity, - "Tank " + i + " should have capacity " + expectedCapacity + " but had " + tank.TANK.getCapacity() + " (" + context + ")" - ); - } - } - - private void assertTankActive( - SFMGameTestHelper helper, - BlockPos pos, - boolean expectedActive, - String message - ) { - WaterTankBlockEntity tank = (WaterTankBlockEntity) helper.getBlockEntity(pos); - assertTrue(tank != null, "Tank should exist for active check"); - assertTrue(tank.isActive() == expectedActive, message); - } -} +package ca.teamdman.sfm.gametest.tests.water_tanks; + +import ca.teamdman.sfm.common.blockentity.WaterTankBlockEntity; +import ca.teamdman.sfm.common.registry.SFMBlocks; +import ca.teamdman.sfm.gametest.SFMGameTest; +import ca.teamdman.sfm.gametest.SFMGameTestDefinition; +import ca.teamdman.sfm.gametest.SFMGameTestHelper; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.Blocks; + +import static ca.teamdman.sfm.gametest.SFMGameTestMethodHelpers.assertTrue; + +/** + * Tests that water tank capacity scales correctly with active member count. + *

+ * Capacity formula: 2^(activeMemberCount - 1) * 1000 + * - 0 active: 0 capacity + * - 1 active: 1000 capacity + * - 2 active: 2000 capacity + * - 3 active: 4000 capacity + * - 4 active: 8000 capacity + * - 5 active: 16000 capacity + *

+ * This test places 5 water tanks in a row and progressively activates them + * by adding water sources, verifying capacity at each step. + */ +@SuppressWarnings({ + "RedundantSuppression", + "DataFlowIssue", + "OptionalGetWithoutIsPresent", + "DuplicatedCode" +}) +@SFMGameTest +public class WaterTankCapacityScalingGameTest extends SFMGameTestDefinition { + + @Override + public String template() { + // 9 wide x 2 tall x 5 deep to contain water properly + return "9x2x5"; + } + + @Override + public void run(SFMGameTestHelper helper) { + // Build containment walls + // Stone walls on all sides to prevent water flow + for (int x = 0; x < 9; x++) { + helper.setBlock(new BlockPos(x, 2, 0), Blocks.STONE); + helper.setBlock(new BlockPos(x, 2, 4), Blocks.STONE); + } + for (int z = 0; z < 5; z++) { + helper.setBlock(new BlockPos(0, 2, z), Blocks.STONE); + helper.setBlock(new BlockPos(8, 2, z), Blocks.STONE); + } + + // Place 5 water tanks in a row (x=2 to x=6, z=2) + BlockPos[] tankPositions = new BlockPos[5]; + for (int i = 0; i < 5; i++) { + BlockPos pos = new BlockPos(i + 2, 2, 2); + tankPositions[i] = pos; + helper.setBlock(pos, SFMBlocks.WATER_TANK_BLOCK.get()); + } + + // Initially all tanks are inactive, capacity should be 0 + assertAllTanksHaveCapacity(helper, tankPositions, 0, "initial state (0 active)"); + + // Activate first tank by adding 2 water sources around it + helper.setBlock(new BlockPos(2, 2, 1), Blocks.WATER); // above tank 0 + helper.setBlock(new BlockPos(2, 2, 3), Blocks.WATER); // below tank 0 + + // 1 active member: capacity = 2^0 * 1000 = 1000 + assertAllTanksHaveCapacity(helper, tankPositions, 1000, "1 active member"); + assertTankActive(helper, tankPositions[0], true, "tank 0 should be active"); + for (int i = 1; i < 5; i++) { + assertTankActive(helper, tankPositions[i], false, "tank " + i + " should be inactive"); + } + + // Activate second tank + helper.setBlock(new BlockPos(3, 2, 1), Blocks.WATER); // above tank 1 + helper.setBlock(new BlockPos(3, 2, 3), Blocks.WATER); // below tank 1 + + // 2 active members: capacity = 2^1 * 1000 = 2000 + assertAllTanksHaveCapacity(helper, tankPositions, 2000, "2 active members"); + + // Activate third tank + helper.setBlock(new BlockPos(4, 2, 1), Blocks.WATER); // above tank 2 + helper.setBlock(new BlockPos(4, 2, 3), Blocks.WATER); // below tank 2 + + // 3 active members: capacity = 2^2 * 1000 = 4000 + assertAllTanksHaveCapacity(helper, tankPositions, 4000, "3 active members"); + + // Activate fourth tank + helper.setBlock(new BlockPos(5, 2, 1), Blocks.WATER); // above tank 3 + helper.setBlock(new BlockPos(5, 2, 3), Blocks.WATER); // below tank 3 + + // 4 active members: capacity = 2^3 * 1000 = 8000 + assertAllTanksHaveCapacity(helper, tankPositions, 8000, "4 active members"); + + // Activate fifth tank + helper.setBlock(new BlockPos(6, 2, 1), Blocks.WATER); // above tank 4 + helper.setBlock(new BlockPos(6, 2, 3), Blocks.WATER); // below tank 4 + + // 5 active members: capacity = 2^4 * 1000 = 16000 + assertAllTanksHaveCapacity(helper, tankPositions, 16000, "5 active members"); + + // Now deactivate tanks one by one by removing water + // Remove water from tank 4 (last one) + helper.setBlock(new BlockPos(6, 2, 1), Blocks.AIR); + helper.setBlock(new BlockPos(6, 2, 3), Blocks.AIR); + + // Back to 4 active members: capacity = 8000 + assertAllTanksHaveCapacity(helper, tankPositions, 8000, "back to 4 active members"); + + // Remove water from tank 0 (first one) + helper.setBlock(new BlockPos(2, 2, 1), Blocks.AIR); + helper.setBlock(new BlockPos(2, 2, 3), Blocks.AIR); + + // 3 active members: capacity = 4000 + assertAllTanksHaveCapacity(helper, tankPositions, 4000, "3 active members after removing first"); + + helper.succeed(); + } + + private void assertAllTanksHaveCapacity( + SFMGameTestHelper helper, + BlockPos[] tankPositions, + int expectedCapacity, + String context + ) { + for (int i = 0; i < tankPositions.length; i++) { + WaterTankBlockEntity tank = (WaterTankBlockEntity) helper.getBlockEntity(tankPositions[i]); + assertTrue( + tank != null, + "Tank " + i + " should exist (" + context + ")" + ); + assertTrue( + tank.TANK.getCapacity() == expectedCapacity, + "Tank " + i + " should have capacity " + expectedCapacity + " but had " + tank.TANK.getCapacity() + " (" + context + ")" + ); + } + } + + private void assertTankActive( + SFMGameTestHelper helper, + BlockPos pos, + boolean expectedActive, + String message + ) { + WaterTankBlockEntity tank = (WaterTankBlockEntity) helper.getBlockEntity(pos); + assertTrue(tank != null, "Tank should exist for active check"); + assertTrue(tank.isActive() == expectedActive, message); + } +} diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/tests/water_tanks/WaterTankNetworkFormationGameTest.java b/src/gametest/java/ca/teamdman/sfm/gametest/tests/water_tanks/WaterTankNetworkFormationGameTest.java index 429665c6a..934ff44b6 100644 --- a/src/gametest/java/ca/teamdman/sfm/gametest/tests/water_tanks/WaterTankNetworkFormationGameTest.java +++ b/src/gametest/java/ca/teamdman/sfm/gametest/tests/water_tanks/WaterTankNetworkFormationGameTest.java @@ -1,162 +1,162 @@ -package ca.teamdman.sfm.gametest.tests.water_tanks; - -import ca.teamdman.sfm.common.blockentity.WaterTankBlockEntity; -import ca.teamdman.sfm.common.registry.SFMBlocks; -import ca.teamdman.sfm.gametest.SFMGameTest; -import ca.teamdman.sfm.gametest.SFMGameTestDefinition; -import ca.teamdman.sfm.gametest.SFMGameTestHelper; -import net.minecraft.core.BlockPos; -import net.minecraft.world.level.block.Blocks; - -import static ca.teamdman.sfm.gametest.SFMGameTestMethodHelpers.assertTrue; - -/** - * Tests water tank network formation, splitting, and merging behavior. - *

- * This test verifies: - * 1. Water tanks form a single network when touching - * 2. Breaking a tank splits the network appropriately - * 3. Repairing a tank merges networks back together - * 4. Plus-shaped networks split into 4 separate networks when the center is removed - */ -@SuppressWarnings({ - "RedundantSuppression", - "DataFlowIssue", - "OptionalGetWithoutIsPresent", - "DuplicatedCode" -}) -@SFMGameTest -public class WaterTankNetworkFormationGameTest extends SFMGameTestDefinition { - - @Override - public String template() { - // Need enough space for the plus-shaped network test - return "15x2x15"; - } - - @Override - public void run(SFMGameTestHelper helper) { - // Create a row of 5 water tanks (without water - testing network formation only) - for (int i = 0; i < 5; i++) { - helper.setBlock(new BlockPos(i, 2, 0), SFMBlocks.WATER_TANK_BLOCK.get()); - } - - // All tanks should be in the same network - WaterTankBlockEntity firstTank = (WaterTankBlockEntity) helper.getBlockEntity(new BlockPos(0, 2, 0)); - assertTrue(firstTank != null, "First tank should exist"); - - // Get capacity - with 0 active members, capacity should be 0 - // But the network should still exist and contain all 5 tanks - for (int i = 0; i < 5; i++) { - WaterTankBlockEntity tank = (WaterTankBlockEntity) helper.getBlockEntity(new BlockPos(i, 2, 0)); - assertTrue(tank != null, "Tank " + i + " should exist"); - // All inactive tanks should have capacity 0 - assertTrue( - tank.TANK.getCapacity() == 0, - "Inactive tank " + i + " should have capacity 0 but had " + tank.TANK.getCapacity() - ); - } - - // Break the middle tank (position 2) to split the network - helper.setBlock(new BlockPos(2, 2, 0), Blocks.AIR); - - // The tanks should now be in separate networks - // Network 1: positions 0, 1 (2 tanks) - // Network 2: positions 3, 4 (2 tanks) - // All remain inactive (no water), so capacity should still be 0 for all - for (int i = 0; i < 2; i++) { - WaterTankBlockEntity tank = (WaterTankBlockEntity) helper.getBlockEntity(new BlockPos(i, 2, 0)); - assertTrue(tank != null, "Tank " + i + " should still exist after split"); - assertTrue( - tank.TANK.getCapacity() == 0, - "Tank " + i + " should have capacity 0 after split" - ); - } - for (int i = 3; i < 5; i++) { - WaterTankBlockEntity tank = (WaterTankBlockEntity) helper.getBlockEntity(new BlockPos(i, 2, 0)); - assertTrue(tank != null, "Tank " + i + " should still exist after split"); - assertTrue( - tank.TANK.getCapacity() == 0, - "Tank " + i + " should have capacity 0 after split" - ); - } - - // Repair the network by placing the middle tank back - helper.setBlock(new BlockPos(2, 2, 0), SFMBlocks.WATER_TANK_BLOCK.get()); - - // All tanks should be in the same network again - for (int i = 0; i < 5; i++) { - WaterTankBlockEntity tank = (WaterTankBlockEntity) helper.getBlockEntity(new BlockPos(i, 2, 0)); - assertTrue(tank != null, "Tank " + i + " should exist after repair"); - } - - // ===== Plus-shaped network test ===== - // Create a plus shape at position (10, 2, 7) - center of a 5x5 area offset to avoid the row - BlockPos center = new BlockPos(10, 2, 7); - BlockPos north = center.north(); // (10, 2, 6) - BlockPos south = center.south(); // (10, 2, 8) - BlockPos east = center.east(); // (11, 2, 7) - BlockPos west = center.west(); // (9, 2, 7) - - // Place the plus shape - helper.setBlock(center, SFMBlocks.WATER_TANK_BLOCK.get()); - helper.setBlock(north, SFMBlocks.WATER_TANK_BLOCK.get()); - helper.setBlock(south, SFMBlocks.WATER_TANK_BLOCK.get()); - helper.setBlock(east, SFMBlocks.WATER_TANK_BLOCK.get()); - helper.setBlock(west, SFMBlocks.WATER_TANK_BLOCK.get()); - - // All 5 tanks in the plus should be in the same network - WaterTankBlockEntity centerTank = (WaterTankBlockEntity) helper.getBlockEntity(center); - WaterTankBlockEntity northTank = (WaterTankBlockEntity) helper.getBlockEntity(north); - WaterTankBlockEntity southTank = (WaterTankBlockEntity) helper.getBlockEntity(south); - WaterTankBlockEntity eastTank = (WaterTankBlockEntity) helper.getBlockEntity(east); - WaterTankBlockEntity westTank = (WaterTankBlockEntity) helper.getBlockEntity(west); - - assertTrue(centerTank != null, "Center tank should exist"); - assertTrue(northTank != null, "North tank should exist"); - assertTrue(southTank != null, "South tank should exist"); - assertTrue(eastTank != null, "East tank should exist"); - assertTrue(westTank != null, "West tank should exist"); - - // Remove the center tank - this should split into 4 separate networks - helper.setBlock(center, Blocks.AIR); - - // Each of the 4 tanks should now be in its own network - // Since they're all inactive (no water) and alone, capacity should be 0 - northTank = (WaterTankBlockEntity) helper.getBlockEntity(north); - southTank = (WaterTankBlockEntity) helper.getBlockEntity(south); - eastTank = (WaterTankBlockEntity) helper.getBlockEntity(east); - westTank = (WaterTankBlockEntity) helper.getBlockEntity(west); - - assertTrue(northTank != null, "North tank should still exist after removing center"); - assertTrue(southTank != null, "South tank should still exist after removing center"); - assertTrue(eastTank != null, "East tank should still exist after removing center"); - assertTrue(westTank != null, "West tank should still exist after removing center"); - - // All should have capacity 0 since they're inactive - assertTrue( - northTank.TANK.getCapacity() == 0, - "North tank should have capacity 0 after split" - ); - assertTrue( - southTank.TANK.getCapacity() == 0, - "South tank should have capacity 0 after split" - ); - assertTrue( - eastTank.TANK.getCapacity() == 0, - "East tank should have capacity 0 after split" - ); - assertTrue( - westTank.TANK.getCapacity() == 0, - "West tank should have capacity 0 after split" - ); - - // Restore the center - all 5 should merge back into one network - helper.setBlock(center, SFMBlocks.WATER_TANK_BLOCK.get()); - - centerTank = (WaterTankBlockEntity) helper.getBlockEntity(center); - assertTrue(centerTank != null, "Center tank should exist after restoration"); - - helper.succeed(); - } -} +package ca.teamdman.sfm.gametest.tests.water_tanks; + +import ca.teamdman.sfm.common.blockentity.WaterTankBlockEntity; +import ca.teamdman.sfm.common.registry.SFMBlocks; +import ca.teamdman.sfm.gametest.SFMGameTest; +import ca.teamdman.sfm.gametest.SFMGameTestDefinition; +import ca.teamdman.sfm.gametest.SFMGameTestHelper; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.Blocks; + +import static ca.teamdman.sfm.gametest.SFMGameTestMethodHelpers.assertTrue; + +/** + * Tests water tank network formation, splitting, and merging behavior. + *

+ * This test verifies: + * 1. Water tanks form a single network when touching + * 2. Breaking a tank splits the network appropriately + * 3. Repairing a tank merges networks back together + * 4. Plus-shaped networks split into 4 separate networks when the center is removed + */ +@SuppressWarnings({ + "RedundantSuppression", + "DataFlowIssue", + "OptionalGetWithoutIsPresent", + "DuplicatedCode" +}) +@SFMGameTest +public class WaterTankNetworkFormationGameTest extends SFMGameTestDefinition { + + @Override + public String template() { + // Need enough space for the plus-shaped network test + return "15x2x15"; + } + + @Override + public void run(SFMGameTestHelper helper) { + // Create a row of 5 water tanks (without water - testing network formation only) + for (int i = 0; i < 5; i++) { + helper.setBlock(new BlockPos(i, 2, 0), SFMBlocks.WATER_TANK_BLOCK.get()); + } + + // All tanks should be in the same network + WaterTankBlockEntity firstTank = (WaterTankBlockEntity) helper.getBlockEntity(new BlockPos(0, 2, 0)); + assertTrue(firstTank != null, "First tank should exist"); + + // Get capacity - with 0 active members, capacity should be 0 + // But the network should still exist and contain all 5 tanks + for (int i = 0; i < 5; i++) { + WaterTankBlockEntity tank = (WaterTankBlockEntity) helper.getBlockEntity(new BlockPos(i, 2, 0)); + assertTrue(tank != null, "Tank " + i + " should exist"); + // All inactive tanks should have capacity 0 + assertTrue( + tank.TANK.getCapacity() == 0, + "Inactive tank " + i + " should have capacity 0 but had " + tank.TANK.getCapacity() + ); + } + + // Break the middle tank (position 2) to split the network + helper.setBlock(new BlockPos(2, 2, 0), Blocks.AIR); + + // The tanks should now be in separate networks + // Network 1: positions 0, 1 (2 tanks) + // Network 2: positions 3, 4 (2 tanks) + // All remain inactive (no water), so capacity should still be 0 for all + for (int i = 0; i < 2; i++) { + WaterTankBlockEntity tank = (WaterTankBlockEntity) helper.getBlockEntity(new BlockPos(i, 2, 0)); + assertTrue(tank != null, "Tank " + i + " should still exist after split"); + assertTrue( + tank.TANK.getCapacity() == 0, + "Tank " + i + " should have capacity 0 after split" + ); + } + for (int i = 3; i < 5; i++) { + WaterTankBlockEntity tank = (WaterTankBlockEntity) helper.getBlockEntity(new BlockPos(i, 2, 0)); + assertTrue(tank != null, "Tank " + i + " should still exist after split"); + assertTrue( + tank.TANK.getCapacity() == 0, + "Tank " + i + " should have capacity 0 after split" + ); + } + + // Repair the network by placing the middle tank back + helper.setBlock(new BlockPos(2, 2, 0), SFMBlocks.WATER_TANK_BLOCK.get()); + + // All tanks should be in the same network again + for (int i = 0; i < 5; i++) { + WaterTankBlockEntity tank = (WaterTankBlockEntity) helper.getBlockEntity(new BlockPos(i, 2, 0)); + assertTrue(tank != null, "Tank " + i + " should exist after repair"); + } + + // ===== Plus-shaped network test ===== + // Create a plus shape at position (10, 2, 7) - center of a 5x5 area offset to avoid the row + BlockPos center = new BlockPos(10, 2, 7); + BlockPos north = center.north(); // (10, 2, 6) + BlockPos south = center.south(); // (10, 2, 8) + BlockPos east = center.east(); // (11, 2, 7) + BlockPos west = center.west(); // (9, 2, 7) + + // Place the plus shape + helper.setBlock(center, SFMBlocks.WATER_TANK_BLOCK.get()); + helper.setBlock(north, SFMBlocks.WATER_TANK_BLOCK.get()); + helper.setBlock(south, SFMBlocks.WATER_TANK_BLOCK.get()); + helper.setBlock(east, SFMBlocks.WATER_TANK_BLOCK.get()); + helper.setBlock(west, SFMBlocks.WATER_TANK_BLOCK.get()); + + // All 5 tanks in the plus should be in the same network + WaterTankBlockEntity centerTank = (WaterTankBlockEntity) helper.getBlockEntity(center); + WaterTankBlockEntity northTank = (WaterTankBlockEntity) helper.getBlockEntity(north); + WaterTankBlockEntity southTank = (WaterTankBlockEntity) helper.getBlockEntity(south); + WaterTankBlockEntity eastTank = (WaterTankBlockEntity) helper.getBlockEntity(east); + WaterTankBlockEntity westTank = (WaterTankBlockEntity) helper.getBlockEntity(west); + + assertTrue(centerTank != null, "Center tank should exist"); + assertTrue(northTank != null, "North tank should exist"); + assertTrue(southTank != null, "South tank should exist"); + assertTrue(eastTank != null, "East tank should exist"); + assertTrue(westTank != null, "West tank should exist"); + + // Remove the center tank - this should split into 4 separate networks + helper.setBlock(center, Blocks.AIR); + + // Each of the 4 tanks should now be in its own network + // Since they're all inactive (no water) and alone, capacity should be 0 + northTank = (WaterTankBlockEntity) helper.getBlockEntity(north); + southTank = (WaterTankBlockEntity) helper.getBlockEntity(south); + eastTank = (WaterTankBlockEntity) helper.getBlockEntity(east); + westTank = (WaterTankBlockEntity) helper.getBlockEntity(west); + + assertTrue(northTank != null, "North tank should still exist after removing center"); + assertTrue(southTank != null, "South tank should still exist after removing center"); + assertTrue(eastTank != null, "East tank should still exist after removing center"); + assertTrue(westTank != null, "West tank should still exist after removing center"); + + // All should have capacity 0 since they're inactive + assertTrue( + northTank.TANK.getCapacity() == 0, + "North tank should have capacity 0 after split" + ); + assertTrue( + southTank.TANK.getCapacity() == 0, + "South tank should have capacity 0 after split" + ); + assertTrue( + eastTank.TANK.getCapacity() == 0, + "East tank should have capacity 0 after split" + ); + assertTrue( + westTank.TANK.getCapacity() == 0, + "West tank should have capacity 0 after split" + ); + + // Restore the center - all 5 should merge back into one network + helper.setBlock(center, SFMBlocks.WATER_TANK_BLOCK.get()); + + centerTank = (WaterTankBlockEntity) helper.getBlockEntity(center); + assertTrue(centerTank != null, "Center tank should exist after restoration"); + + helper.succeed(); + } +} diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/tests/water_tanks/WaterTankPlusSplitGameTest.java b/src/gametest/java/ca/teamdman/sfm/gametest/tests/water_tanks/WaterTankPlusSplitGameTest.java index 075fbbedf..cc41614ee 100644 --- a/src/gametest/java/ca/teamdman/sfm/gametest/tests/water_tanks/WaterTankPlusSplitGameTest.java +++ b/src/gametest/java/ca/teamdman/sfm/gametest/tests/water_tanks/WaterTankPlusSplitGameTest.java @@ -1,178 +1,178 @@ -package ca.teamdman.sfm.gametest.tests.water_tanks; - -import ca.teamdman.sfm.common.blockentity.WaterTankBlockEntity; -import ca.teamdman.sfm.common.registry.SFMBlocks; -import ca.teamdman.sfm.gametest.SFMGameTest; -import ca.teamdman.sfm.gametest.SFMGameTestDefinition; -import ca.teamdman.sfm.gametest.SFMGameTestHelper; -import net.minecraft.core.BlockPos; -import net.minecraft.world.level.block.Blocks; - -import static ca.teamdman.sfm.gametest.SFMGameTestMethodHelpers.assertTrue; - -/** - * Tests a plus-shaped water tank network with water, verifying that removing - * the center tank splits the network into 4 separate networks, each with their - * own capacity based on active member count. - *

- * Structure: - *

- *     W
- *    WTW
- *   WTTTW
- *    WTW
- *     W
- * 
- * Where T = water tank, W = water source (with appropriate containment) - */ -@SuppressWarnings({ - "RedundantSuppression", - "DataFlowIssue", - "OptionalGetWithoutIsPresent", - "DuplicatedCode" -}) -@SFMGameTest -public class WaterTankPlusSplitGameTest extends SFMGameTestDefinition { - - @Override - public String template() { - // Need space for plus shape with water around it and containment - return "11x2x11"; - } - - @Override - public void run(SFMGameTestHelper helper) { - // Center of the structure - int cx = 5; - int cz = 5; - int y = 2; - - // Build containment walls (stone ring around the entire structure) - for (int x = 2; x <= 8; x++) { - helper.setBlock(new BlockPos(x, y, 2), Blocks.STONE); - helper.setBlock(new BlockPos(x, y, 8), Blocks.STONE); - } - for (int z = 2; z <= 8; z++) { - helper.setBlock(new BlockPos(2, y, z), Blocks.STONE); - helper.setBlock(new BlockPos(8, y, z), Blocks.STONE); - } - - // Place water tanks in plus shape - BlockPos center = new BlockPos(cx, y, cz); - BlockPos north = new BlockPos(cx, y, cz - 1); - BlockPos south = new BlockPos(cx, y, cz + 1); - BlockPos east = new BlockPos(cx + 1, y, cz); - BlockPos west = new BlockPos(cx - 1, y, cz); - - helper.setBlock(center, SFMBlocks.WATER_TANK_BLOCK.get()); - helper.setBlock(north, SFMBlocks.WATER_TANK_BLOCK.get()); - helper.setBlock(south, SFMBlocks.WATER_TANK_BLOCK.get()); - helper.setBlock(east, SFMBlocks.WATER_TANK_BLOCK.get()); - helper.setBlock(west, SFMBlocks.WATER_TANK_BLOCK.get()); - - // Place water sources around each tank (need 2 per tank to activate) - // Center tank already has 4 tank neighbors, so it needs water in diagonal positions - // Actually, center tank touches 4 other tanks, not water. Let's place water at corners. - - // Water around the plus shape - filling in the gaps - // North tank needs water at (cx, y, cz-2) and either (cx-1, y, cz-1) or (cx+1, y, cz-1) - helper.setBlock(new BlockPos(cx, y, cz - 2), Blocks.WATER); // north of north tank - helper.setBlock(new BlockPos(cx - 1, y, cz - 1), Blocks.WATER); // west of north tank - helper.setBlock(new BlockPos(cx + 1, y, cz - 1), Blocks.WATER); // east of north tank - - // South tank - helper.setBlock(new BlockPos(cx, y, cz + 2), Blocks.WATER); // south of south tank - helper.setBlock(new BlockPos(cx - 1, y, cz + 1), Blocks.WATER); // west of south tank - helper.setBlock(new BlockPos(cx + 1, y, cz + 1), Blocks.WATER); // east of south tank - - // East tank - helper.setBlock(new BlockPos(cx + 2, y, cz), Blocks.WATER); // east of east tank - helper.setBlock(new BlockPos(cx + 1, y, cz - 1), Blocks.WATER); // already placed (north-east) - helper.setBlock(new BlockPos(cx + 1, y, cz + 1), Blocks.WATER); // already placed (south-east) - - // West tank - helper.setBlock(new BlockPos(cx - 2, y, cz), Blocks.WATER); // west of west tank - helper.setBlock(new BlockPos(cx - 1, y, cz - 1), Blocks.WATER); // already placed (north-west) - helper.setBlock(new BlockPos(cx - 1, y, cz + 1), Blocks.WATER); // already placed (south-west) - - // Now verify tanks are active - // North, South, East, West should each have 2 water sources - // Center has 0 water sources (surrounded by other tanks), so it's inactive - WaterTankBlockEntity centerTank = (WaterTankBlockEntity) helper.getBlockEntity(center); - WaterTankBlockEntity northTank = (WaterTankBlockEntity) helper.getBlockEntity(north); - WaterTankBlockEntity southTank = (WaterTankBlockEntity) helper.getBlockEntity(south); - WaterTankBlockEntity eastTank = (WaterTankBlockEntity) helper.getBlockEntity(east); - WaterTankBlockEntity westTank = (WaterTankBlockEntity) helper.getBlockEntity(west); - - assertTrue(centerTank != null, "Center tank should exist"); - assertTrue(northTank != null, "North tank should exist"); - assertTrue(southTank != null, "South tank should exist"); - assertTrue(eastTank != null, "East tank should exist"); - assertTrue(westTank != null, "West tank should exist"); - - // Center is inactive (no water touching it directly) - assertTrue(!centerTank.isActive(), "Center tank should be inactive (no water touching)"); - assertTrue(northTank.isActive(), "North tank should be active"); - assertTrue(southTank.isActive(), "South tank should be active"); - assertTrue(eastTank.isActive(), "East tank should be active"); - assertTrue(westTank.isActive(), "West tank should be active"); - - // 4 active members in the network: capacity = 2^3 * 1000 = 8000 - int expectedCapacity = 8000; - assertTrue( - centerTank.TANK.getCapacity() == expectedCapacity, - "Center tank should have capacity " + expectedCapacity + " but had " + centerTank.TANK.getCapacity() - ); - assertTrue( - northTank.TANK.getCapacity() == expectedCapacity, - "North tank should have capacity " + expectedCapacity + " but had " + northTank.TANK.getCapacity() - ); - - // Remove the center tank - this splits into 4 networks of 1 active tank each - helper.setBlock(center, Blocks.AIR); - - // Refresh references - northTank = (WaterTankBlockEntity) helper.getBlockEntity(north); - southTank = (WaterTankBlockEntity) helper.getBlockEntity(south); - eastTank = (WaterTankBlockEntity) helper.getBlockEntity(east); - westTank = (WaterTankBlockEntity) helper.getBlockEntity(west); - - // Each network now has 1 active member: capacity = 2^0 * 1000 = 1000 - int expectedCapacityAfterSplit = 1000; - assertTrue( - northTank.TANK.getCapacity() == expectedCapacityAfterSplit, - "North tank should have capacity " + expectedCapacityAfterSplit + " after split but had " + northTank.TANK.getCapacity() - ); - assertTrue( - southTank.TANK.getCapacity() == expectedCapacityAfterSplit, - "South tank should have capacity " + expectedCapacityAfterSplit + " after split but had " + southTank.TANK.getCapacity() - ); - assertTrue( - eastTank.TANK.getCapacity() == expectedCapacityAfterSplit, - "East tank should have capacity " + expectedCapacityAfterSplit + " after split but had " + eastTank.TANK.getCapacity() - ); - assertTrue( - westTank.TANK.getCapacity() == expectedCapacityAfterSplit, - "West tank should have capacity " + expectedCapacityAfterSplit + " after split but had " + westTank.TANK.getCapacity() - ); - - // Restore center tank - networks should merge back - helper.setBlock(center, SFMBlocks.WATER_TANK_BLOCK.get()); - - // Refresh references - centerTank = (WaterTankBlockEntity) helper.getBlockEntity(center); - northTank = (WaterTankBlockEntity) helper.getBlockEntity(north); - - // Back to 4 active members (center is still inactive): capacity = 8000 - assertTrue( - centerTank.TANK.getCapacity() == expectedCapacity, - "Center tank should have capacity " + expectedCapacity + " after merge but had " + centerTank.TANK.getCapacity() - ); - assertTrue( - northTank.TANK.getCapacity() == expectedCapacity, - "North tank should have capacity " + expectedCapacity + " after merge but had " + northTank.TANK.getCapacity() - ); - - helper.succeed(); - } -} +package ca.teamdman.sfm.gametest.tests.water_tanks; + +import ca.teamdman.sfm.common.blockentity.WaterTankBlockEntity; +import ca.teamdman.sfm.common.registry.SFMBlocks; +import ca.teamdman.sfm.gametest.SFMGameTest; +import ca.teamdman.sfm.gametest.SFMGameTestDefinition; +import ca.teamdman.sfm.gametest.SFMGameTestHelper; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.Blocks; + +import static ca.teamdman.sfm.gametest.SFMGameTestMethodHelpers.assertTrue; + +/** + * Tests a plus-shaped water tank network with water, verifying that removing + * the center tank splits the network into 4 separate networks, each with their + * own capacity based on active member count. + *

+ * Structure: + *

+ *     W
+ *    WTW
+ *   WTTTW
+ *    WTW
+ *     W
+ * 
+ * Where T = water tank, W = water source (with appropriate containment) + */ +@SuppressWarnings({ + "RedundantSuppression", + "DataFlowIssue", + "OptionalGetWithoutIsPresent", + "DuplicatedCode" +}) +@SFMGameTest +public class WaterTankPlusSplitGameTest extends SFMGameTestDefinition { + + @Override + public String template() { + // Need space for plus shape with water around it and containment + return "11x2x11"; + } + + @Override + public void run(SFMGameTestHelper helper) { + // Center of the structure + int cx = 5; + int cz = 5; + int y = 2; + + // Build containment walls (stone ring around the entire structure) + for (int x = 2; x <= 8; x++) { + helper.setBlock(new BlockPos(x, y, 2), Blocks.STONE); + helper.setBlock(new BlockPos(x, y, 8), Blocks.STONE); + } + for (int z = 2; z <= 8; z++) { + helper.setBlock(new BlockPos(2, y, z), Blocks.STONE); + helper.setBlock(new BlockPos(8, y, z), Blocks.STONE); + } + + // Place water tanks in plus shape + BlockPos center = new BlockPos(cx, y, cz); + BlockPos north = new BlockPos(cx, y, cz - 1); + BlockPos south = new BlockPos(cx, y, cz + 1); + BlockPos east = new BlockPos(cx + 1, y, cz); + BlockPos west = new BlockPos(cx - 1, y, cz); + + helper.setBlock(center, SFMBlocks.WATER_TANK_BLOCK.get()); + helper.setBlock(north, SFMBlocks.WATER_TANK_BLOCK.get()); + helper.setBlock(south, SFMBlocks.WATER_TANK_BLOCK.get()); + helper.setBlock(east, SFMBlocks.WATER_TANK_BLOCK.get()); + helper.setBlock(west, SFMBlocks.WATER_TANK_BLOCK.get()); + + // Place water sources around each tank (need 2 per tank to activate) + // Center tank already has 4 tank neighbors, so it needs water in diagonal positions + // Actually, center tank touches 4 other tanks, not water. Let's place water at corners. + + // Water around the plus shape - filling in the gaps + // North tank needs water at (cx, y, cz-2) and either (cx-1, y, cz-1) or (cx+1, y, cz-1) + helper.setBlock(new BlockPos(cx, y, cz - 2), Blocks.WATER); // north of north tank + helper.setBlock(new BlockPos(cx - 1, y, cz - 1), Blocks.WATER); // west of north tank + helper.setBlock(new BlockPos(cx + 1, y, cz - 1), Blocks.WATER); // east of north tank + + // South tank + helper.setBlock(new BlockPos(cx, y, cz + 2), Blocks.WATER); // south of south tank + helper.setBlock(new BlockPos(cx - 1, y, cz + 1), Blocks.WATER); // west of south tank + helper.setBlock(new BlockPos(cx + 1, y, cz + 1), Blocks.WATER); // east of south tank + + // East tank + helper.setBlock(new BlockPos(cx + 2, y, cz), Blocks.WATER); // east of east tank + helper.setBlock(new BlockPos(cx + 1, y, cz - 1), Blocks.WATER); // already placed (north-east) + helper.setBlock(new BlockPos(cx + 1, y, cz + 1), Blocks.WATER); // already placed (south-east) + + // West tank + helper.setBlock(new BlockPos(cx - 2, y, cz), Blocks.WATER); // west of west tank + helper.setBlock(new BlockPos(cx - 1, y, cz - 1), Blocks.WATER); // already placed (north-west) + helper.setBlock(new BlockPos(cx - 1, y, cz + 1), Blocks.WATER); // already placed (south-west) + + // Now verify tanks are active + // North, South, East, West should each have 2 water sources + // Center has 0 water sources (surrounded by other tanks), so it's inactive + WaterTankBlockEntity centerTank = (WaterTankBlockEntity) helper.getBlockEntity(center); + WaterTankBlockEntity northTank = (WaterTankBlockEntity) helper.getBlockEntity(north); + WaterTankBlockEntity southTank = (WaterTankBlockEntity) helper.getBlockEntity(south); + WaterTankBlockEntity eastTank = (WaterTankBlockEntity) helper.getBlockEntity(east); + WaterTankBlockEntity westTank = (WaterTankBlockEntity) helper.getBlockEntity(west); + + assertTrue(centerTank != null, "Center tank should exist"); + assertTrue(northTank != null, "North tank should exist"); + assertTrue(southTank != null, "South tank should exist"); + assertTrue(eastTank != null, "East tank should exist"); + assertTrue(westTank != null, "West tank should exist"); + + // Center is inactive (no water touching it directly) + assertTrue(!centerTank.isActive(), "Center tank should be inactive (no water touching)"); + assertTrue(northTank.isActive(), "North tank should be active"); + assertTrue(southTank.isActive(), "South tank should be active"); + assertTrue(eastTank.isActive(), "East tank should be active"); + assertTrue(westTank.isActive(), "West tank should be active"); + + // 4 active members in the network: capacity = 2^3 * 1000 = 8000 + int expectedCapacity = 8000; + assertTrue( + centerTank.TANK.getCapacity() == expectedCapacity, + "Center tank should have capacity " + expectedCapacity + " but had " + centerTank.TANK.getCapacity() + ); + assertTrue( + northTank.TANK.getCapacity() == expectedCapacity, + "North tank should have capacity " + expectedCapacity + " but had " + northTank.TANK.getCapacity() + ); + + // Remove the center tank - this splits into 4 networks of 1 active tank each + helper.setBlock(center, Blocks.AIR); + + // Refresh references + northTank = (WaterTankBlockEntity) helper.getBlockEntity(north); + southTank = (WaterTankBlockEntity) helper.getBlockEntity(south); + eastTank = (WaterTankBlockEntity) helper.getBlockEntity(east); + westTank = (WaterTankBlockEntity) helper.getBlockEntity(west); + + // Each network now has 1 active member: capacity = 2^0 * 1000 = 1000 + int expectedCapacityAfterSplit = 1000; + assertTrue( + northTank.TANK.getCapacity() == expectedCapacityAfterSplit, + "North tank should have capacity " + expectedCapacityAfterSplit + " after split but had " + northTank.TANK.getCapacity() + ); + assertTrue( + southTank.TANK.getCapacity() == expectedCapacityAfterSplit, + "South tank should have capacity " + expectedCapacityAfterSplit + " after split but had " + southTank.TANK.getCapacity() + ); + assertTrue( + eastTank.TANK.getCapacity() == expectedCapacityAfterSplit, + "East tank should have capacity " + expectedCapacityAfterSplit + " after split but had " + eastTank.TANK.getCapacity() + ); + assertTrue( + westTank.TANK.getCapacity() == expectedCapacityAfterSplit, + "West tank should have capacity " + expectedCapacityAfterSplit + " after split but had " + westTank.TANK.getCapacity() + ); + + // Restore center tank - networks should merge back + helper.setBlock(center, SFMBlocks.WATER_TANK_BLOCK.get()); + + // Refresh references + centerTank = (WaterTankBlockEntity) helper.getBlockEntity(center); + northTank = (WaterTankBlockEntity) helper.getBlockEntity(north); + + // Back to 4 active members (center is still inactive): capacity = 8000 + assertTrue( + centerTank.TANK.getCapacity() == expectedCapacity, + "Center tank should have capacity " + expectedCapacity + " after merge but had " + centerTank.TANK.getCapacity() + ); + assertTrue( + northTank.TANK.getCapacity() == expectedCapacity, + "North tank should have capacity " + expectedCapacity + " after merge but had " + northTank.TANK.getCapacity() + ); + + helper.succeed(); + } +} diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/tests/water_tanks/WaterTankRowWithWaterGameTest.java b/src/gametest/java/ca/teamdman/sfm/gametest/tests/water_tanks/WaterTankRowWithWaterGameTest.java index a8c3255e6..c03aa01f6 100644 --- a/src/gametest/java/ca/teamdman/sfm/gametest/tests/water_tanks/WaterTankRowWithWaterGameTest.java +++ b/src/gametest/java/ca/teamdman/sfm/gametest/tests/water_tanks/WaterTankRowWithWaterGameTest.java @@ -1,144 +1,144 @@ -package ca.teamdman.sfm.gametest.tests.water_tanks; - -import ca.teamdman.sfm.common.blockentity.WaterTankBlockEntity; -import ca.teamdman.sfm.common.registry.SFMBlocks; -import ca.teamdman.sfm.gametest.SFMGameTest; -import ca.teamdman.sfm.gametest.SFMGameTestDefinition; -import ca.teamdman.sfm.gametest.SFMGameTestHelper; -import net.minecraft.core.BlockPos; -import net.minecraft.world.level.block.Blocks; - -import static ca.teamdman.sfm.gametest.SFMGameTestMethodHelpers.assertTrue; - -/** - * Tests that water tanks correctly detect active state when surrounded by water sources. - *

- * Structure (7x2x5): - *

- * xxxxxxx
- * xaaaaax
- * xbbbbbx
- * xaaaaax
- * xxxxxxx
- * 
- * Where: - * - x = stone (containment) - * - a = water source - * - b = water tank - *

- * Tests: - * 1. All 5 tanks should be active when surrounded by water - * 2. Removing water sources should deactivate tanks - * 3. Restoring water sources should reactivate tanks - */ -@SuppressWarnings({ - "RedundantSuppression", - "DataFlowIssue", - "OptionalGetWithoutIsPresent", - "DuplicatedCode" -}) -@SFMGameTest -public class WaterTankRowWithWaterGameTest extends SFMGameTestDefinition { - - @Override - public String template() { - // 7 wide (x direction) x 2 tall (y direction) x 5 deep (z direction) - return "7x2x5"; - } - - @Override - public void run(SFMGameTestHelper helper) { - // Build the containment structure with stone - // Bottom layer (z=0): all stone - for (int x = 0; x < 7; x++) { - helper.setBlock(new BlockPos(x, 2, 0), Blocks.STONE); - } - // Top layer (z=4): all stone - for (int x = 0; x < 7; x++) { - helper.setBlock(new BlockPos(x, 2, 4), Blocks.STONE); - } - // Left wall (x=0): all stone - for (int z = 0; z < 5; z++) { - helper.setBlock(new BlockPos(0, 2, z), Blocks.STONE); - } - // Right wall (x=6): all stone - for (int z = 0; z < 5; z++) { - helper.setBlock(new BlockPos(6, 2, z), Blocks.STONE); - } - - // Place water tanks in the middle row (z=2) - BlockPos[] tankPositions = new BlockPos[5]; - for (int x = 1; x <= 5; x++) { - BlockPos pos = new BlockPos(x, 2, 2); - tankPositions[x - 1] = pos; - helper.setBlock(pos, SFMBlocks.WATER_TANK_BLOCK.get()); - } - - // Place water sources in the rows above and below the tanks (z=1 and z=3) - for (int x = 1; x <= 5; x++) { - BlockPos above = new BlockPos(x, 2, 1); - BlockPos below = new BlockPos(x, 2, 3); - helper.setBlock(above, Blocks.WATER); - helper.setBlock(below, Blocks.WATER); - } - - // Verify all tanks are active (have 2 water sources touching them) - for (int i = 0; i < 5; i++) { - WaterTankBlockEntity tank = (WaterTankBlockEntity) helper.getBlockEntity(tankPositions[i]); - assertTrue(tank != null, "Water tank block entity should exist at position " + i); - assertTrue(tank.isActive(), "Water tank " + i + " should be active when surrounded by water"); - } - - // Verify tank capacity is correct (5 active members = 2^(5-1) * 1000 = 16000) - int expectedCapacity = (int) Math.pow(2, 5 - 1) * 1000; // 16000 - for (int i = 0; i < 5; i++) { - WaterTankBlockEntity tank = (WaterTankBlockEntity) helper.getBlockEntity(tankPositions[i]); - assertTrue( - tank.TANK.getCapacity() == expectedCapacity, - "Water tank " + i + " should have capacity " + expectedCapacity + " but had " + tank.TANK.getCapacity() - ); - } - - // Remove water sources from the top row (z=1) - for (int x = 1; x <= 5; x++) { - helper.setBlock(new BlockPos(x, 2, 1), Blocks.AIR); - } - - // Tanks should now be inactive (only 1 water source touching each) - for (int i = 0; i < 5; i++) { - WaterTankBlockEntity tank = (WaterTankBlockEntity) helper.getBlockEntity(tankPositions[i]); - assertTrue(!tank.isActive(), "Water tank " + i + " should be inactive with only 1 water source"); - } - - // Verify tank capacity is now 0 (0 active members) - for (int i = 0; i < 5; i++) { - WaterTankBlockEntity tank = (WaterTankBlockEntity) helper.getBlockEntity(tankPositions[i]); - assertTrue( - tank.TANK.getCapacity() == 0, - "Water tank " + i + " should have capacity 0 when inactive but had " + tank.TANK.getCapacity() - ); - } - - // Restore water sources to the top row - for (int x = 1; x <= 5; x++) { - helper.setBlock(new BlockPos(x, 2, 1), Blocks.WATER); - } - - // Tanks should be active again - for (int i = 0; i < 5; i++) { - WaterTankBlockEntity tank = (WaterTankBlockEntity) helper.getBlockEntity(tankPositions[i]); - assertTrue(tank.isActive(), "Water tank " + i + " should be active again after restoring water"); - } - - // Verify tank capacity is restored - for (int i = 0; i < 5; i++) { - WaterTankBlockEntity tank = (WaterTankBlockEntity) helper.getBlockEntity(tankPositions[i]); - assertTrue( - tank.TANK.getCapacity() == expectedCapacity, - "Water tank " + i + " should have capacity " + expectedCapacity + " after restoring water but had " + tank.TANK.getCapacity() - ); - } - - helper.succeed(); - } -} +package ca.teamdman.sfm.gametest.tests.water_tanks; + +import ca.teamdman.sfm.common.blockentity.WaterTankBlockEntity; +import ca.teamdman.sfm.common.registry.SFMBlocks; +import ca.teamdman.sfm.gametest.SFMGameTest; +import ca.teamdman.sfm.gametest.SFMGameTestDefinition; +import ca.teamdman.sfm.gametest.SFMGameTestHelper; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.Blocks; + +import static ca.teamdman.sfm.gametest.SFMGameTestMethodHelpers.assertTrue; + +/** + * Tests that water tanks correctly detect active state when surrounded by water sources. + *

+ * Structure (7x2x5): + *

+ * xxxxxxx
+ * xaaaaax
+ * xbbbbbx
+ * xaaaaax
+ * xxxxxxx
+ * 
+ * Where: + * - x = stone (containment) + * - a = water source + * - b = water tank + *

+ * Tests: + * 1. All 5 tanks should be active when surrounded by water + * 2. Removing water sources should deactivate tanks + * 3. Restoring water sources should reactivate tanks + */ +@SuppressWarnings({ + "RedundantSuppression", + "DataFlowIssue", + "OptionalGetWithoutIsPresent", + "DuplicatedCode" +}) +@SFMGameTest +public class WaterTankRowWithWaterGameTest extends SFMGameTestDefinition { + + @Override + public String template() { + // 7 wide (x direction) x 2 tall (y direction) x 5 deep (z direction) + return "7x2x5"; + } + + @Override + public void run(SFMGameTestHelper helper) { + // Build the containment structure with stone + // Bottom layer (z=0): all stone + for (int x = 0; x < 7; x++) { + helper.setBlock(new BlockPos(x, 2, 0), Blocks.STONE); + } + // Top layer (z=4): all stone + for (int x = 0; x < 7; x++) { + helper.setBlock(new BlockPos(x, 2, 4), Blocks.STONE); + } + // Left wall (x=0): all stone + for (int z = 0; z < 5; z++) { + helper.setBlock(new BlockPos(0, 2, z), Blocks.STONE); + } + // Right wall (x=6): all stone + for (int z = 0; z < 5; z++) { + helper.setBlock(new BlockPos(6, 2, z), Blocks.STONE); + } + + // Place water tanks in the middle row (z=2) + BlockPos[] tankPositions = new BlockPos[5]; + for (int x = 1; x <= 5; x++) { + BlockPos pos = new BlockPos(x, 2, 2); + tankPositions[x - 1] = pos; + helper.setBlock(pos, SFMBlocks.WATER_TANK_BLOCK.get()); + } + + // Place water sources in the rows above and below the tanks (z=1 and z=3) + for (int x = 1; x <= 5; x++) { + BlockPos above = new BlockPos(x, 2, 1); + BlockPos below = new BlockPos(x, 2, 3); + helper.setBlock(above, Blocks.WATER); + helper.setBlock(below, Blocks.WATER); + } + + // Verify all tanks are active (have 2 water sources touching them) + for (int i = 0; i < 5; i++) { + WaterTankBlockEntity tank = (WaterTankBlockEntity) helper.getBlockEntity(tankPositions[i]); + assertTrue(tank != null, "Water tank block entity should exist at position " + i); + assertTrue(tank.isActive(), "Water tank " + i + " should be active when surrounded by water"); + } + + // Verify tank capacity is correct (5 active members = 2^(5-1) * 1000 = 16000) + int expectedCapacity = (int) Math.pow(2, 5 - 1) * 1000; // 16000 + for (int i = 0; i < 5; i++) { + WaterTankBlockEntity tank = (WaterTankBlockEntity) helper.getBlockEntity(tankPositions[i]); + assertTrue( + tank.TANK.getCapacity() == expectedCapacity, + "Water tank " + i + " should have capacity " + expectedCapacity + " but had " + tank.TANK.getCapacity() + ); + } + + // Remove water sources from the top row (z=1) + for (int x = 1; x <= 5; x++) { + helper.setBlock(new BlockPos(x, 2, 1), Blocks.AIR); + } + + // Tanks should now be inactive (only 1 water source touching each) + for (int i = 0; i < 5; i++) { + WaterTankBlockEntity tank = (WaterTankBlockEntity) helper.getBlockEntity(tankPositions[i]); + assertTrue(!tank.isActive(), "Water tank " + i + " should be inactive with only 1 water source"); + } + + // Verify tank capacity is now 0 (0 active members) + for (int i = 0; i < 5; i++) { + WaterTankBlockEntity tank = (WaterTankBlockEntity) helper.getBlockEntity(tankPositions[i]); + assertTrue( + tank.TANK.getCapacity() == 0, + "Water tank " + i + " should have capacity 0 when inactive but had " + tank.TANK.getCapacity() + ); + } + + // Restore water sources to the top row + for (int x = 1; x <= 5; x++) { + helper.setBlock(new BlockPos(x, 2, 1), Blocks.WATER); + } + + // Tanks should be active again + for (int i = 0; i < 5; i++) { + WaterTankBlockEntity tank = (WaterTankBlockEntity) helper.getBlockEntity(tankPositions[i]); + assertTrue(tank.isActive(), "Water tank " + i + " should be active again after restoring water"); + } + + // Verify tank capacity is restored + for (int i = 0; i < 5; i++) { + WaterTankBlockEntity tank = (WaterTankBlockEntity) helper.getBlockEntity(tankPositions[i]); + assertTrue( + tank.TANK.getCapacity() == expectedCapacity, + "Water tank " + i + " should have capacity " + expectedCapacity + " after restoring water but had " + tank.TANK.getCapacity() + ); + } + + helper.succeed(); + } +} diff --git a/src/generated/resources/.cache/67cce32b1c3cbbcb1f646605f4914e3f196986c2 b/src/generated/resources/.cache/67cce32b1c3cbbcb1f646605f4914e3f196986c2 index 95645b511..09f434055 100644 --- a/src/generated/resources/.cache/67cce32b1c3cbbcb1f646605f4914e3f196986c2 +++ b/src/generated/resources/.cache/67cce32b1c3cbbcb1f646605f4914e3f196986c2 @@ -1,9 +1,10 @@ -// 1.19.2 2026-01-17T15:43:25.546102 LootTables +// 1.19.2 2026-01-24T23:53:42.6110462 LootTables 530958bfa0a0340b5c16ed70d416bfe5fb8776de data/sfm/loot_tables/blocks/buffer.json 5286db740244722d7db672fc22de815fdd8ab330 data/sfm/loot_tables/blocks/cable.json 5286db740244722d7db672fc22de815fdd8ab330 data/sfm/loot_tables/blocks/cable_facade.json 9362833df3c9ef338cf4648460e7e1185154e2d7 data/sfm/loot_tables/blocks/fancy_cable.json 9362833df3c9ef338cf4648460e7e1185154e2d7 data/sfm/loot_tables/blocks/fancy_cable_facade.json +d5c24c23a8c72dc72bbb491ec361f96c38825f07 data/sfm/loot_tables/blocks/library.json ef4bb95208a13cbe51de891c102654406caee78e data/sfm/loot_tables/blocks/manager.json c0a6bdf7e2d285eb10d05e1515ffede8d52f6c0f data/sfm/loot_tables/blocks/printing_press.json fd8a55ce56649bd45a0e686b5c8c5e18e22fc445 data/sfm/loot_tables/blocks/tough_cable.json diff --git a/src/generated/resources/.cache/726a4eaaa226cb50028c5f3f85d691417b67903b b/src/generated/resources/.cache/726a4eaaa226cb50028c5f3f85d691417b67903b index d788cf77e..8885ed43c 100644 --- a/src/generated/resources/.cache/726a4eaaa226cb50028c5f3f85d691417b67903b +++ b/src/generated/resources/.cache/726a4eaaa226cb50028c5f3f85d691417b67903b @@ -1,4 +1,4 @@ -// 1.19.2 2026-01-17T15:43:25.5481014 Item Models: sfm +// 1.19.2 2026-01-25T00:08:43.9099939 Item Models: sfm c30f68ac136d6a8ae24eee7845de7afb19597988 assets/sfm/models/item/buffer.json 8518af52bb22ef1d984719f3534ff29e08cca857 assets/sfm/models/item/cable.json bee75adb2bb431a6b671015aa9bac0d68a98f5fb assets/sfm/models/item/disk.json @@ -6,6 +6,7 @@ bee75adb2bb431a6b671015aa9bac0d68a98f5fb assets/sfm/models/item/disk.json 2c8c32d785bfc59bf23083eff8838054ee580570 assets/sfm/models/item/form.json a74b029bcf88d674c6a4c4eaece9426cfd9c4533 assets/sfm/models/item/form_base.json a70b1032681b30f1066fdfb8b28870e87e51c261 assets/sfm/models/item/labelgun.json +19cd878c54f1849d9d3f4f851f09445f06afee4c assets/sfm/models/item/library.json e945e739059a159bb1d4de5af118d412b50aae6c assets/sfm/models/item/manager.json d35a3dbb1180878d85e94f3f00812e5f34a4b2da assets/sfm/models/item/network_tool.json cd81e93f92577f247bb4bd12e01494d83e0733ce assets/sfm/models/item/printing_press.json diff --git a/src/generated/resources/.cache/9fb1092f32d4fcbf9e061ffd718d4ec689c6c95e b/src/generated/resources/.cache/9fb1092f32d4fcbf9e061ffd718d4ec689c6c95e index 7355d6967..338d11fe1 100644 --- a/src/generated/resources/.cache/9fb1092f32d4fcbf9e061ffd718d4ec689c6c95e +++ b/src/generated/resources/.cache/9fb1092f32d4fcbf9e061ffd718d4ec689c6c95e @@ -1,4 +1,4 @@ -// 1.19.2 2026-01-17T15:43:25.5451014 Recipes +// 1.19.2 2026-01-25T11:49:16.7151191 Recipes 7ded111f9e17f19c22e228ed71a142b4cb9ec527 data/minecraft/advancements/recipes/sfm/tunnelled_manager_horizontal.json da2d621c83e62b6385405f9d13afb961b33c50a9 data/minecraft/advancements/recipes/sfm/tunnelled_manager_vertical.json 220658ab093bcf17e9432b2a8baf08809a7c6434 data/minecraft/advancements/recipes/sfm/uncraft_tunnelled_manager.json @@ -11,6 +11,7 @@ da2d621c83e62b6385405f9d13afb961b33c50a9 data/minecraft/advancements/recipes/sfm 3812a02ebe30dcd7536c329803d6a65d05a14800 data/sfm/advancements/recipes/sfm/disk.json b725fc4de1a90aaf2d01b0f35a1ce27f224c28d7 data/sfm/advancements/recipes/sfm/fancy_cable.json 5ab1a54cb2fe6bb9413bddf645feefb5cf9224cd data/sfm/advancements/recipes/sfm/fancy_to_cable.json +105d00768e69b0fe1ac995e483540239921560d6 data/sfm/advancements/recipes/sfm/library.json 6c7cda7b36500f3d8a4dd623c9dd6bfd14d301b4 data/sfm/advancements/recipes/sfm/manager.json 5d5c0ab9485456aafdf9c693f00786b52d883e6b data/sfm/advancements/recipes/sfm/network_tool.json 90144588e0c1cb7857f8cc7ec6a73ec36485080e data/sfm/advancements/recipes/sfm/printing_press.json @@ -30,6 +31,7 @@ ce43b2a22f3ad72b5360ec9203dbfb1e91a4a671 data/sfm/recipes/disk.json 86b45396d6e685d9f816a6da3434eb5d7f49f178 data/sfm/recipes/enchanted_book_copy.json f70a61a222119968c8534f2735fa3294e4de521b data/sfm/recipes/fancy_cable.json 33c5e1c41f605a33864e706e0a6dfb48b521d6ec data/sfm/recipes/fancy_to_cable.json +0e8e9115333e425787d46928b8af979aab9a34fd data/sfm/recipes/library.json 306641bbc2f89d40e9dc83cf126ee696ecc40300 data/sfm/recipes/manager.json 4432751f9842cae951f8b9dc8e405317ea4dc08f data/sfm/recipes/map_copy.json 7c201e42b5e054c7d1c2844e5b2647ef3b66ad0d data/sfm/recipes/network_tool.json diff --git a/src/generated/resources/.cache/bf0ffec7956ce36aba0a993095f41e47d655813b b/src/generated/resources/.cache/bf0ffec7956ce36aba0a993095f41e47d655813b index 49fa9a0a6..89ebcf5a2 100644 --- a/src/generated/resources/.cache/bf0ffec7956ce36aba0a993095f41e47d655813b +++ b/src/generated/resources/.cache/bf0ffec7956ce36aba0a993095f41e47d655813b @@ -1,9 +1,10 @@ -// 1.19.2 2026-01-17T15:43:25.5471015 Block States: sfm +// 1.19.2 2026-01-25T00:08:43.9119934 Block States: sfm 6a4e3ee4c0c8d070519225c335fe0857b0b627fd assets/sfm/blockstates/buffer.json f303dc233cde263e111cfdb2e2cbc48b93521724 assets/sfm/blockstates/cable.json f303dc233cde263e111cfdb2e2cbc48b93521724 assets/sfm/blockstates/cable_facade.json 0a672c9322f8bb4628d2c78d1f2d4edf3099aa31 assets/sfm/blockstates/fancy_cable.json 0a672c9322f8bb4628d2c78d1f2d4edf3099aa31 assets/sfm/blockstates/fancy_cable_facade.json +735956f079981e4f5ccb931bd90f54a6b2b75fd2 assets/sfm/blockstates/library.json 01e9d7d54169b7f6bbd287f4b896629f66f25305 assets/sfm/blockstates/manager.json a351025a2054b6ce2ddc21dc14734cc8b5b635bc assets/sfm/blockstates/printing_press.json 9be344941a18ad0d8dccbcd6536d52bf7295c1c8 assets/sfm/blockstates/test_barrel.json @@ -27,6 +28,7 @@ f3c25839c6554e631061f91277bc687aebc38fd7 assets/sfm/blockstates/water_tank.json a76567fd4e65d191d69cf166b98776786d47a8e2 assets/sfm/models/block/cable.json 7b8c954b87699a007b3d7aa3558bf7892b838975 assets/sfm/models/block/fancy_cable_connection.json 7ba1003f6a1c077779c8e51166e1d134874844a8 assets/sfm/models/block/fancy_cable_core.json +bacc43d799e70733161cc879b677d223120ce0d0 assets/sfm/models/block/library.json 953313d31931040aa2bd9a8742d4a54a2c632b8d assets/sfm/models/block/manager.json 369e3217e52fc9f71d3e4a83b9175bb4e36dbac0 assets/sfm/models/block/test_barrel_tank.json 0c2ab96c6a619b233e9434e6f3b30645fdee3101 assets/sfm/models/block/tough_cable.json diff --git a/src/generated/resources/.cache/c622617f6fabf890a00b9275cd5f643584a8a2c8 b/src/generated/resources/.cache/c622617f6fabf890a00b9275cd5f643584a8a2c8 index c36f7e697..3cf1a1845 100644 --- a/src/generated/resources/.cache/c622617f6fabf890a00b9275cd5f643584a8a2c8 +++ b/src/generated/resources/.cache/c622617f6fabf890a00b9275cd5f643584a8a2c8 @@ -1,2 +1,2 @@ -// 1.19.2 2026-01-24T14:42:13.2323638 Languages: en_us -46b6dd6cb820b334c84d9bd64ff079d7512046c3 assets/sfm/lang/en_us.json +// 1.19.2 2026-01-24T23:53:42.6100447 Languages: en_us +f5abacc648082ac18ffe913436c93452ff59c12e assets/sfm/lang/en_us.json diff --git a/src/generated/resources/assets/sfm/blockstates/library.json b/src/generated/resources/assets/sfm/blockstates/library.json new file mode 100644 index 000000000..660d5f632 --- /dev/null +++ b/src/generated/resources/assets/sfm/blockstates/library.json @@ -0,0 +1,19 @@ +{ + "variants": { + "facing=east": { + "model": "sfm:block/library", + "y": 90 + }, + "facing=north": { + "model": "sfm:block/library" + }, + "facing=south": { + "model": "sfm:block/library", + "y": 180 + }, + "facing=west": { + "model": "sfm:block/library", + "y": 270 + } + } +} \ No newline at end of file diff --git a/src/generated/resources/assets/sfm/lang/en_us.json b/src/generated/resources/assets/sfm/lang/en_us.json index dae15a8e3..6a17b6cde 100644 --- a/src/generated/resources/assets/sfm/lang/en_us.json +++ b/src/generated/resources/assets/sfm/lang/en_us.json @@ -4,6 +4,7 @@ "block.sfm.cable_facade": "Inventory Cable Facade", "block.sfm.fancy_cable": "Fancy Inventory Cable", "block.sfm.fancy_cable_facade": "Fancy Inventory Cable Facade", + "block.sfm.library": "SFML Library Block", "block.sfm.manager": "Factory Manager", "block.sfm.printing_press": "Printing Press", "block.sfm.printing_press.tooltip": "Place with an air gap below a downward facing piston. Extend the piston to use.", @@ -25,6 +26,7 @@ "chat.sfm.config_update_and_sync_result.internal_failure": "Something went wrong while updating the SFM config, I have no idea if changes were made. Check the server logs.", "chat.sfm.config_update_and_sync_result.invalid_config": "The provided SFM config was invalid, no changes were made.", "chat.sfm.config_update_and_sync_result.success": "Successfully updated SFM config.", + "container.sfm.library": "SFML Library", "container.sfm.manager": "Factory Manager", "container.sfm.test_barrel_tank": "Test Barrel Tank", "gui.jei.category.sfm.falling_anvil": "Falling Anvil", @@ -243,6 +245,8 @@ "program.sfm.error.compile_failed_with_errors": "Failed to compile with %d errors.", "program.sfm.error.compile_success_with_warnings": "Successfully compiled \"%s\" with %d warnings.", "program.sfm.error.disallowed_resource_type": "Program references a disallowed resource type \"%s\"", + "program.sfm.error.library_has_errors": "Library \"%s\" has compilation errors.", + "program.sfm.error.library_not_found": "Library \"%s\" not found in cable network.", "program.sfm.error.literal": "%s", "program.sfm.error.malformed_resource_type": "Program has a malformed resource type \"%s\".\nReminder: Resource types must be literals, not wildcards.", "program.sfm.error.unknown_resource_type": "Program references an unknown resource type \"%s\"", @@ -266,6 +270,11 @@ "program.sfm.warnings.unknown_resource_id": "Resource \"%s\" was not found.", "program.sfm.warnings.unused_input_label": "Statement \"%s\" at %s inputs \"%s\" from \"%s\" but no future output statement consume \"%s\".", "program.sfm.warnings.unused_label": "Label \"%s\" is used in code but not assigned in the world.", + "program.sfm.warnings.unused_library": "Library \"%s\" is imported but none of its definitions are used.", + "program.sfm.warnings.unused_macro": "Macro \"%s\" is defined but never expanded.", + "program.sfm.warnings.unused_protocol": "Protocol \"%s\" is defined but never used.", + "program.sfm.warnings.unused_struct": "Struct \"%s\" is defined but never instantiated.", + "program.sfm.warnings.unused_struct_instance": "Struct instance \"%s\" is defined but never used in a USING clause.", "sfm.command.bust_cable_network_cache.success": "Successfully busted cable network cache.", "sfm.command.bust_water_network_cache.success": "Successfully busted water network cache.", "sfm.label_gun.view_mode.show_only_active_and_targeted": "Showing blocks with active label. Cycle mode in gui or with %s", diff --git a/src/generated/resources/assets/sfm/models/block/library.json b/src/generated/resources/assets/sfm/models/block/library.json new file mode 100644 index 000000000..95abadc4a --- /dev/null +++ b/src/generated/resources/assets/sfm/models/block/library.json @@ -0,0 +1,12 @@ +{ + "parent": "minecraft:block/cube", + "textures": { + "down": "sfm:block/library_bot", + "east": "sfm:block/library_side", + "north": "sfm:block/library_front", + "particle": "sfm:block/library_top", + "south": "sfm:block/library_back", + "up": "sfm:block/library_top", + "west": "sfm:block/library_side" + } +} \ No newline at end of file diff --git a/src/generated/resources/assets/sfm/models/item/library.json b/src/generated/resources/assets/sfm/models/item/library.json new file mode 100644 index 000000000..ac40f054b --- /dev/null +++ b/src/generated/resources/assets/sfm/models/item/library.json @@ -0,0 +1,3 @@ +{ + "parent": "sfm:block/library" +} \ No newline at end of file diff --git a/src/generated/resources/data/sfm/advancements/recipes/sfm/library.json b/src/generated/resources/data/sfm/advancements/recipes/sfm/library.json new file mode 100644 index 000000000..fa8e2c604 --- /dev/null +++ b/src/generated/resources/data/sfm/advancements/recipes/sfm/library.json @@ -0,0 +1,34 @@ +{ + "parent": "minecraft:recipes/root", + "criteria": { + "has_manager": { + "conditions": { + "items": [ + { + "items": [ + "sfm:manager" + ] + } + ] + }, + "trigger": "minecraft:inventory_changed" + }, + "has_the_recipe": { + "conditions": { + "recipe": "sfm:library" + }, + "trigger": "minecraft:recipe_unlocked" + } + }, + "requirements": [ + [ + "has_manager", + "has_the_recipe" + ] + ], + "rewards": { + "recipes": [ + "sfm:library" + ] + } +} \ No newline at end of file diff --git a/src/generated/resources/data/sfm/loot_tables/blocks/library.json b/src/generated/resources/data/sfm/loot_tables/blocks/library.json new file mode 100644 index 000000000..9bae64f51 --- /dev/null +++ b/src/generated/resources/data/sfm/loot_tables/blocks/library.json @@ -0,0 +1,20 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "bonus_rolls": 0.0, + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ], + "entries": [ + { + "type": "minecraft:item", + "name": "sfm:library" + } + ], + "rolls": 1.0 + } + ] +} \ No newline at end of file diff --git a/src/generated/resources/data/sfm/recipes/library.json b/src/generated/resources/data/sfm/recipes/library.json new file mode 100644 index 000000000..5c3c47ab1 --- /dev/null +++ b/src/generated/resources/data/sfm/recipes/library.json @@ -0,0 +1,22 @@ +{ + "type": "minecraft:crafting_shaped", + "key": { + "B": { + "item": "minecraft:bookshelf" + }, + "L": { + "item": "minecraft:lectern" + }, + "M": { + "item": "sfm:manager" + } + }, + "pattern": [ + "MBM", + "BLB", + "MBM" + ], + "result": { + "item": "sfm:library" + } +} \ No newline at end of file diff --git a/src/main/antlr/sfml/SFML.g4 b/src/main/antlr/sfml/SFML.g4 index 3c2e70f78..b6d0854aa 100644 --- a/src/main/antlr/sfml/SFML.g4 +++ b/src/main/antlr/sfml/SFML.g4 @@ -6,10 +6,71 @@ package ca.teamdman.langs; public boolean INCLUDE_UNUSED = false; // we want syntax highlighting to not break on unexpected tokens } -program : name? trigger* EOF; +program : name? library* protocolDefinition* structDefinition* macroDefinition* letStatement* trigger* EOF; + +// +// LIBRARIES +// + +library : USE LIBRARY string ; + +// +// PROTOCOL DEFINITIONS +// + +protocolDefinition : PROTOCOL identifier protocolBody END ; +protocolBody : protocolField* ; +protocolField : identifier COLON protocolFieldType ; +protocolFieldType : SIDEQUALIFIER SLOTQUALIFIER #SideAndSlotType + | SIDEQUALIFIER #SideType + | SLOTQUALIFIER #SlotType + | LABEL #LabelType + | RESOURCE #ResourceType + | NUMBERTYPE #NumberType + ; name: NAME string ; +// +// STRUCT DEFINITIONS +// + +structDefinition : STRUCT identifier (COLON identifier (COMMA identifier)*)? structBody END ; +structBody : structField* ; +structField : identifier COLON structFieldValue ; +structFieldValue : sidequalifier slotqualifier? // composite: TOP SIDE SLOTS 0 + | slotqualifier + | label // label must come before resourceIdDisjunction to match strings correctly + | resourceIdDisjunction + | number + ; + +// +// MACRO DEFINITIONS +// + +macroDefinition : MACRO identifier LPAREN macroParamList? RPAREN macroBody END ; +macroParamList : macroParam (COMMA macroParam)* ; +macroParam : identifier (COLON identifier)? ; +macroBody : macroStatement* ; +macroStatement : macroInputStatement + | macroOutputStatement + | macroIfStatement + | macroForgetStatement + ; +macroInputStatement : INPUT macroResourceLimits? FROM EACH? macroLabelAccess ; +macroOutputStatement : OUTPUT macroResourceLimits? TO EACH? macroLabelAccess ; +macroIfStatement : IF boolexpr THEN macroBody (ELSE macroBody)? END ; +macroForgetStatement : FORGET ; +macroResourceLimits : resourceLimitList ; +macroLabelAccess : identifier #MacroParamLabelAccess + | identifier USING identifier sidequalifier? slotqualifier? #MacroStructLabelAccess + ; + +letStatement : LET identifier EQ_SYMBOL structInstantiation ; +structInstantiation : identifier (WITH structFieldOverride (COMMA structFieldOverride)*)? ; +structFieldOverride : identifier COLON structFieldValue ; + // // TRIGGERS // @@ -30,8 +91,13 @@ statement : inputStatement | outputStatement | ifStatement | forgetStatement + | expandStatement ; +expandStatement : (DO | AT) identifier LPAREN expandArgList? RPAREN ; +expandArgList : expandArg (COMMA expandArg)* ; +expandArg : identifier | string ; + // IO STATEMENT forgetStatement : FORGET label? (COMMA label)* COMMA?; inputStatement : INPUT inputResourceLimits? resourceExclusion? FROM EACH? labelAccess @@ -141,7 +207,9 @@ setOp : OVERALL // // IO HELPERS // -labelAccess : label (COMMA label)* roundrobin? sidequalifier? slotqualifier?; +labelAccess : label (COMMA label)* roundrobin? sidequalifier? slotqualifier? #DirectLabelAccess + | identifier USING identifier sidequalifier? slotqualifier? #StructLabelAccess + ; roundrobin : ROUND ROBIN BY (LABEL | BLOCK); label : (identifier) #RawLabel @@ -150,7 +218,7 @@ label : (identifier) #RawLabel emptyslots : EMPTY (SLOTS | SLOT) IN ; -identifier : (IDENTIFIER | REDSTONE | GLOBAL | SECOND | SECONDS | TOP | BOTTOM | LEFT | RIGHT | FRONT | BACK) ; +identifier : (IDENTIFIER | REDSTONE | GLOBAL | SECOND | SECONDS | TOP | BOTTOM | LEFT | RIGHT | FRONT | BACK | INPUT | OUTPUT | LABEL | STRUCT | LET | USING | SLOT | SLOTS | SIDE | BLOCK | PROTOCOL | MACRO | DO | USE | LIBRARY | RESOURCE | SIDEQUALIFIER | SLOTQUALIFIER | NUMBERTYPE) ; // GENERAL string: STRING ; @@ -252,6 +320,26 @@ DO : D O ; END : E N D ; NAME : N A M E ; +// STRUCT SYMBOLS +STRUCT : S T R U C T ; +LET : L E T ; +USING : U S I N G ; + +// PROTOCOL SYMBOLS +PROTOCOL : P R O T O C O L ; +SIDEQUALIFIER : S I D E Q U A L I F I E R ; +SLOTQUALIFIER : S L O T Q U A L I F I E R ; +RESOURCE : R E S O U R C E ; +NUMBERTYPE : N U M B E R ; + +// MACRO SYMBOLS +MACRO : M A C R O ; +AT : '@' ; + +// LIBRARY SYMBOLS +USE : U S E ; +LIBRARY : L I B R A R Y ; + // GENERAL SYMBOLS // used by triggers and as a set operator EVERY : E V E R Y ; diff --git a/src/main/java/ca/teamdman/sfm/client/registry/SFMBlockColors.java b/src/main/java/ca/teamdman/sfm/client/registry/SFMBlockColors.java index d2324ebfe..367603163 100644 --- a/src/main/java/ca/teamdman/sfm/client/registry/SFMBlockColors.java +++ b/src/main/java/ca/teamdman/sfm/client/registry/SFMBlockColors.java @@ -1,16 +1,16 @@ -package ca.teamdman.sfm.client.registry; - -import ca.teamdman.sfm.client.render.FacadeBlockColor; -import ca.teamdman.sfm.common.event_bus.SFMSubscribeEvent; -import ca.teamdman.sfm.common.registry.SFMBlocks; -import ca.teamdman.sfm.common.util.SFMDist; -import net.minecraftforge.client.event.RegisterColorHandlersEvent; - -public class SFMBlockColors { - @SFMSubscribeEvent(value = SFMDist.CLIENT) - public static void registerBlockColor(RegisterColorHandlersEvent.Block event) { - FacadeBlockColor blockColor = new FacadeBlockColor(); - event.register(blockColor, SFMBlocks.CABLE_FACADE_BLOCK.get()); - event.register(blockColor, SFMBlocks.FANCY_CABLE_FACADE_BLOCK.get()); - } -} +package ca.teamdman.sfm.client.registry; + +import ca.teamdman.sfm.client.render.FacadeBlockColor; +import ca.teamdman.sfm.common.event_bus.SFMSubscribeEvent; +import ca.teamdman.sfm.common.registry.SFMBlocks; +import ca.teamdman.sfm.common.util.SFMDist; +import net.minecraftforge.client.event.RegisterColorHandlersEvent; + +public class SFMBlockColors { + @SFMSubscribeEvent(value = SFMDist.CLIENT) + public static void registerBlockColor(RegisterColorHandlersEvent.Block event) { + FacadeBlockColor blockColor = new FacadeBlockColor(); + event.register(blockColor, SFMBlocks.CABLE_FACADE_BLOCK.get()); + event.register(blockColor, SFMBlocks.FANCY_CABLE_FACADE_BLOCK.get()); + } +} diff --git a/src/main/java/ca/teamdman/sfm/client/registry/SFMBlockEntityRenderers.java b/src/main/java/ca/teamdman/sfm/client/registry/SFMBlockEntityRenderers.java index 0d0f9f6e7..bd3bb9526 100644 --- a/src/main/java/ca/teamdman/sfm/client/registry/SFMBlockEntityRenderers.java +++ b/src/main/java/ca/teamdman/sfm/client/registry/SFMBlockEntityRenderers.java @@ -1,17 +1,24 @@ -package ca.teamdman.sfm.client.registry; - -import ca.teamdman.sfm.client.render.PrintingPressBlockEntityRenderer; -import ca.teamdman.sfm.common.event_bus.SFMSubscribeEvent; -import ca.teamdman.sfm.common.registry.SFMBlockEntities; -import ca.teamdman.sfm.common.util.SFMDist; -import net.minecraftforge.client.event.EntityRenderersEvent; - -public class SFMBlockEntityRenderers { - @SFMSubscribeEvent(value = SFMDist.CLIENT) - public static void onRegisterRenderers(EntityRenderersEvent.RegisterRenderers event) { - event.registerBlockEntityRenderer( - SFMBlockEntities.PRINTING_PRESS_BLOCK_ENTITY.get(), - PrintingPressBlockEntityRenderer::new - ); - } -} +package ca.teamdman.sfm.client.registry; + +import ca.teamdman.sfm.client.render.LibraryBlockEntityRenderer; +import ca.teamdman.sfm.client.render.PrintingPressBlockEntityRenderer; +import ca.teamdman.sfm.common.event_bus.SFMSubscribeEvent; +import ca.teamdman.sfm.common.registry.SFMBlockEntities; +import ca.teamdman.sfm.common.util.SFMDist; +import net.minecraftforge.client.event.EntityRenderersEvent; + +public class SFMBlockEntityRenderers { + @SFMSubscribeEvent(value = SFMDist.CLIENT) + public static void onRegisterRenderers(EntityRenderersEvent.RegisterRenderers event) { + event.registerBlockEntityRenderer( + SFMBlockEntities.PRINTING_PRESS_BLOCK_ENTITY.get(), + PrintingPressBlockEntityRenderer::new + ); + if (SFMBlockEntities.LIBRARY_BLOCK_ENTITY != null) { + event.registerBlockEntityRenderer( + SFMBlockEntities.LIBRARY_BLOCK_ENTITY.get(), + LibraryBlockEntityRenderer::new + ); + } + } +} diff --git a/src/main/java/ca/teamdman/sfm/client/registry/SFMMenuScreens.java b/src/main/java/ca/teamdman/sfm/client/registry/SFMMenuScreens.java index ffa5beaf9..987b20602 100644 --- a/src/main/java/ca/teamdman/sfm/client/registry/SFMMenuScreens.java +++ b/src/main/java/ca/teamdman/sfm/client/registry/SFMMenuScreens.java @@ -1,5 +1,6 @@ package ca.teamdman.sfm.client.registry; +import ca.teamdman.sfm.client.screen.LibraryScreen; import ca.teamdman.sfm.client.screen.ManagerScreen; import ca.teamdman.sfm.client.screen.TestBarrelTankScreen; import ca.teamdman.sfm.common.registry.SFMMenus; @@ -9,5 +10,6 @@ public class SFMMenuScreens { public static void register() { MenuScreens.register(SFMMenus.MANAGER_MENU.get(), ManagerScreen::new); MenuScreens.register(SFMMenus.TEST_BARREL_TANK_MENU.get(), TestBarrelTankScreen::new); + MenuScreens.register(SFMMenus.LIBRARY_MENU.get(), LibraryScreen::new); } } diff --git a/src/main/java/ca/teamdman/sfm/client/render/LibraryBlockEntityRenderer.java b/src/main/java/ca/teamdman/sfm/client/render/LibraryBlockEntityRenderer.java new file mode 100644 index 000000000..e97bad483 --- /dev/null +++ b/src/main/java/ca/teamdman/sfm/client/render/LibraryBlockEntityRenderer.java @@ -0,0 +1,312 @@ +package ca.teamdman.sfm.client.render; + +import ca.teamdman.sfm.common.block.LibraryBlock; +import ca.teamdman.sfm.common.blockentity.LibraryBlockEntity; +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.vertex.*; +import com.mojang.math.Matrix4f; +import com.mojang.math.Vector3f; +import net.minecraft.client.renderer.GameRenderer; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderer; +import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; +import net.minecraft.core.Direction; +import net.minecraft.world.level.Level; + +/** + * Renders disk slot indicators on the front face of the library block. + * Shows colored lights for occupied slots: green (normal), yellow (warnings), red (errors). + * Indicators pulse with speed/intensity based on status. + */ +public class LibraryBlockEntityRenderer implements BlockEntityRenderer { + + // Indicator light colors + // Normal state: Green + private static final float NORMAL_R = 64f / 255f; + private static final float NORMAL_G = 204f / 255f; + private static final float NORMAL_B = 64f / 255f; + + // Error state: Red + private static final float ERROR_R = 204f / 255f; + private static final float ERROR_G = 64f / 255f; + private static final float ERROR_B = 64f / 255f; + + // Warning state: Yellow + private static final float WARNING_R = 204f / 255f; + private static final float WARNING_G = 204f / 255f; + private static final float WARNING_B = 64f / 255f; + + // Disk edge color (matching disk texture - dark red/burgundy) + private static final float DISK_R = 139f / 255f; + private static final float DISK_G = 35f / 255f; + private static final float DISK_B = 35f / 255f; + + // Pulse parameters for each status + private static final float NORMAL_PULSE_SPEED = 0.1f; + private static final float NORMAL_PULSE_AMPLITUDE = 0.15f; + private static final float WARNING_PULSE_SPEED = 0.2f; + private static final float WARNING_PULSE_AMPLITUDE = 0.25f; + private static final float ERROR_PULSE_SPEED = 0.4f; + private static final float ERROR_PULSE_AMPLITUDE = 0.35f; + + // Base brightness for indicators + private static final float BASE_BRIGHTNESS = 0.7f; + + // Layout constants based on 128x128 texture + // Texture generator uses: GRID_START_X=4, ROW1_Y=48, ROW2_Y=72, COL_SPACING=25 + // Slot layout: [LED 4px][divider 1px][disk area 15px] = 20px total + private static final float TEX = 128.0f; + + // Disk slot dimensions + private static final float LIGHT_SIZE_PX = 4.0f; + private static final float DISK_WIDTH_PX = 15.0f; + private static final float SLOT_WIDTH = (LIGHT_SIZE_PX + 1 + DISK_WIDTH_PX) / TEX; // 20px total + private static final float SLOT_HEIGHT = 6.0f / TEX; + private static final float COL_SPACING = 25.0f / TEX; + private static final float GRID_START_X = 4.0f / TEX; + private static final float ROW1_Y = 48.0f / TEX; + private static final float ROW2_Y = 72.0f / TEX; + + // Indicator light position (left side of slot, integrated) + private static final float LIGHT_SIZE = 4.0f / TEX; + private static final float LIGHT_OFFSET_Y = (SLOT_HEIGHT - LIGHT_SIZE) / 2.0f; + + // Disk area position (right side of slot, after LED + divider) + private static final float DISK_AREA_OFFSET_X = (LIGHT_SIZE_PX + 1) / TEX; + private static final float DISK_WIDTH = DISK_WIDTH_PX / TEX; + + // Disk line dimensions (thin red line inside disk area) + private static final float DISK_LINE_INSET = 1.0f / TEX; + private static final float DISK_LINE_HEIGHT = 2.0f / TEX; + + // Z offset to prevent z-fighting + private static final float Z_OFFSET = -0.001f; + private static final float Z_GLOW_OFFSET = -0.002f; + + // Glow effect + private static final float GLOW_SIZE_MULTIPLIER = 1.5f; + private static final float GLOW_ALPHA = 0.5f; + + public LibraryBlockEntityRenderer(BlockEntityRendererProvider.Context context) { + } + + @Override + public void render( + LibraryBlockEntity blockEntity, + float partialTick, + PoseStack poseStack, + MultiBufferSource bufferSource, + int packedLight, + int packedOverlay + ) { + int diskMask = blockEntity.getDiskSlotMask(); + if (diskMask == 0) return; + + int errorMask = blockEntity.getErrorSlotMask(); + int warningMask = blockEntity.getWarningSlotMask(); + + Direction facing = blockEntity.getBlockState().getValue(LibraryBlock.FACING); + + // Calculate time for pulsing animation + Level level = blockEntity.getLevel(); + float gameTime = level != null ? level.getGameTime() + partialTick : 0; + + poseStack.pushPose(); + + // Move to center of block + poseStack.translate(0.5, 0.5, 0.5); + + // Rotate based on facing direction + // Note: blockstate uses clockwise rotation, but renderer uses counter-clockwise, + // so EAST/WEST values are inverted (90 <-> 270) + float rotation = switch (facing) { + case NORTH -> 0; + case SOUTH -> 180; + case EAST -> 270; + case WEST -> 90; + default -> 0; + }; + poseStack.mulPose(Vector3f.YP.rotationDegrees(rotation)); + + // Move back from center + poseStack.translate(-0.5, -0.5, -0.5); + + Matrix4f matrix = poseStack.last().pose(); + + // Set up rendering for colored quads with blending for glow + RenderSystem.enableDepthTest(); + RenderSystem.enableBlend(); + RenderSystem.defaultBlendFunc(); + RenderSystem.setShader(GameRenderer::getPositionColorShader); + + Tesselator tesselator = Tesselator.getInstance(); + BufferBuilder buffer = tesselator.getBuilder(); + + // First pass: render disk lines (thin red line in each occupied slot) + buffer.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_COLOR); + + for (int slot = 0; slot < LibraryBlockEntity.DISK_SLOT_COUNT; slot++) { + if ((diskMask & (1 << slot)) != 0) { + renderDiskLine(buffer, matrix, slot); + } + } + + tesselator.end(); + + // Second pass: render glow effects (larger, semi-transparent) + buffer.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_COLOR); + + for (int slot = 0; slot < LibraryBlockEntity.DISK_SLOT_COUNT; slot++) { + if ((diskMask & (1 << slot)) != 0) { + int slotBit = 1 << slot; + float r, g, b, pulseSpeed, pulseAmplitude; + + // Priority: error (red) > warning (yellow) > normal (green) + if ((errorMask & slotBit) != 0) { + r = ERROR_R; + g = ERROR_G; + b = ERROR_B; + pulseSpeed = ERROR_PULSE_SPEED; + pulseAmplitude = ERROR_PULSE_AMPLITUDE; + } else if ((warningMask & slotBit) != 0) { + r = WARNING_R; + g = WARNING_G; + b = WARNING_B; + pulseSpeed = WARNING_PULSE_SPEED; + pulseAmplitude = WARNING_PULSE_AMPLITUDE; + } else { + r = NORMAL_R; + g = NORMAL_G; + b = NORMAL_B; + pulseSpeed = NORMAL_PULSE_SPEED; + pulseAmplitude = NORMAL_PULSE_AMPLITUDE; + } + + // Calculate pulse with slot-based phase offset for visual variety + float phaseOffset = slot * 0.5f; + float pulse = (float) (Math.sin((gameTime + phaseOffset) * pulseSpeed) * 0.5 + 0.5); + float intensity = BASE_BRIGHTNESS + pulse * pulseAmplitude; + + // Render glow (larger, semi-transparent) + renderGlow(buffer, matrix, slot, r * intensity, g * intensity, b * intensity); + } + } + + tesselator.end(); + + // Third pass: render main indicator lights + buffer.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_COLOR); + + for (int slot = 0; slot < LibraryBlockEntity.DISK_SLOT_COUNT; slot++) { + if ((diskMask & (1 << slot)) != 0) { + int slotBit = 1 << slot; + float r, g, b, pulseSpeed, pulseAmplitude; + + if ((errorMask & slotBit) != 0) { + r = ERROR_R; + g = ERROR_G; + b = ERROR_B; + pulseSpeed = ERROR_PULSE_SPEED; + pulseAmplitude = ERROR_PULSE_AMPLITUDE; + } else if ((warningMask & slotBit) != 0) { + r = WARNING_R; + g = WARNING_G; + b = WARNING_B; + pulseSpeed = WARNING_PULSE_SPEED; + pulseAmplitude = WARNING_PULSE_AMPLITUDE; + } else { + r = NORMAL_R; + g = NORMAL_G; + b = NORMAL_B; + pulseSpeed = NORMAL_PULSE_SPEED; + pulseAmplitude = NORMAL_PULSE_AMPLITUDE; + } + + float phaseOffset = slot * 0.5f; + float pulse = (float) (Math.sin((gameTime + phaseOffset) * pulseSpeed) * 0.5 + 0.5); + float intensity = BASE_BRIGHTNESS + pulse * pulseAmplitude; + + renderSlotIndicator(buffer, matrix, slot, r * intensity, g * intensity, b * intensity); + } + } + + tesselator.end(); + + RenderSystem.disableBlend(); + poseStack.popPose(); + } + + private void renderDiskLine(BufferBuilder buffer, Matrix4f matrix, int slot) { + int row = slot / 5; + int col = slot % 5; + + float slotX = GRID_START_X + col * COL_SPACING; + float slotY = (row == 0) ? ROW1_Y : ROW2_Y; + + // Disk area is on the right side of slot (after LED + divider) + float diskAreaX = slotX + DISK_AREA_OFFSET_X; + + // Thin red line inside the disk area (representing disk edge) + // Mirror X coordinate (1.0f - x) to match texture orientation on north face + float x1 = 1.0f - diskAreaX - DISK_WIDTH + DISK_LINE_INSET; + float x2 = 1.0f - diskAreaX - DISK_LINE_INSET; + float y1 = 1.0f - slotY - SLOT_HEIGHT / 2.0f - DISK_LINE_HEIGHT / 2.0f; + float y2 = 1.0f - slotY - SLOT_HEIGHT / 2.0f + DISK_LINE_HEIGHT / 2.0f; + float z = Z_OFFSET; + + buffer.vertex(matrix, x1, y1, z).color(DISK_R, DISK_G, DISK_B, 1.0f).endVertex(); + buffer.vertex(matrix, x1, y2, z).color(DISK_R, DISK_G, DISK_B, 1.0f).endVertex(); + buffer.vertex(matrix, x2, y2, z).color(DISK_R, DISK_G, DISK_B, 1.0f).endVertex(); + buffer.vertex(matrix, x2, y1, z).color(DISK_R, DISK_G, DISK_B, 1.0f).endVertex(); + } + + private void renderSlotIndicator(BufferBuilder buffer, Matrix4f matrix, int slot, float r, float g, float b) { + int row = slot / 5; + int col = slot % 5; + + float slotX = GRID_START_X + col * COL_SPACING; + float slotY = (row == 0) ? ROW1_Y : ROW2_Y; + + // LED is on the left side of slot (integrated) + // Mirror X coordinate (1.0f - x) to match texture orientation on north face + float x1 = 1.0f - slotX - LIGHT_SIZE; + float x2 = 1.0f - slotX; + float y1 = 1.0f - slotY - LIGHT_OFFSET_Y - LIGHT_SIZE; + float y2 = 1.0f - slotY - LIGHT_OFFSET_Y; + float z = Z_OFFSET; + + // Quad on the north face (z=0) + buffer.vertex(matrix, x1, y1, z).color(r, g, b, 1.0f).endVertex(); + buffer.vertex(matrix, x1, y2, z).color(r, g, b, 1.0f).endVertex(); + buffer.vertex(matrix, x2, y2, z).color(r, g, b, 1.0f).endVertex(); + buffer.vertex(matrix, x2, y1, z).color(r, g, b, 1.0f).endVertex(); + } + + private void renderGlow(BufferBuilder buffer, Matrix4f matrix, int slot, float r, float g, float b) { + int row = slot / 5; + int col = slot % 5; + + float slotX = GRID_START_X + col * COL_SPACING; + float slotY = (row == 0) ? ROW1_Y : ROW2_Y; + + // Glow centered on the LED (left side of slot) + // Mirror X coordinate (1.0f - x) to match texture orientation on north face + float lightCenterX = 1.0f - slotX - LIGHT_SIZE / 2.0f; + float lightCenterY = slotY + LIGHT_OFFSET_Y + LIGHT_SIZE / 2.0f; + + float glowSize = LIGHT_SIZE * GLOW_SIZE_MULTIPLIER; + float halfGlow = glowSize / 2.0f; + + float x1 = lightCenterX - halfGlow; + float x2 = lightCenterX + halfGlow; + float y1 = 1.0f - lightCenterY - halfGlow; + float y2 = 1.0f - lightCenterY + halfGlow; + float z = Z_GLOW_OFFSET; + + // Semi-transparent glow quad + buffer.vertex(matrix, x1, y1, z).color(r, g, b, GLOW_ALPHA).endVertex(); + buffer.vertex(matrix, x1, y2, z).color(r, g, b, GLOW_ALPHA).endVertex(); + buffer.vertex(matrix, x2, y2, z).color(r, g, b, GLOW_ALPHA).endVertex(); + buffer.vertex(matrix, x2, y1, z).color(r, g, b, GLOW_ALPHA).endVertex(); + } +} diff --git a/src/main/java/ca/teamdman/sfm/client/render/RetexturedBakedQuad.java b/src/main/java/ca/teamdman/sfm/client/render/RetexturedBakedQuad.java index ae72efd6a..0c47c8384 100644 --- a/src/main/java/ca/teamdman/sfm/client/render/RetexturedBakedQuad.java +++ b/src/main/java/ca/teamdman/sfm/client/render/RetexturedBakedQuad.java @@ -1,65 +1,65 @@ -package ca.teamdman.sfm.client.render; - - -import ca.teamdman.sfm.common.util.MCVersionDependentBehaviour; -import com.mojang.blaze3d.vertex.DefaultVertexFormat; -import net.minecraft.client.renderer.block.model.BakedQuad; -import net.minecraft.client.renderer.block.model.FaceBakery; -import net.minecraft.client.renderer.texture.TextureAtlasSprite; - -import java.util.Arrays; - -/** - * Revived from 1.14 - * - * @author Mojang - * Thanks tterrag! - * - */ -// The original file can be found at: -// https://github.com/CoFH/CoFHCore/blob/dcd7bd6703418ee2e8eb2185957de83925fa89fe/src/main/java/cofh/lib/client/renderer/block/model/RetexturedBakedQuad.java -// The license can be found at: -// https://github.com/CoFH/CoFHCore/blob/dcd7bd6703418ee2e8eb2185957de83925fa89fe/README.md -// Their don't-be-a-jerk license is compatible as far as I can tell, thanks CoFH <3 -public class RetexturedBakedQuad extends BakedQuad { - - private final TextureAtlasSprite texture; - - public RetexturedBakedQuad(BakedQuad quad, TextureAtlasSprite textureIn) { - - super(Arrays.copyOf(quad.getVertices(), quad.getVertices().length), quad.getTintIndex(), FaceBakery.calculateFacing(quad.getVertices()), quad.getSprite(), quad.isShade()); - this.texture = textureIn; - this.remapQuad(); - } - - private void remapQuad() { - - for (int i = 0; i < 4; ++i) { - int j = DefaultVertexFormat.BLOCK.getIntegerSize() * i; - int uvIndex = 4; - this.vertices[j + uvIndex] = Float.floatToRawIntBits(this.texture.getU(getUnInterpolatedU(this.sprite, Float.intBitsToFloat(this.vertices[j + uvIndex])))); - this.vertices[j + uvIndex + 1] = Float.floatToRawIntBits(this.texture.getV(getUnInterpolatedV(this.sprite, Float.intBitsToFloat(this.vertices[j + uvIndex + 1])))); - } - } - - @Override - public TextureAtlasSprite getSprite() { - - return texture; - } - - @MCVersionDependentBehaviour - private static float getUnInterpolatedU(TextureAtlasSprite sprite, float u) { - - float f = sprite.getU1() - sprite.getU0(); - return (u - sprite.getU0()) / f * 16.0F; - } - - @MCVersionDependentBehaviour - private static float getUnInterpolatedV(TextureAtlasSprite sprite, float v) { - - float f = sprite.getV1() - sprite.getV0(); - return (v - sprite.getV0()) / f * 16.0F; - } - -} +package ca.teamdman.sfm.client.render; + + +import ca.teamdman.sfm.common.util.MCVersionDependentBehaviour; +import com.mojang.blaze3d.vertex.DefaultVertexFormat; +import net.minecraft.client.renderer.block.model.BakedQuad; +import net.minecraft.client.renderer.block.model.FaceBakery; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; + +import java.util.Arrays; + +/** + * Revived from 1.14 + * + * @author Mojang + * Thanks tterrag! + * + */ +// The original file can be found at: +// https://github.com/CoFH/CoFHCore/blob/dcd7bd6703418ee2e8eb2185957de83925fa89fe/src/main/java/cofh/lib/client/renderer/block/model/RetexturedBakedQuad.java +// The license can be found at: +// https://github.com/CoFH/CoFHCore/blob/dcd7bd6703418ee2e8eb2185957de83925fa89fe/README.md +// Their don't-be-a-jerk license is compatible as far as I can tell, thanks CoFH <3 +public class RetexturedBakedQuad extends BakedQuad { + + private final TextureAtlasSprite texture; + + public RetexturedBakedQuad(BakedQuad quad, TextureAtlasSprite textureIn) { + + super(Arrays.copyOf(quad.getVertices(), quad.getVertices().length), quad.getTintIndex(), FaceBakery.calculateFacing(quad.getVertices()), quad.getSprite(), quad.isShade()); + this.texture = textureIn; + this.remapQuad(); + } + + private void remapQuad() { + + for (int i = 0; i < 4; ++i) { + int j = DefaultVertexFormat.BLOCK.getIntegerSize() * i; + int uvIndex = 4; + this.vertices[j + uvIndex] = Float.floatToRawIntBits(this.texture.getU(getUnInterpolatedU(this.sprite, Float.intBitsToFloat(this.vertices[j + uvIndex])))); + this.vertices[j + uvIndex + 1] = Float.floatToRawIntBits(this.texture.getV(getUnInterpolatedV(this.sprite, Float.intBitsToFloat(this.vertices[j + uvIndex + 1])))); + } + } + + @Override + public TextureAtlasSprite getSprite() { + + return texture; + } + + @MCVersionDependentBehaviour + private static float getUnInterpolatedU(TextureAtlasSprite sprite, float u) { + + float f = sprite.getU1() - sprite.getU0(); + return (u - sprite.getU0()) / f * 16.0F; + } + + @MCVersionDependentBehaviour + private static float getUnInterpolatedV(TextureAtlasSprite sprite, float v) { + + float f = sprite.getV1() - sprite.getV0(); + return (v - sprite.getV0()) / f * 16.0F; + } + +} diff --git a/src/main/java/ca/teamdman/sfm/client/screen/LibraryScreen.java b/src/main/java/ca/teamdman/sfm/client/screen/LibraryScreen.java new file mode 100644 index 000000000..5b754a3d6 --- /dev/null +++ b/src/main/java/ca/teamdman/sfm/client/screen/LibraryScreen.java @@ -0,0 +1,321 @@ +package ca.teamdman.sfm.client.screen; + +import ca.teamdman.sfm.client.text_editor.SFMTextEditScreenLibraryDiskOpenContext; +import ca.teamdman.sfm.common.blockentity.LibraryBlockEntity; +import ca.teamdman.sfm.common.containermenu.LibraryContainerMenu; +import ca.teamdman.sfm.common.containermenu.LibraryContainerMenu.LibraryEntry; +import ca.teamdman.sfm.common.item.DiskItem; +import ca.teamdman.sfm.common.net.ServerboundLibraryDiskSetProgramPacket; +import ca.teamdman.sfm.common.registry.SFMPackets; +import ca.teamdman.sfm.common.util.SFMResourceLocation; +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.vertex.PoseStack; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.client.renderer.GameRenderer; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.item.ItemStack; + +/** + * Client-side screen for the library block. + * Shows disk slots and displays available library names from inserted disks + * in a computer terminal-style panel on the left. + */ +public class LibraryScreen extends AbstractContainerScreen { + private static final ResourceLocation BACKGROUND_TEXTURE_LOCATION = SFMResourceLocation.fromSFMPath( + "textures/gui/container/library.png" + ); + + private static final int PANEL_WIDTH = 120; + private static final int PANEL_GAP = 4; + + // Industrial server rack color palette (matching block textures) + private static final int PANEL_BG = 0xFF1A1A1A; // Darkest background + private static final int PANEL_FRAME = 0xFF2D2D2D; // Mid panel + private static final int PANEL_HIGHLIGHT = 0xFF4A4A4A; // Edge highlights + private static final int PANEL_SHADOW = 0xFF0F0F0F; // Deep shadows + private static final int PANEL_LIGHT = 0xFF3D3D3D; // Light metal accents + + // Text colors (cool industrial) + private static final int TEXT_HEADER = 0xFF60C0C0; // Cyan accent + private static final int TEXT_PRIMARY = 0xFFE0E0E0; // Light gray + private static final int TEXT_SECONDARY = 0xFF707070; // Dim gray + private static final int TEXT_HOVER = 0xFFFFFFFF; // White on hover + private static final int TEXT_ERROR = 0xFFFF6060; // Error text + private static final int TEXT_WARNING = 0xFFE0E040; // Warning text + + // Effects + private static final int HOVER_BAR = 0x40FFFFFF; // Subtle highlight bar + private static final int ERROR_BAR = 0x30FF4040; // Error highlight + private static final int WARNING_BAR = 0x30FFFF40; // Warning highlight + + private int hoveredLibraryEntry = -1; + private int lastHoveredLibraryEntry = -1; + private long hoverStartTime = 0; + private static final long SCROLL_DELAY_MS = 500; // Wait before scrolling starts + private static final float SCROLL_SPEED = 30f; // Pixels per second + + public LibraryScreen( + LibraryContainerMenu menu, + Inventory inv, + Component title + ) { + super(menu, inv, title); + } + + @Override + protected void init() { + super.init(); + } + + @Override + protected void containerTick() { + super.containerTick(); + refreshLibraryEntries(); + } + + private void refreshLibraryEntries() { + menu.libraryEntries = LibraryContainerMenu.extractLibraryEntries( + menu.CONTAINER, LibraryBlockEntity.DISK_SLOT_COUNT); + } + + private void openEditorForSlot(int slotIndex) { + ItemStack disk = menu.CONTAINER.getItem(slotIndex); + if (!DiskItem.isValidDisk(disk)) return; + + String source = DiskItem.getProgramString(disk); + SFMScreenChangeHelpers.showProgramEditScreen(new SFMTextEditScreenLibraryDiskOpenContext( + source, + newSource -> { + SFMPackets.sendToServer(new ServerboundLibraryDiskSetProgramPacket( + menu.containerId, menu.LIBRARY_POSITION, slotIndex, newSource)); + DiskItem.setProgram(disk, newSource); + refreshLibraryEntries(); + } + )); + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + // Click on library entry in panel + if (button == 0 && hoveredLibraryEntry >= 0 && hoveredLibraryEntry < menu.libraryEntries.size()) { + LibraryEntry entry = menu.libraryEntries.get(hoveredLibraryEntry); + openEditorForSlot(entry.slotIndex()); + return true; + } + return super.mouseClicked(mouseX, mouseY, button); + } + + @Override + public void render( + PoseStack poseStack, + int mx, + int my, + float partialTicks + ) { + this.renderBackground(poseStack); + super.render(poseStack, mx, my, partialTicks); + this.renderTooltip(poseStack, mx, my); + + // Render library list in the panel + int panelX = this.leftPos - PANEL_WIDTH - PANEL_GAP; + int screenX = panelX + 5; + int screenY = this.topPos + 5; + int screenRight = panelX + PANEL_WIDTH - 5; + + // Header area with recessed metal panel effect + int headerY = screenY + 2; + fill(poseStack, screenX + 3, headerY - 2, screenRight - 3, headerY + font.lineHeight + 3, PANEL_SHADOW); + fill(poseStack, screenX + 4, headerY - 1, screenRight - 4, headerY + font.lineHeight + 2, PANEL_BG); + + // Header text - centered with cyan accent + String header = "LIBRARY INDEX"; + int headerWidth = font.width(header); + int headerX = screenX + (PANEL_WIDTH - 10 - headerWidth) / 2; + font.drawShadow(poseStack, header, headerX, headerY, TEXT_HEADER); + + // Horizontal divider lines (ventilation slit style) + int dividerY = headerY + font.lineHeight + 6; + fill(poseStack, screenX + 6, dividerY, screenRight - 6, dividerY + 1, PANEL_SHADOW); + fill(poseStack, screenX + 6, dividerY + 2, screenRight - 6, dividerY + 3, PANEL_HIGHLIGHT); + + // Content area starts below divider + int contentY = dividerY + 8; + int textX = screenX + 6; + + // Reset hover state + hoveredLibraryEntry = -1; + + if (menu.libraryEntries.isEmpty()) { + // Show hint when no libraries are available + font.drawShadow(poseStack, "No libraries found", textX, contentY, TEXT_SECONDARY); + contentY += font.lineHeight + 2; + font.drawShadow(poseStack, "Insert disks with", textX, contentY, TEXT_SECONDARY); + contentY += font.lineHeight; + font.drawShadow(poseStack, "NAME statements", textX, contentY, TEXT_SECONDARY); + } else { + // Draw library entries with cursor indicator + for (int i = 0; i < menu.libraryEntries.size(); i++) { + LibraryEntry entry = menu.libraryEntries.get(i); + + // Hover detection - use full row width + boolean hovered = mx >= screenX + 3 && mx <= screenRight - 3 + && my >= contentY - 1 && my <= contentY + font.lineHeight + 1; + + // Determine text color based on status + int textColor; + int barColor; + + if (entry.hasErrors()) { + textColor = hovered ? TEXT_ERROR : TEXT_PRIMARY; + barColor = ERROR_BAR; + } else if (entry.hasWarnings()) { + textColor = hovered ? TEXT_WARNING : TEXT_PRIMARY; + barColor = WARNING_BAR; + } else { + textColor = hovered ? TEXT_HOVER : TEXT_PRIMARY; + barColor = HOVER_BAR; + } + + // Draw hover highlight bar or status bar + if (hovered) { + hoveredLibraryEntry = i; + if (lastHoveredLibraryEntry != i) { + hoverStartTime = System.currentTimeMillis(); + lastHoveredLibraryEntry = i; + } + fill(poseStack, screenX + 3, contentY - 1, screenRight - 3, contentY + font.lineHeight + 1, barColor); + } else if (entry.hasErrors()) { + fill(poseStack, screenX + 3, contentY - 1, screenRight - 3, contentY + font.lineHeight + 1, ERROR_BAR); + } else if (entry.hasWarnings()) { + fill(poseStack, screenX + 3, contentY - 1, screenRight - 3, contentY + font.lineHeight + 1, WARNING_BAR); + } + + // Cursor indicator and library name + String cursor = hovered ? "> " : " "; + int cursorWidth = font.width(cursor); + + // Slot number right-aligned + String slotText = "[" + entry.slotIndex() + "]"; + int slotWidth = font.width(slotText); + int slotX = screenRight - 6 - slotWidth; + + // Calculate available width for library name (between cursor and slot number) + int nameStartX = textX + cursorWidth; + int maxNameWidth = slotX - nameStartX - 4; // 4px padding before slot + + String name = entry.name(); + int nameWidth = font.width(name); + + // Draw cursor + font.drawShadow(poseStack, cursor, textX, contentY, textColor); + + // Draw library name with truncation or scrolling + if (nameWidth <= maxNameWidth) { + // Name fits - draw normally + font.drawShadow(poseStack, name, nameStartX, contentY, textColor); + } else if (hovered) { + // Hovered and too long - scroll the text + long currentTime = System.currentTimeMillis(); + long hoverDuration = currentTime - hoverStartTime; + + if (hoverDuration > SCROLL_DELAY_MS) { + // Calculate scroll offset - infinite scroll with pause at start + float scrollTime = (hoverDuration - SCROLL_DELAY_MS) / 1000f; + int scrollDistance = nameWidth + 20; // Full width plus gap before repeat + float scrollOffset = (scrollTime * SCROLL_SPEED) % scrollDistance; + + // Enable scissor to clip text + enableScissor(nameStartX, contentY - 1, slotX - 4, contentY + font.lineHeight + 1); + font.drawShadow(poseStack, name, nameStartX - (int) scrollOffset, contentY, textColor); + // Draw second copy for seamless loop + font.drawShadow(poseStack, name, nameStartX - (int) scrollOffset + scrollDistance, contentY, textColor); + disableScissor(); + } else { + // Still in delay period - show truncated with ellipsis + enableScissor(nameStartX, contentY - 1, slotX - 4, contentY + font.lineHeight + 1); + font.drawShadow(poseStack, name, nameStartX, contentY, textColor); + disableScissor(); + } + } else { + // Not hovered and too long - truncate with ellipsis + String ellipsis = "..."; + int ellipsisWidth = font.width(ellipsis); + String truncated = font.plainSubstrByWidth(name, maxNameWidth - ellipsisWidth) + ellipsis; + font.drawShadow(poseStack, truncated, nameStartX, contentY, textColor); + } + + // Draw slot number + font.drawShadow(poseStack, slotText, slotX, contentY, TEXT_SECONDARY); + + contentY += font.lineHeight + 3; + + // Don't overflow past the panel + if (contentY > this.topPos + this.imageHeight - font.lineHeight - 10) break; + } + } + + // Reset scroll state when no longer hovering any entry + if (hoveredLibraryEntry == -1) { + lastHoveredLibraryEntry = -1; + } + } + + @Override + protected void renderLabels( + PoseStack poseStack, + int mx, + int my + ) { + // Draw title and inventory label with light text for readability on dark background + this.font.draw(poseStack, this.title, (float) this.titleLabelX, (float) this.titleLabelY, TEXT_PRIMARY); + this.font.draw(poseStack, this.playerInventoryTitle, (float) this.inventoryLabelX, (float) this.inventoryLabelY, TEXT_PRIMARY); + } + + @Override + protected void renderBg( + PoseStack matrixStack, + float partialTicks, + int mx, + int my + ) { + // Render the industrial metal panel frame + int panelX = this.leftPos - PANEL_WIDTH - PANEL_GAP; + int panelY = this.topPos; + int panelRight = panelX + PANEL_WIDTH; + int panelBottom = panelY + imageHeight; + + // Main panel background + fill(matrixStack, panelX, panelY, panelRight, panelBottom, PANEL_FRAME); + + // Clean outer border - dark edge + fill(matrixStack, panelX, panelY, panelRight, panelY + 1, PANEL_SHADOW); + fill(matrixStack, panelX, panelY, panelX + 1, panelBottom, PANEL_SHADOW); + fill(matrixStack, panelX, panelBottom - 1, panelRight, panelBottom, PANEL_SHADOW); + fill(matrixStack, panelRight - 1, panelY, panelRight, panelBottom, PANEL_SHADOW); + + // Inner lighter border for depth + fill(matrixStack, panelX + 1, panelY + 1, panelRight - 1, panelY + 2, PANEL_LIGHT); + fill(matrixStack, panelX + 1, panelY + 1, panelX + 2, panelBottom - 1, PANEL_LIGHT); + fill(matrixStack, panelX + 1, panelBottom - 2, panelRight - 1, panelBottom - 1, PANEL_LIGHT); + fill(matrixStack, panelRight - 2, panelY + 1, panelRight - 1, panelBottom - 1, PANEL_LIGHT); + + // Inner recessed area + int innerX = panelX + 3; + int innerY = panelY + 3; + int innerRight = panelRight - 3; + int innerBottom = panelBottom - 3; + + // Main display area (dark background) + fill(matrixStack, innerX, innerY, innerRight, innerBottom, PANEL_BG); + + // Render the main container background + RenderSystem.setShader(GameRenderer::getPositionTexShader); + RenderSystem.setShaderColor(1f, 1f, 1f, 1f); + RenderSystem.setShaderTexture(0, BACKGROUND_TEXTURE_LOCATION); + int i = (this.width - this.imageWidth) / 2; + int j = (this.height - this.imageHeight) / 2; + blit(matrixStack, i, j, 0, 0, this.imageWidth, this.imageHeight); + } +} diff --git a/src/main/java/ca/teamdman/sfm/client/text_editor/SFMTextEditScreenLibraryDiskOpenContext.java b/src/main/java/ca/teamdman/sfm/client/text_editor/SFMTextEditScreenLibraryDiskOpenContext.java new file mode 100644 index 000000000..925f3251f --- /dev/null +++ b/src/main/java/ca/teamdman/sfm/client/text_editor/SFMTextEditScreenLibraryDiskOpenContext.java @@ -0,0 +1,20 @@ +package ca.teamdman.sfm.client.text_editor; + +import ca.teamdman.sfm.common.label.LabelPositionHolder; + +import java.util.function.Consumer; + +/** + * Context for opening the text editor for a disk in a library block. + * Library disks don't have label positions, so we use an empty holder. + */ +public record SFMTextEditScreenLibraryDiskOpenContext( + String initialValue, + Consumer saveWriter +) implements ISFMTextEditScreenOpenContext { + + @Override + public LabelPositionHolder labelPositionHolder() { + return LabelPositionHolder.empty(); + } +} diff --git a/src/main/java/ca/teamdman/sfm/client/text_styling/ProgramSyntaxHighlightingHelper.java b/src/main/java/ca/teamdman/sfm/client/text_styling/ProgramSyntaxHighlightingHelper.java index 599794f14..a1dd1daf4 100644 --- a/src/main/java/ca/teamdman/sfm/client/text_styling/ProgramSyntaxHighlightingHelper.java +++ b/src/main/java/ca/teamdman/sfm/client/text_styling/ProgramSyntaxHighlightingHelper.java @@ -156,6 +156,21 @@ private static ChatFormatting getColour(Token token) { case SFMLLexer.BLOCK: case SFMLLexer.LABEL: return ChatFormatting.YELLOW; + // Protocol, macro, and library keywords + case SFMLLexer.PROTOCOL: + case SFMLLexer.STRUCT: + case SFMLLexer.LET: + case SFMLLexer.USING: + case SFMLLexer.MACRO: + case SFMLLexer.AT: + case SFMLLexer.USE: + case SFMLLexer.LIBRARY: + return ChatFormatting.BLUE; + case SFMLLexer.SIDEQUALIFIER: + case SFMLLexer.SLOTQUALIFIER: + case SFMLLexer.RESOURCE: + case SFMLLexer.NUMBERTYPE: + return ChatFormatting.DARK_PURPLE; default: return ChatFormatting.WHITE; } diff --git a/src/main/java/ca/teamdman/sfm/common/block/LibraryBlock.java b/src/main/java/ca/teamdman/sfm/common/block/LibraryBlock.java new file mode 100644 index 000000000..6c6d58af9 --- /dev/null +++ b/src/main/java/ca/teamdman/sfm/common/block/LibraryBlock.java @@ -0,0 +1,155 @@ +package ca.teamdman.sfm.common.block; + +import ca.teamdman.sfm.common.blockentity.LibraryBlockEntity; +import ca.teamdman.sfm.common.blockentity.ManagerBlockEntity; +import ca.teamdman.sfm.common.block_network.CableNetwork; +import ca.teamdman.sfm.common.block_network.CableNetworkManager; +import ca.teamdman.sfm.common.block_network.ICableBlock; +import ca.teamdman.sfm.common.containermenu.LibraryContainerMenu; +import ca.teamdman.sfm.common.registry.SFMBlockEntities; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.BaseEntityBlock; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.EntityBlock; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.SoundType; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockBehaviour; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.Containers; +import net.minecraft.world.level.block.state.StateDefinition; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.level.block.state.properties.DirectionProperty; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.material.Material; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraftforge.network.NetworkHooks; +import org.jetbrains.annotations.Nullable; + +import java.util.HashSet; +import java.util.Set; + +/** + * A block that stores SFML library definitions (protocols, structs, macros). + * Can be referenced by manager programs via "use library" statements. + * The front face shows disk slot indicators via a BlockEntityRenderer. + */ +public class LibraryBlock extends BaseEntityBlock implements EntityBlock, ICableBlock { + + /** + * Property tracking which direction the front face is facing. + */ + public static final DirectionProperty FACING = BlockStateProperties.HORIZONTAL_FACING; + + public LibraryBlock() { + super(BlockBehaviour.Properties + .of(Material.PISTON) + .destroyTime(1.5f) + .sound(SoundType.METAL)); + registerDefaultState(getStateDefinition().any().setValue(FACING, net.minecraft.core.Direction.NORTH)); + } + + @Override + protected void createBlockStateDefinition(StateDefinition.Builder builder) { + builder.add(FACING); + } + + @Override + public BlockState getStateForPlacement(BlockPlaceContext context) { + return defaultBlockState().setValue(FACING, context.getHorizontalDirection().getOpposite()); + } + + @Override + @SuppressWarnings("deprecation") + public RenderShape getRenderShape(BlockState state) { + return RenderShape.MODEL; + } + + @Override + public @Nullable BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return SFMBlockEntities.LIBRARY_BLOCK_ENTITY.get().create(pos, state); + } + + @Override + @SuppressWarnings("deprecation") + public InteractionResult use( + BlockState state, + Level level, + BlockPos pos, + Player player, + InteractionHand hand, + BlockHitResult hit + ) { + if (!level.isClientSide() && level.getBlockEntity(pos) instanceof LibraryBlockEntity library) { + NetworkHooks.openScreen( + (ServerPlayer) player, + library, + buf -> LibraryContainerMenu.encode(library, buf) + ); + } + return InteractionResult.sidedSuccess(level.isClientSide()); + } + + @Override + @SuppressWarnings("deprecation") + public void onPlace( + BlockState state, + Level level, + BlockPos pos, + BlockState oldState, + boolean isMoving + ) { + CableNetworkManager.onCablePlaced(level, pos); + // Notify managers that a library block was added + if (!level.isClientSide()) { + CableNetworkManager.getNetworksForLevel(level) + .values().stream() + .filter(network -> network.isAdjacentToCable(pos)) + .forEach(CableNetwork::invalidateAutoLabelsAndNotifyDependents); + } + } + + @Override + @SuppressWarnings("deprecation") + public void onRemove( + BlockState state, + Level level, + BlockPos pos, + BlockState newState, + boolean isMoving + ) { + if (!state.is(newState.getBlock())) { + // Capture managers to notify BEFORE removal (while library still exists in cache) + Set managersToNotify = new HashSet<>(); + if (!level.isClientSide()) { + CableNetworkManager.getNetworksForLevel(level) + .values().stream() + .filter(network -> network.isAdjacentToCable(pos)) + .forEach(network -> { + managersToNotify.addAll(network.getOrRebuildAutoLabels() + .getPositions(ManagerBlockEntity.MANAGER_LABEL)); + network.invalidateAutoLabelCache(); + }); + } + + // Drop all disks when block is broken + if (level.getBlockEntity(pos) instanceof LibraryBlockEntity library) { + Containers.dropContents(level, pos, library); + } + CableNetworkManager.onCableRemoved(level, pos); + super.onRemove(state, level, pos, newState, isMoving); + + // NOW notify managers (after library is fully removed) + for (BlockPos managerPos : managersToNotify) { + if (level.getBlockEntity(managerPos) instanceof ManagerBlockEntity manager) { + manager.rebuildProgramAndUpdateDisk(); + } + } + } + } +} diff --git a/src/main/java/ca/teamdman/sfm/common/block/ToughCableBlock.java b/src/main/java/ca/teamdman/sfm/common/block/ToughCableBlock.java index b88a6f716..50830c42d 100644 --- a/src/main/java/ca/teamdman/sfm/common/block/ToughCableBlock.java +++ b/src/main/java/ca/teamdman/sfm/common/block/ToughCableBlock.java @@ -1,19 +1,19 @@ -package ca.teamdman.sfm.common.block; - -import ca.teamdman.sfm.common.registry.SFMBlocks; - -public class ToughCableBlock extends CableBlock { - public ToughCableBlock(Properties properties) { - super(properties); - } - - @Override - public IFacadableBlock getNonFacadeBlock() { - return SFMBlocks.TOUGH_CABLE_BLOCK.get(); - } - - @Override - public IFacadableBlock getFacadeBlock() { - return SFMBlocks.TOUGH_CABLE_FACADE_BLOCK.get(); - } -} +package ca.teamdman.sfm.common.block; + +import ca.teamdman.sfm.common.registry.SFMBlocks; + +public class ToughCableBlock extends CableBlock { + public ToughCableBlock(Properties properties) { + super(properties); + } + + @Override + public IFacadableBlock getNonFacadeBlock() { + return SFMBlocks.TOUGH_CABLE_BLOCK.get(); + } + + @Override + public IFacadableBlock getFacadeBlock() { + return SFMBlocks.TOUGH_CABLE_FACADE_BLOCK.get(); + } +} diff --git a/src/main/java/ca/teamdman/sfm/common/block/ToughCableFacadeBlock.java b/src/main/java/ca/teamdman/sfm/common/block/ToughCableFacadeBlock.java index b1ce4296d..5b470d575 100644 --- a/src/main/java/ca/teamdman/sfm/common/block/ToughCableFacadeBlock.java +++ b/src/main/java/ca/teamdman/sfm/common/block/ToughCableFacadeBlock.java @@ -1,73 +1,73 @@ -package ca.teamdman.sfm.common.block; - -import ca.teamdman.sfm.common.registry.SFMBlockEntities; -import ca.teamdman.sfm.common.registry.SFMBlocks; -import net.minecraft.core.BlockPos; -import net.minecraft.world.item.ItemStack; -import net.minecraft.world.level.BlockGetter; -import net.minecraft.world.level.block.EntityBlock; -import net.minecraft.world.level.block.LightBlock; -import net.minecraft.world.level.block.entity.BlockEntity; -import net.minecraft.world.level.block.state.BlockState; -import org.jetbrains.annotations.Nullable; - -public class ToughCableFacadeBlock extends CableFacadeBlock implements EntityBlock, IFacadableBlock { - public ToughCableFacadeBlock(Properties properties) { - super(properties.lightLevel(LightBlock.LIGHT_EMISSION)); - registerDefaultState( - getStateDefinition() - .any() - .setValue( - ca.teamdman.sfm.common.facade.FacadeTransparency.FACADE_TRANSPARENCY_PROPERTY, - ca.teamdman.sfm.common.facade.FacadeTransparency.OPAQUE - ) - .setValue(LightBlock.LEVEL, 0) - ); - } - - @Override - public @Nullable BlockEntity newBlockEntity( - BlockPos blockPos, - BlockState blockState - ) { - return SFMBlockEntities.TOUGH_CABLE_FACADE_BLOCK_ENTITY.get().create(blockPos, blockState); - } - - @SuppressWarnings("deprecation") - @Override - public ItemStack getCloneItemStack( - BlockGetter pLevel, - BlockPos pPos, - BlockState pState - ) { - return new ItemStack(SFMBlocks.TOUGH_CABLE_BLOCK.get()); - } - - @Override - public IFacadableBlock getNonFacadeBlock() { - return SFMBlocks.TOUGH_CABLE_BLOCK.get(); - } - - @Override - public IFacadableBlock getFacadeBlock() { - return SFMBlocks.TOUGH_CABLE_FACADE_BLOCK.get(); - } - - // TODO: implement destroyTime to inherit from facade block state - - @Override - @SuppressWarnings("deprecation") - public float getExplosionResistance(net.minecraft.world.level.block.state.BlockState state, net.minecraft.world.level.BlockGetter world, net.minecraft.core.BlockPos pos, net.minecraft.world.level.Explosion explosion) { - try { - net.minecraft.world.level.block.entity.BlockEntity be = world.getBlockEntity(pos); - if (be instanceof ca.teamdman.sfm.common.blockentity.IFacadeBlockEntity facadeBE) { - ca.teamdman.sfm.common.facade.FacadeData fd = facadeBE.getFacadeData(); - if (fd != null) { - return fd.facadeBlockState().getBlock().getExplosionResistance(); - } - } - } catch (Throwable ignored) { - } - return super.getExplosionResistance(); - } -} +package ca.teamdman.sfm.common.block; + +import ca.teamdman.sfm.common.registry.SFMBlockEntities; +import ca.teamdman.sfm.common.registry.SFMBlocks; +import net.minecraft.core.BlockPos; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.block.EntityBlock; +import net.minecraft.world.level.block.LightBlock; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import org.jetbrains.annotations.Nullable; + +public class ToughCableFacadeBlock extends CableFacadeBlock implements EntityBlock, IFacadableBlock { + public ToughCableFacadeBlock(Properties properties) { + super(properties.lightLevel(LightBlock.LIGHT_EMISSION)); + registerDefaultState( + getStateDefinition() + .any() + .setValue( + ca.teamdman.sfm.common.facade.FacadeTransparency.FACADE_TRANSPARENCY_PROPERTY, + ca.teamdman.sfm.common.facade.FacadeTransparency.OPAQUE + ) + .setValue(LightBlock.LEVEL, 0) + ); + } + + @Override + public @Nullable BlockEntity newBlockEntity( + BlockPos blockPos, + BlockState blockState + ) { + return SFMBlockEntities.TOUGH_CABLE_FACADE_BLOCK_ENTITY.get().create(blockPos, blockState); + } + + @SuppressWarnings("deprecation") + @Override + public ItemStack getCloneItemStack( + BlockGetter pLevel, + BlockPos pPos, + BlockState pState + ) { + return new ItemStack(SFMBlocks.TOUGH_CABLE_BLOCK.get()); + } + + @Override + public IFacadableBlock getNonFacadeBlock() { + return SFMBlocks.TOUGH_CABLE_BLOCK.get(); + } + + @Override + public IFacadableBlock getFacadeBlock() { + return SFMBlocks.TOUGH_CABLE_FACADE_BLOCK.get(); + } + + // TODO: implement destroyTime to inherit from facade block state + + @Override + @SuppressWarnings("deprecation") + public float getExplosionResistance(net.minecraft.world.level.block.state.BlockState state, net.minecraft.world.level.BlockGetter world, net.minecraft.core.BlockPos pos, net.minecraft.world.level.Explosion explosion) { + try { + net.minecraft.world.level.block.entity.BlockEntity be = world.getBlockEntity(pos); + if (be instanceof ca.teamdman.sfm.common.blockentity.IFacadeBlockEntity facadeBE) { + ca.teamdman.sfm.common.facade.FacadeData fd = facadeBE.getFacadeData(); + if (fd != null) { + return fd.facadeBlockState().getBlock().getExplosionResistance(); + } + } + } catch (Throwable ignored) { + } + return super.getExplosionResistance(); + } +} diff --git a/src/main/java/ca/teamdman/sfm/common/block/ToughFancyCableBlock.java b/src/main/java/ca/teamdman/sfm/common/block/ToughFancyCableBlock.java index bb83f7018..d214488e6 100644 --- a/src/main/java/ca/teamdman/sfm/common/block/ToughFancyCableBlock.java +++ b/src/main/java/ca/teamdman/sfm/common/block/ToughFancyCableBlock.java @@ -1,19 +1,19 @@ -package ca.teamdman.sfm.common.block; - -import ca.teamdman.sfm.common.registry.SFMBlocks; - -public class ToughFancyCableBlock extends FancyCableBlock { - public ToughFancyCableBlock(Properties properties) { - super(properties); - } - - @Override - public IFacadableBlock getNonFacadeBlock() { - return SFMBlocks.TOUGH_FANCY_CABLE_BLOCK.get(); - } - - @Override - public IFacadableBlock getFacadeBlock() { - return SFMBlocks.TOUGH_FANCY_CABLE_FACADE_BLOCK.get(); - } -} +package ca.teamdman.sfm.common.block; + +import ca.teamdman.sfm.common.registry.SFMBlocks; + +public class ToughFancyCableBlock extends FancyCableBlock { + public ToughFancyCableBlock(Properties properties) { + super(properties); + } + + @Override + public IFacadableBlock getNonFacadeBlock() { + return SFMBlocks.TOUGH_FANCY_CABLE_BLOCK.get(); + } + + @Override + public IFacadableBlock getFacadeBlock() { + return SFMBlocks.TOUGH_FANCY_CABLE_FACADE_BLOCK.get(); + } +} diff --git a/src/main/java/ca/teamdman/sfm/common/block/ToughFancyCableFacadeBlock.java b/src/main/java/ca/teamdman/sfm/common/block/ToughFancyCableFacadeBlock.java index db8f7745e..a767214b6 100644 --- a/src/main/java/ca/teamdman/sfm/common/block/ToughFancyCableFacadeBlock.java +++ b/src/main/java/ca/teamdman/sfm/common/block/ToughFancyCableFacadeBlock.java @@ -1,66 +1,66 @@ -package ca.teamdman.sfm.common.block; - -import ca.teamdman.sfm.common.registry.SFMBlockEntities; -import ca.teamdman.sfm.common.registry.SFMBlocks; -import net.minecraft.core.BlockPos; -import net.minecraft.world.item.ItemStack; -import net.minecraft.world.level.BlockGetter; -import net.minecraft.world.level.block.entity.BlockEntity; -import net.minecraft.world.level.block.state.BlockState; -import org.jetbrains.annotations.Nullable; - -public class ToughFancyCableFacadeBlock extends FancyCableFacadeBlock implements IFacadableBlock, net.minecraft.world.level.block.EntityBlock { - public ToughFancyCableFacadeBlock(Properties properties) { - super(properties); - registerDefaultState( - defaultBlockState() - .setValue(ca.teamdman.sfm.common.facade.FacadeTransparency.FACADE_TRANSPARENCY_PROPERTY, ca.teamdman.sfm.common.facade.FacadeTransparency.TRANSLUCENT) - .setValue(net.minecraft.world.level.block.LightBlock.LEVEL, 0) - ); - } - - @Override - public @Nullable BlockEntity newBlockEntity( - BlockPos blockPos, - BlockState blockState - ) { - return SFMBlockEntities.TOUGH_FANCY_CABLE_FACADE_BLOCK_ENTITY.get().create(blockPos, blockState); - } - - @Override - public ItemStack getCloneItemStack( - BlockGetter pLevel, - BlockPos pPos, - BlockState pState - ) { - return new ItemStack(SFMBlocks.TOUGH_FANCY_CABLE_BLOCK.get()); - } - - @Override - public IFacadableBlock getNonFacadeBlock() { - return SFMBlocks.TOUGH_FANCY_CABLE_BLOCK.get(); - } - - @Override - public IFacadableBlock getFacadeBlock() { - return SFMBlocks.TOUGH_FANCY_CABLE_FACADE_BLOCK.get(); - } - - // TODO: implement destroyTime to inherit from facade block state - - @Override - @SuppressWarnings("deprecation") - public float getExplosionResistance(net.minecraft.world.level.block.state.BlockState state, net.minecraft.world.level.BlockGetter world, net.minecraft.core.BlockPos pos, net.minecraft.world.level.Explosion explosion) { - try { - net.minecraft.world.level.block.entity.BlockEntity be = world.getBlockEntity(pos); - if (be instanceof ca.teamdman.sfm.common.blockentity.IFacadeBlockEntity facadeBE) { - ca.teamdman.sfm.common.facade.FacadeData fd = facadeBE.getFacadeData(); - if (fd != null) { - return fd.facadeBlockState().getBlock().getExplosionResistance(); - } - } - } catch (Throwable ignored) { - } - return super.getExplosionResistance(); - } -} +package ca.teamdman.sfm.common.block; + +import ca.teamdman.sfm.common.registry.SFMBlockEntities; +import ca.teamdman.sfm.common.registry.SFMBlocks; +import net.minecraft.core.BlockPos; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import org.jetbrains.annotations.Nullable; + +public class ToughFancyCableFacadeBlock extends FancyCableFacadeBlock implements IFacadableBlock, net.minecraft.world.level.block.EntityBlock { + public ToughFancyCableFacadeBlock(Properties properties) { + super(properties); + registerDefaultState( + defaultBlockState() + .setValue(ca.teamdman.sfm.common.facade.FacadeTransparency.FACADE_TRANSPARENCY_PROPERTY, ca.teamdman.sfm.common.facade.FacadeTransparency.TRANSLUCENT) + .setValue(net.minecraft.world.level.block.LightBlock.LEVEL, 0) + ); + } + + @Override + public @Nullable BlockEntity newBlockEntity( + BlockPos blockPos, + BlockState blockState + ) { + return SFMBlockEntities.TOUGH_FANCY_CABLE_FACADE_BLOCK_ENTITY.get().create(blockPos, blockState); + } + + @Override + public ItemStack getCloneItemStack( + BlockGetter pLevel, + BlockPos pPos, + BlockState pState + ) { + return new ItemStack(SFMBlocks.TOUGH_FANCY_CABLE_BLOCK.get()); + } + + @Override + public IFacadableBlock getNonFacadeBlock() { + return SFMBlocks.TOUGH_FANCY_CABLE_BLOCK.get(); + } + + @Override + public IFacadableBlock getFacadeBlock() { + return SFMBlocks.TOUGH_FANCY_CABLE_FACADE_BLOCK.get(); + } + + // TODO: implement destroyTime to inherit from facade block state + + @Override + @SuppressWarnings("deprecation") + public float getExplosionResistance(net.minecraft.world.level.block.state.BlockState state, net.minecraft.world.level.BlockGetter world, net.minecraft.core.BlockPos pos, net.minecraft.world.level.Explosion explosion) { + try { + net.minecraft.world.level.block.entity.BlockEntity be = world.getBlockEntity(pos); + if (be instanceof ca.teamdman.sfm.common.blockentity.IFacadeBlockEntity facadeBE) { + ca.teamdman.sfm.common.facade.FacadeData fd = facadeBE.getFacadeData(); + if (fd != null) { + return fd.facadeBlockState().getBlock().getExplosionResistance(); + } + } + } catch (Throwable ignored) { + } + return super.getExplosionResistance(); + } +} diff --git a/src/main/java/ca/teamdman/sfm/common/block/TunnelledCableBlock.java b/src/main/java/ca/teamdman/sfm/common/block/TunnelledCableBlock.java index bb34f4e2e..c38c7783e 100644 --- a/src/main/java/ca/teamdman/sfm/common/block/TunnelledCableBlock.java +++ b/src/main/java/ca/teamdman/sfm/common/block/TunnelledCableBlock.java @@ -1,33 +1,33 @@ -package ca.teamdman.sfm.common.block; - -import ca.teamdman.sfm.common.registry.SFMBlockEntities; -import ca.teamdman.sfm.common.registry.SFMBlocks; -import net.minecraft.core.BlockPos; -import net.minecraft.world.level.block.EntityBlock; -import net.minecraft.world.level.block.entity.BlockEntity; -import net.minecraft.world.level.block.state.BlockState; -import org.jetbrains.annotations.Nullable; - -public class TunnelledCableBlock extends CableBlock implements EntityBlock { - public TunnelledCableBlock(Properties properties) { - super(properties); - } - - @Override - public @Nullable BlockEntity newBlockEntity( - BlockPos blockPos, - BlockState blockState - ) { - return SFMBlockEntities.TUNNELLED_CABLE_BLOCK_ENTITY.get().create(blockPos, blockState); - } - - @Override - public IFacadableBlock getNonFacadeBlock() { - return SFMBlocks.TUNNELLED_CABLE_BLOCK.get(); - } - - @Override - public IFacadableBlock getFacadeBlock() { - return SFMBlocks.TUNNELLED_CABLE_FACADE_BLOCK.get(); - } -} +package ca.teamdman.sfm.common.block; + +import ca.teamdman.sfm.common.registry.SFMBlockEntities; +import ca.teamdman.sfm.common.registry.SFMBlocks; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.EntityBlock; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import org.jetbrains.annotations.Nullable; + +public class TunnelledCableBlock extends CableBlock implements EntityBlock { + public TunnelledCableBlock(Properties properties) { + super(properties); + } + + @Override + public @Nullable BlockEntity newBlockEntity( + BlockPos blockPos, + BlockState blockState + ) { + return SFMBlockEntities.TUNNELLED_CABLE_BLOCK_ENTITY.get().create(blockPos, blockState); + } + + @Override + public IFacadableBlock getNonFacadeBlock() { + return SFMBlocks.TUNNELLED_CABLE_BLOCK.get(); + } + + @Override + public IFacadableBlock getFacadeBlock() { + return SFMBlocks.TUNNELLED_CABLE_FACADE_BLOCK.get(); + } +} diff --git a/src/main/java/ca/teamdman/sfm/common/block/TunnelledCableFacadeBlock.java b/src/main/java/ca/teamdman/sfm/common/block/TunnelledCableFacadeBlock.java index d5539f59b..49cfba6ff 100644 --- a/src/main/java/ca/teamdman/sfm/common/block/TunnelledCableFacadeBlock.java +++ b/src/main/java/ca/teamdman/sfm/common/block/TunnelledCableFacadeBlock.java @@ -1,54 +1,54 @@ -package ca.teamdman.sfm.common.block; - -import ca.teamdman.sfm.common.registry.SFMBlockEntities; -import ca.teamdman.sfm.common.registry.SFMBlocks; -import net.minecraft.core.BlockPos; -import net.minecraft.world.item.ItemStack; -import net.minecraft.world.level.BlockGetter; -import net.minecraft.world.level.block.EntityBlock; -import net.minecraft.world.level.block.LightBlock; -import net.minecraft.world.level.block.entity.BlockEntity; -import net.minecraft.world.level.block.state.BlockState; -import org.jetbrains.annotations.Nullable; - -public class TunnelledCableFacadeBlock extends CableFacadeBlock implements EntityBlock, IFacadableBlock { - public TunnelledCableFacadeBlock(Properties properties) { - super(properties.lightLevel(LightBlock.LIGHT_EMISSION)); - registerDefaultState( - getStateDefinition() - .any() - .setValue( - ca.teamdman.sfm.common.facade.FacadeTransparency.FACADE_TRANSPARENCY_PROPERTY, - ca.teamdman.sfm.common.facade.FacadeTransparency.OPAQUE - ) - .setValue(LightBlock.LEVEL, 0) - ); - } - - @Override - public @Nullable BlockEntity newBlockEntity( - BlockPos blockPos, - BlockState blockState - ) { - return SFMBlockEntities.TUNNELLED_CABLE_FACADE_BLOCK_ENTITY.get().create(blockPos, blockState); - } - - @Override - public ItemStack getCloneItemStack( - BlockGetter pLevel, - BlockPos pPos, - BlockState pState - ) { - return new ItemStack(SFMBlocks.TUNNELLED_CABLE_BLOCK.get()); - } - - @Override - public IFacadableBlock getNonFacadeBlock() { - return SFMBlocks.TUNNELLED_CABLE_BLOCK.get(); - } - - @Override - public IFacadableBlock getFacadeBlock() { - return SFMBlocks.TUNNELLED_CABLE_FACADE_BLOCK.get(); - } -} +package ca.teamdman.sfm.common.block; + +import ca.teamdman.sfm.common.registry.SFMBlockEntities; +import ca.teamdman.sfm.common.registry.SFMBlocks; +import net.minecraft.core.BlockPos; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.block.EntityBlock; +import net.minecraft.world.level.block.LightBlock; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import org.jetbrains.annotations.Nullable; + +public class TunnelledCableFacadeBlock extends CableFacadeBlock implements EntityBlock, IFacadableBlock { + public TunnelledCableFacadeBlock(Properties properties) { + super(properties.lightLevel(LightBlock.LIGHT_EMISSION)); + registerDefaultState( + getStateDefinition() + .any() + .setValue( + ca.teamdman.sfm.common.facade.FacadeTransparency.FACADE_TRANSPARENCY_PROPERTY, + ca.teamdman.sfm.common.facade.FacadeTransparency.OPAQUE + ) + .setValue(LightBlock.LEVEL, 0) + ); + } + + @Override + public @Nullable BlockEntity newBlockEntity( + BlockPos blockPos, + BlockState blockState + ) { + return SFMBlockEntities.TUNNELLED_CABLE_FACADE_BLOCK_ENTITY.get().create(blockPos, blockState); + } + + @Override + public ItemStack getCloneItemStack( + BlockGetter pLevel, + BlockPos pPos, + BlockState pState + ) { + return new ItemStack(SFMBlocks.TUNNELLED_CABLE_BLOCK.get()); + } + + @Override + public IFacadableBlock getNonFacadeBlock() { + return SFMBlocks.TUNNELLED_CABLE_BLOCK.get(); + } + + @Override + public IFacadableBlock getFacadeBlock() { + return SFMBlocks.TUNNELLED_CABLE_FACADE_BLOCK.get(); + } +} diff --git a/src/main/java/ca/teamdman/sfm/common/block/TunnelledFancyCableBlock.java b/src/main/java/ca/teamdman/sfm/common/block/TunnelledFancyCableBlock.java index 520a1d484..878eed2be 100644 --- a/src/main/java/ca/teamdman/sfm/common/block/TunnelledFancyCableBlock.java +++ b/src/main/java/ca/teamdman/sfm/common/block/TunnelledFancyCableBlock.java @@ -1,33 +1,33 @@ -package ca.teamdman.sfm.common.block; - -import ca.teamdman.sfm.common.registry.SFMBlockEntities; -import ca.teamdman.sfm.common.registry.SFMBlocks; -import net.minecraft.core.BlockPos; -import net.minecraft.world.level.block.EntityBlock; -import net.minecraft.world.level.block.entity.BlockEntity; -import net.minecraft.world.level.block.state.BlockState; -import org.jetbrains.annotations.Nullable; - -public class TunnelledFancyCableBlock extends FancyCableBlock implements EntityBlock { - public TunnelledFancyCableBlock(Properties properties) { - super(properties); - } - - @Override - public @Nullable BlockEntity newBlockEntity( - BlockPos blockPos, - BlockState blockState - ) { - return SFMBlockEntities.TUNNELLED_FANCY_CABLE_BLOCK_ENTITY.get().create(blockPos, blockState); - } - - @Override - public IFacadableBlock getNonFacadeBlock() { - return SFMBlocks.TUNNELLED_FANCY_CABLE_BLOCK.get(); - } - - @Override - public IFacadableBlock getFacadeBlock() { - return SFMBlocks.TUNNELLED_FANCY_CABLE_FACADE_BLOCK.get(); - } -} +package ca.teamdman.sfm.common.block; + +import ca.teamdman.sfm.common.registry.SFMBlockEntities; +import ca.teamdman.sfm.common.registry.SFMBlocks; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.EntityBlock; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import org.jetbrains.annotations.Nullable; + +public class TunnelledFancyCableBlock extends FancyCableBlock implements EntityBlock { + public TunnelledFancyCableBlock(Properties properties) { + super(properties); + } + + @Override + public @Nullable BlockEntity newBlockEntity( + BlockPos blockPos, + BlockState blockState + ) { + return SFMBlockEntities.TUNNELLED_FANCY_CABLE_BLOCK_ENTITY.get().create(blockPos, blockState); + } + + @Override + public IFacadableBlock getNonFacadeBlock() { + return SFMBlocks.TUNNELLED_FANCY_CABLE_BLOCK.get(); + } + + @Override + public IFacadableBlock getFacadeBlock() { + return SFMBlocks.TUNNELLED_FANCY_CABLE_FACADE_BLOCK.get(); + } +} diff --git a/src/main/java/ca/teamdman/sfm/common/block/TunnelledFancyCableFacadeBlock.java b/src/main/java/ca/teamdman/sfm/common/block/TunnelledFancyCableFacadeBlock.java index 68ffd1190..3074a91f7 100644 --- a/src/main/java/ca/teamdman/sfm/common/block/TunnelledFancyCableFacadeBlock.java +++ b/src/main/java/ca/teamdman/sfm/common/block/TunnelledFancyCableFacadeBlock.java @@ -1,50 +1,50 @@ -package ca.teamdman.sfm.common.block; - -import ca.teamdman.sfm.common.registry.SFMBlockEntities; -import ca.teamdman.sfm.common.registry.SFMBlocks; -import net.minecraft.core.BlockPos; -import net.minecraft.world.item.ItemStack; -import net.minecraft.world.level.BlockGetter; -import net.minecraft.world.level.block.EntityBlock; -import net.minecraft.world.level.block.LightBlock; -import net.minecraft.world.level.block.entity.BlockEntity; -import net.minecraft.world.level.block.state.BlockState; -import org.jetbrains.annotations.Nullable; - -public class TunnelledFancyCableFacadeBlock extends FancyCableFacadeBlock implements EntityBlock, IFacadableBlock { - public TunnelledFancyCableFacadeBlock(Properties properties) { - super(properties.lightLevel(LightBlock.LIGHT_EMISSION)); - registerDefaultState( - defaultBlockState() - .setValue(ca.teamdman.sfm.common.facade.FacadeTransparency.FACADE_TRANSPARENCY_PROPERTY, ca.teamdman.sfm.common.facade.FacadeTransparency.TRANSLUCENT) - .setValue(LightBlock.LEVEL, 0) - ); - } - - @Override - public @Nullable BlockEntity newBlockEntity( - BlockPos blockPos, - BlockState blockState - ) { - return SFMBlockEntities.TUNNELLED_FANCY_CABLE_FACADE_BLOCK_ENTITY.get().create(blockPos, blockState); - } - - @Override - public ItemStack getCloneItemStack( - BlockGetter pLevel, - BlockPos pPos, - BlockState pState - ) { - return new ItemStack(SFMBlocks.TUNNELLED_FANCY_CABLE_BLOCK.get()); - } - - @Override - public IFacadableBlock getNonFacadeBlock() { - return SFMBlocks.TUNNELLED_FANCY_CABLE_BLOCK.get(); - } - - @Override - public IFacadableBlock getFacadeBlock() { - return SFMBlocks.TUNNELLED_FANCY_CABLE_FACADE_BLOCK.get(); - } -} +package ca.teamdman.sfm.common.block; + +import ca.teamdman.sfm.common.registry.SFMBlockEntities; +import ca.teamdman.sfm.common.registry.SFMBlocks; +import net.minecraft.core.BlockPos; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.block.EntityBlock; +import net.minecraft.world.level.block.LightBlock; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import org.jetbrains.annotations.Nullable; + +public class TunnelledFancyCableFacadeBlock extends FancyCableFacadeBlock implements EntityBlock, IFacadableBlock { + public TunnelledFancyCableFacadeBlock(Properties properties) { + super(properties.lightLevel(LightBlock.LIGHT_EMISSION)); + registerDefaultState( + defaultBlockState() + .setValue(ca.teamdman.sfm.common.facade.FacadeTransparency.FACADE_TRANSPARENCY_PROPERTY, ca.teamdman.sfm.common.facade.FacadeTransparency.TRANSLUCENT) + .setValue(LightBlock.LEVEL, 0) + ); + } + + @Override + public @Nullable BlockEntity newBlockEntity( + BlockPos blockPos, + BlockState blockState + ) { + return SFMBlockEntities.TUNNELLED_FANCY_CABLE_FACADE_BLOCK_ENTITY.get().create(blockPos, blockState); + } + + @Override + public ItemStack getCloneItemStack( + BlockGetter pLevel, + BlockPos pPos, + BlockState pState + ) { + return new ItemStack(SFMBlocks.TUNNELLED_FANCY_CABLE_BLOCK.get()); + } + + @Override + public IFacadableBlock getNonFacadeBlock() { + return SFMBlocks.TUNNELLED_FANCY_CABLE_BLOCK.get(); + } + + @Override + public IFacadableBlock getFacadeBlock() { + return SFMBlocks.TUNNELLED_FANCY_CABLE_FACADE_BLOCK.get(); + } +} diff --git a/src/main/java/ca/teamdman/sfm/common/block_network/BlockNetwork.java b/src/main/java/ca/teamdman/sfm/common/block_network/BlockNetwork.java index 309458d15..339552cfe 100644 --- a/src/main/java/ca/teamdman/sfm/common/block_network/BlockNetwork.java +++ b/src/main/java/ca/teamdman/sfm/common/block_network/BlockNetwork.java @@ -1,255 +1,255 @@ -package ca.teamdman.sfm.common.block_network; - -import ca.teamdman.sfm.common.util.*; -import com.mojang.datafixers.util.Pair; -import it.unimi.dsi.fastutil.longs.Long2ObjectFunction; -import it.unimi.dsi.fastutil.longs.LongIterator; -import net.minecraft.core.BlockPos; -import net.minecraft.core.Direction; -import net.minecraft.world.level.ChunkPos; -import org.jetbrains.annotations.Nullable; - -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import java.util.stream.Stream; - -/// A block network is a contiguous chain of blocks touching in the world. -/// Block networks are tracked by a {@link BlockNetworkManager}. -/// Methods are intentionally package-private since the block network manager needs to be kept in sync. -/// -/// The {@link LEVEL} generic is used to enable unit testing without instantiating full Minecraft level objects. -public class BlockNetwork { - private final LEVEL level; - - private final BlockPosMap membersByBlockPosition; - - private final ChunkPosMap memberBlockPositionsByChunk; - - private final BlockNetworkMemberFilterMapper memberFilterMapper; - - private final BlockNetworkConstructor> networkConstructor; - - public BlockNetwork( - LEVEL level, - BlockNetworkMemberFilterMapper memberFilterMapper - ) { - this(level, memberFilterMapper, BlockNetwork::new); - } - - public BlockNetwork( - LEVEL level, - BlockNetworkMemberFilterMapper memberFilterMapper, - BlockNetworkConstructor> networkConstructor - ) { - - this.level = level; - this.membersByBlockPosition = new BlockPosMap<>(); - this.memberBlockPositionsByChunk = new ChunkPosMap<>(); - this.memberFilterMapper = memberFilterMapper; - this.networkConstructor = networkConstructor; - } - - public LEVEL level() { - - return level; - } - - public BlockPosMap members() { - - return membersByBlockPosition; - } - - public ChunkPosMap memberBlockPositionsByChunk() { - - return memberBlockPositionsByChunk; - } - - public boolean isEmpty() { - - return membersByBlockPosition.isEmpty(); - } - - public boolean usesChunk(ChunkPos chunkPos) { - - return usesChunk(chunkPos.toLong()); - } - - public boolean usesChunk(long chunkPos) { - - return memberBlockPositionsByChunk.containsKey(chunkPos); - } - - public boolean isMember(BlockPos blockPos) { - - return membersByBlockPosition.containsKey(blockPos); - } - - public int size() { - - return membersByBlockPosition.size(); - } - - public boolean containsBlockPos(BlockPos memberBlockPos) { - - return membersByBlockPosition.containsKey(memberBlockPos); - } - - @Nullable T getCandidate(BlockPos pos) { - - return this.memberFilterMapper.getNetworkMember(level, pos); - } - - Stream> discoverCandidatesFromLevel( - BlockPos start - ) { - - return SFMStreamUtils.getRecursiveStream( - (currentBlockPos, next, results) -> { - @Nullable T member = BlockNetwork.this.getCandidate(currentBlockPos); - if (member == null) return; - - // Track the return value - results.accept(Pair.of(currentBlockPos, member)); - - // Schedule the tank neighbours for traversal - for (Direction d : SFMDirections.DIRECTIONS_WITHOUT_NULL) { - next.accept(currentBlockPos.relative(d)); - } - }, - start - ); - } - - Stream> discoverCandidatesFromSelf( - BlockPos start - ) { - - return SFMStreamUtils.getRecursiveStream( - (currentBlockPos, next, results) -> { - // Only traverse positions that have a block entity in the cache - T member = membersByBlockPosition.get(currentBlockPos); - if (member == null) return; - - // Track the return value - results.accept(Pair.of(currentBlockPos, member)); - - // Schedule the neighbour positions for traversal - for (Direction d : SFMDirections.DIRECTIONS_WITHOUT_NULL) { - next.accept(currentBlockPos.relative(d)); - } - }, - start - ); - } - - void purgeChunk(ChunkPos chunkPos) { - - BlockPosSet positionsInChunk = memberBlockPositionsByChunk.get(chunkPos); - if (positionsInChunk != null) { - membersByBlockPosition.removeBlockPositions(positionsInChunk); - memberBlockPositionsByChunk.remove(chunkPos); - } - } - - void addMember(Pair pair) { - - addMember(pair.getFirst(), pair.getSecond()); - } - - void addMember( - BlockPos memberBlockPos, - T member - ) { - - membersByBlockPosition.put(memberBlockPos, member); - - memberBlockPositionsByChunk - .computeIfAbsent( - memberBlockPos, - (Long2ObjectFunction) k -> new BlockPosSet() - ) - .add(memberBlockPos); - } - - void removeMember(BlockPos blockPos) { - - boolean wasPresent = membersByBlockPosition.remove(blockPos) != null; - if (wasPresent) { - BlockPosSet chunkMemberBlockPositions = Objects.requireNonNull(memberBlockPositionsByChunk.get(blockPos)); - chunkMemberBlockPositions.remove(blockPos); - if (chunkMemberBlockPositions.isEmpty()) { - memberBlockPositionsByChunk.remove(blockPos); - } - } - } - - /// Discover from the other network into this one. - private void populateFromPosition( - BlockNetwork other, - BlockPos startPos - ) { - - other.discoverCandidatesFromSelf(startPos).forEach((pair) -> { - BlockPos memberBlockPos = pair.getFirst(); - T member = pair.getSecond(); - addMember(memberBlockPos, member); - }); - } - - void addAllFromOtherNetwork(BlockNetwork other) { - - membersByBlockPosition.putAll(other.membersByBlockPosition); - for (LongIterator iterator = other.memberBlockPositionsByChunk.keySet().iterator(); iterator.hasNext(); ) { - long chunkPosLong = iterator.nextLong(); - BlockPosSet thisChunkPositions = this.memberBlockPositionsByChunk.computeIfAbsent( - chunkPosLong, - k -> new BlockPosSet() - ); - BlockPosSet otherChunkPositions = other.memberBlockPositionsByChunk.get(chunkPosLong); - assert otherChunkPositions != null; - thisChunkPositions.addAll(otherChunkPositions); - } - } - - /// Determine the networks that would result from this network having the given position removed. - List> splitRemoveMember(BlockPos blockPos) { - - // Remove the position from the network - removeMember(blockPos); - - // Prepare the list of resulting branch networks - List> branches = new ArrayList<>(); - - // For each neighbour of the removed position, identify branch uniqueness - for (Direction direction : SFMDirections.DIRECTIONS_WITHOUT_NULL) { - - // Get the neighbour position - BlockPos neighbourPos = blockPos.relative(direction); - - // Skip if the neighbour position is not a member of this network - if (!isMember(neighbourPos)) continue; - - // Skip if the neighbour position is a member of one of the branches we have already discovered - boolean alreadySeen = false; - for (BlockNetwork branch : branches) { - if (branch.isMember(neighbourPos)) { - alreadySeen = true; - break; - } - } - if (alreadySeen) continue; - - // Create the new network to hold the branch - BlockNetwork branch = networkConstructor.create(level, memberFilterMapper); - - // Populate the branch from this network - branch.populateFromPosition(this, neighbourPos); - - // Track the branch to be returned - branches.add(branch); - } - return branches; - } - -} +package ca.teamdman.sfm.common.block_network; + +import ca.teamdman.sfm.common.util.*; +import com.mojang.datafixers.util.Pair; +import it.unimi.dsi.fastutil.longs.Long2ObjectFunction; +import it.unimi.dsi.fastutil.longs.LongIterator; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.level.ChunkPos; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; + +/// A block network is a contiguous chain of blocks touching in the world. +/// Block networks are tracked by a {@link BlockNetworkManager}. +/// Methods are intentionally package-private since the block network manager needs to be kept in sync. +/// +/// The {@link LEVEL} generic is used to enable unit testing without instantiating full Minecraft level objects. +public class BlockNetwork { + private final LEVEL level; + + private final BlockPosMap membersByBlockPosition; + + private final ChunkPosMap memberBlockPositionsByChunk; + + private final BlockNetworkMemberFilterMapper memberFilterMapper; + + private final BlockNetworkConstructor> networkConstructor; + + public BlockNetwork( + LEVEL level, + BlockNetworkMemberFilterMapper memberFilterMapper + ) { + this(level, memberFilterMapper, BlockNetwork::new); + } + + public BlockNetwork( + LEVEL level, + BlockNetworkMemberFilterMapper memberFilterMapper, + BlockNetworkConstructor> networkConstructor + ) { + + this.level = level; + this.membersByBlockPosition = new BlockPosMap<>(); + this.memberBlockPositionsByChunk = new ChunkPosMap<>(); + this.memberFilterMapper = memberFilterMapper; + this.networkConstructor = networkConstructor; + } + + public LEVEL level() { + + return level; + } + + public BlockPosMap members() { + + return membersByBlockPosition; + } + + public ChunkPosMap memberBlockPositionsByChunk() { + + return memberBlockPositionsByChunk; + } + + public boolean isEmpty() { + + return membersByBlockPosition.isEmpty(); + } + + public boolean usesChunk(ChunkPos chunkPos) { + + return usesChunk(chunkPos.toLong()); + } + + public boolean usesChunk(long chunkPos) { + + return memberBlockPositionsByChunk.containsKey(chunkPos); + } + + public boolean isMember(BlockPos blockPos) { + + return membersByBlockPosition.containsKey(blockPos); + } + + public int size() { + + return membersByBlockPosition.size(); + } + + public boolean containsBlockPos(BlockPos memberBlockPos) { + + return membersByBlockPosition.containsKey(memberBlockPos); + } + + @Nullable T getCandidate(BlockPos pos) { + + return this.memberFilterMapper.getNetworkMember(level, pos); + } + + Stream> discoverCandidatesFromLevel( + BlockPos start + ) { + + return SFMStreamUtils.getRecursiveStream( + (currentBlockPos, next, results) -> { + @Nullable T member = BlockNetwork.this.getCandidate(currentBlockPos); + if (member == null) return; + + // Track the return value + results.accept(Pair.of(currentBlockPos, member)); + + // Schedule the tank neighbours for traversal + for (Direction d : SFMDirections.DIRECTIONS_WITHOUT_NULL) { + next.accept(currentBlockPos.relative(d)); + } + }, + start + ); + } + + Stream> discoverCandidatesFromSelf( + BlockPos start + ) { + + return SFMStreamUtils.getRecursiveStream( + (currentBlockPos, next, results) -> { + // Only traverse positions that have a block entity in the cache + T member = membersByBlockPosition.get(currentBlockPos); + if (member == null) return; + + // Track the return value + results.accept(Pair.of(currentBlockPos, member)); + + // Schedule the neighbour positions for traversal + for (Direction d : SFMDirections.DIRECTIONS_WITHOUT_NULL) { + next.accept(currentBlockPos.relative(d)); + } + }, + start + ); + } + + void purgeChunk(ChunkPos chunkPos) { + + BlockPosSet positionsInChunk = memberBlockPositionsByChunk.get(chunkPos); + if (positionsInChunk != null) { + membersByBlockPosition.removeBlockPositions(positionsInChunk); + memberBlockPositionsByChunk.remove(chunkPos); + } + } + + void addMember(Pair pair) { + + addMember(pair.getFirst(), pair.getSecond()); + } + + void addMember( + BlockPos memberBlockPos, + T member + ) { + + membersByBlockPosition.put(memberBlockPos, member); + + memberBlockPositionsByChunk + .computeIfAbsent( + memberBlockPos, + (Long2ObjectFunction) k -> new BlockPosSet() + ) + .add(memberBlockPos); + } + + void removeMember(BlockPos blockPos) { + + boolean wasPresent = membersByBlockPosition.remove(blockPos) != null; + if (wasPresent) { + BlockPosSet chunkMemberBlockPositions = Objects.requireNonNull(memberBlockPositionsByChunk.get(blockPos)); + chunkMemberBlockPositions.remove(blockPos); + if (chunkMemberBlockPositions.isEmpty()) { + memberBlockPositionsByChunk.remove(blockPos); + } + } + } + + /// Discover from the other network into this one. + private void populateFromPosition( + BlockNetwork other, + BlockPos startPos + ) { + + other.discoverCandidatesFromSelf(startPos).forEach((pair) -> { + BlockPos memberBlockPos = pair.getFirst(); + T member = pair.getSecond(); + addMember(memberBlockPos, member); + }); + } + + void addAllFromOtherNetwork(BlockNetwork other) { + + membersByBlockPosition.putAll(other.membersByBlockPosition); + for (LongIterator iterator = other.memberBlockPositionsByChunk.keySet().iterator(); iterator.hasNext(); ) { + long chunkPosLong = iterator.nextLong(); + BlockPosSet thisChunkPositions = this.memberBlockPositionsByChunk.computeIfAbsent( + chunkPosLong, + k -> new BlockPosSet() + ); + BlockPosSet otherChunkPositions = other.memberBlockPositionsByChunk.get(chunkPosLong); + assert otherChunkPositions != null; + thisChunkPositions.addAll(otherChunkPositions); + } + } + + /// Determine the networks that would result from this network having the given position removed. + List> splitRemoveMember(BlockPos blockPos) { + + // Remove the position from the network + removeMember(blockPos); + + // Prepare the list of resulting branch networks + List> branches = new ArrayList<>(); + + // For each neighbour of the removed position, identify branch uniqueness + for (Direction direction : SFMDirections.DIRECTIONS_WITHOUT_NULL) { + + // Get the neighbour position + BlockPos neighbourPos = blockPos.relative(direction); + + // Skip if the neighbour position is not a member of this network + if (!isMember(neighbourPos)) continue; + + // Skip if the neighbour position is a member of one of the branches we have already discovered + boolean alreadySeen = false; + for (BlockNetwork branch : branches) { + if (branch.isMember(neighbourPos)) { + alreadySeen = true; + break; + } + } + if (alreadySeen) continue; + + // Create the new network to hold the branch + BlockNetwork branch = networkConstructor.create(level, memberFilterMapper); + + // Populate the branch from this network + branch.populateFromPosition(this, neighbourPos); + + // Track the branch to be returned + branches.add(branch); + } + return branches; + } + +} diff --git a/src/main/java/ca/teamdman/sfm/common/block_network/BlockNetworkManager.java b/src/main/java/ca/teamdman/sfm/common/block_network/BlockNetworkManager.java index a20343fed..f2f5977a4 100644 --- a/src/main/java/ca/teamdman/sfm/common/block_network/BlockNetworkManager.java +++ b/src/main/java/ca/teamdman/sfm/common/block_network/BlockNetworkManager.java @@ -1,671 +1,671 @@ -package ca.teamdman.sfm.common.block_network; - -import ca.teamdman.sfm.SFM; -import ca.teamdman.sfm.common.util.*; -import com.google.common.collect.Sets; -import it.unimi.dsi.fastutil.longs.Long2ObjectMap; -import it.unimi.dsi.fastutil.longs.LongIterator; -import it.unimi.dsi.fastutil.longs.LongSet; -import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; -import net.minecraft.core.BlockPos; -import net.minecraft.core.Direction; -import net.minecraft.world.level.ChunkPos; -import org.jetbrains.annotations.Nullable; - -import java.util.*; - -/// A data structure for handling networks of blocks governed by contiguous-touching rules in a level. -/// -/// Because this area deals with both block positions and chunk positions, variables should be named to clearly indicate which is being used. -/// E.g., `memberPos` should be `memberBlockPos` -/// -/// The {@link LEVEL} generic is used to enable unit testing without instantiating full Minecraft level objects. -public class BlockNetworkManager> { - /// The maximum number of members in a network before skipping expensive split operations. - /// Networks larger than this will be cleared and lazily rebuilt instead of split. - public static final int SPLIT_SIZE_THRESHOLD = 256; - - private final Map> networksByLevelBlockPos = new Object2ObjectOpenHashMap<>(); - - private final Map>> networksByLevelChunk = new Object2ObjectOpenHashMap<>(); - - private final Map> networksByLevel = new Object2ObjectOpenHashMap<>(); - - private final BlockNetworkMemberFilterMapper memberFilterMapper; - - private final BlockNetworkConstructor networkConstructor; - - public BlockNetworkManager( - BlockNetworkMemberFilterMapper memberFilterMapper, - BlockNetworkConstructor networkConstructor - ) { - - this.memberFilterMapper = memberFilterMapper; - this.networkConstructor = networkConstructor; - } - - /// Called when the network structure changes. - /// Override to add custom behavior like additional logging. - /// Default implementation prints diagnostics and asserts invariants in IDE. - protected void onChange() { - printChangeDiagnostics(); - if (SFMEnvironmentUtils.isInIDE()) { - assertInvariants(); - } - } - - public @Nullable NETWORK getNetwork( - LEVEL level, - BlockPos blockPos - ) { - - BlockPosMap blockPosMap = networksByLevelBlockPos.get(level); - if (blockPosMap == null) return null; - return blockPosMap.get(blockPos); - } - - public BlockPosMap getNetworksForLevel(LEVEL level) { - - BlockPosMap blockPosMap = networksByLevelBlockPos.get(level); - if (blockPosMap == null) return new BlockPosMap<>(); - return blockPosMap; - } - - public void clearChunk( - LEVEL level, - ChunkPos chunkPos - ) { - - ChunkPosMap> levelChunkPosMap = networksByLevelChunk.get(level); - if (levelChunkPosMap == null) return; - Set networksForChunk = levelChunkPosMap.get(chunkPos); - if (networksForChunk == null) return; - for (NETWORK network : networksForChunk) { - network.purgeChunk(chunkPos); - } - onChange(); - } - - public @Nullable NETWORK getOrRegisterNetworkFromMemberPosition( - LEVEL level, - BlockPos memberBlockPos - ) { - // Return existing network if one present. - @Nullable NETWORK existing = getNetwork(level, memberBlockPos); - if (existing != null) return existing; - - // No network exists for this position yet. - // We will update an adjacent network if one exists to include this position. - // Otherwise, we will create a new network. - - // Ensure the position is a valid member - @Nullable T member = memberFilterMapper.getNetworkMember(level, memberBlockPos); - if (member == null) return null; - - // Scan neighbours to find networks and unclaimed members - ArrayDeque unclaimedNeighbourPositions = new ArrayDeque<>(6); - List neighbouringNetworks = new ArrayList<>(); - - BlockPos.MutableBlockPos neighbourBlockPosition = new BlockPos.MutableBlockPos(); - for (Direction direction : SFMDirections.DIRECTIONS_WITHOUT_NULL) { - neighbourBlockPosition.set(memberBlockPos).move(direction); - @Nullable NETWORK neighbourNetwork = getNetwork(level, neighbourBlockPosition); - if (neighbourNetwork == null) { - /// We do not need to check if this neighbour block is a valid candidate. - /// {@link BlockNetwork#discoverCandidatesFromLevel(BlockPos)} will check for us. - unclaimedNeighbourPositions.add(neighbourBlockPosition.immutable()); - } else { - neighbouringNetworks.add(neighbourNetwork); - } - } - - // Determine the result network - NETWORK resultNetwork; - Iterator neighbouringNetworkIterator = neighbouringNetworks.iterator(); - if (neighbouringNetworkIterator.hasNext()) { - // A neighbour network exists, it will absorb the other networks - resultNetwork = neighbouringNetworkIterator.next(); - // Update membership - resultNetwork.addMember(memberBlockPos, member); - // Update tracking - trackMemberBlockPosForNetwork(memberBlockPos, resultNetwork); - } else { - // No neighbouring networks exist, we construct a new network - resultNetwork = networkConstructor.create(level, memberFilterMapper); - // Apply discovery; the candidates include the member position itself - resultNetwork.discoverCandidatesFromLevel(memberBlockPos).forEach(pair -> { - // Update membership - resultNetwork.addMember(pair); - // Update tracking - trackMemberBlockPosForNetwork(pair.getFirst(), resultNetwork); - }); - } - - // Merge any neighbouring networks - while (neighbouringNetworkIterator.hasNext()) { - // Get the old network - NETWORK oldNetwork = neighbouringNetworkIterator.next(); - // Merge the old network into the result network - resultNetwork.addAllFromOtherNetwork(oldNetwork); - // Update tracking - trackNetworkTransfer(oldNetwork, resultNetwork); - } - - // Add any unclaimed members - for (BlockPos unclaimedNeighbourPosition : unclaimedNeighbourPositions) { - resultNetwork.discoverCandidatesFromLevel(unclaimedNeighbourPosition).forEach(pair -> { - // Update membership - resultNetwork.addMember(pair); - // Update tracking - trackMemberBlockPosForNetwork(pair.getFirst(), resultNetwork); - }); - } - - onChange(); - return resultNetwork; - } - - /// MUST be called to keep the networks in sync - public @Nullable NETWORK onMemberAddedToLevel( - LEVEL level, - BlockPos memberBlockPos - ) { - - return getOrRegisterNetworkFromMemberPosition( - level, - memberBlockPos - ); - } - - /// MUST be called to keep the networks in sync - @SuppressWarnings("unchecked") - public List onMemberRemovedFromLevel( - LEVEL level, - BlockPos memberBlockPos - ) { - - // Identify the network associated with the member position - NETWORK oldNetwork = getNetwork(level, memberBlockPos); - if (oldNetwork == null) return List.of(); - - // Untrack the position as the member has been removed from the level - untrackMemberFromNetwork(memberBlockPos, oldNetwork); - - // For large networks, skip expensive split and clear the network for lazy rebuild - if (oldNetwork.size() > SPLIT_SIZE_THRESHOLD) { - untrackNetwork(oldNetwork); - if (SFMEnvironmentUtils.isInIDE()) { - assertNetworkForgotten(oldNetwork); - } - onChange(); - return List.of(); - } - - // Identify the networks that result from the removal of the position - // The cast is safe because splitRemoveMember uses the networkConstructor which produces NETWORK instances - List resultingNetworks = (List) oldNetwork.splitRemoveMember(memberBlockPos); - - // Track the resulting networks - for (NETWORK newNetwork : resultingNetworks) { - trackNetworkTransfer(oldNetwork, newNetwork); - } - - // The old network should no longer be tracked as all lookup table entries - // have been clobbered by the split after untracking the removed member position - if (SFMEnvironmentUtils.isInIDE()) { - assertNetworkForgotten(oldNetwork); - } - - onChange(); - return resultingNetworks; - } - - - public void assertNetworkForgotten(BlockNetwork network) { - - LEVEL level = network.level(); - - // Check the level lookup - if (networksByLevel.getOrDefault(level, Collections.emptySet()).contains(network)) { - throw new IllegalStateException("Network still tracked in level lookup"); - } - - // Check the chunk lookup - if (networksByLevelChunk - .getOrDefault(level, new ChunkPosMap<>()) - .values() - .stream() - .anyMatch(list -> list.contains(network))) { - throw new IllegalStateException("Network still tracked in chunk lookup"); - } - - // Check the block position lookup - if (networksByLevelBlockPos.getOrDefault(level, new BlockPosMap<>()).values().contains(network)) { - throw new IllegalStateException("Network still tracked in block position lookup"); - } - } - - public void assertInvariants() { - - try { - for (Map.Entry> entry : networksByLevel.entrySet()) { - LEVEL level = entry.getKey(); - BlockPosMap networksByPositionLookup = networksByLevelBlockPos.get(level); - if (networksByPositionLookup == null) { - throw new IllegalStateException("Level " + level + " has no block position lookup"); - } - Set networksForLevel = entry.getValue(); - BlockPosSet seen = new BlockPosSet(); - for (NETWORK network : networksForLevel) { - for (BlockPos blockPos : network.members().keysAsBlockPosSet()) { - // Assert that no position belongs to multiple networks in this level - boolean modified = seen.add(blockPos); - if (!modified) { - throw new IllegalStateException("Position " - + blockPos - + " is in multiple networks in level " - + level); - } - - // Assert that the position is present in the lookup map - NETWORK foundNetwork = networksByPositionLookup.get(blockPos); - if (foundNetwork == null) { - throw new IllegalStateException("Position " - + blockPos - + " is not in the position lookup for level " - + level); - } else if (foundNetwork != network) { - throw new IllegalStateException("Position " - + blockPos - + " is in two networks, expected " - + network - + " but found " - + foundNetwork); - } - - // Assert that the position is present in the chunk lookup map - Set networksInChunk = networksByLevelChunk.get(level).get(blockPos); - if (networksInChunk == null) { - throw new IllegalStateException("Position " - + blockPos - + " is not in a tracked chunk for level " - + level); - } - if (!networksInChunk.contains(network)) { - throw new IllegalStateException("Position " - + blockPos - + " is in network " - + foundNetwork - + " but not " - + network); - } - } - } - } - - for (Map.Entry>> entry : networksByLevelChunk.entrySet()) { - LEVEL level = entry.getKey(); - ChunkPosMap> networksByChunk = entry.getValue(); - Set networksInLevel = networksByLevel.get(level); - for (Long2ObjectMap.Entry> networksInChunk : networksByChunk.entrySet()) { - if (networksInLevel == null) { - throw new IllegalStateException("Networks in chunk " - + new ChunkPos(networksInChunk.getLongKey()) - + " (" + networksByChunk.size() + " entries) are not in level " - + level); - } else if (!networksInLevel.containsAll(networksInChunk.getValue())) { - long chunkPosLong = networksInChunk.getLongKey(); - throw new IllegalStateException("Networks in chunk " - + new ChunkPos(chunkPosLong) - + " are not in level " - + level); - } - } - } - - for (Map.Entry> entry : networksByLevelBlockPos.entrySet()) { - LEVEL level = entry.getKey(); - BlockPosMap networksByBlockPos = entry.getValue(); - for (Long2ObjectMap.Entry posEntry : networksByBlockPos.long2ObjectEntrySet()) { - BlockPos blockPos = BlockPos.of(posEntry.getLongKey()); - NETWORK network = posEntry.getValue(); - if (!Objects.requireNonNull(networksByLevelChunk.get(level).get(blockPos)).contains(network)) { - throw new IllegalStateException("Network " - + network - + " is not in chunk " - + new ChunkPos(blockPos)); - } - if (!networksByLevel.get(level).contains(network)) { - throw new IllegalStateException("Network " + network + " is not in level " + level); - } - } - } - } catch (IllegalStateException e) { - SFM.LOGGER.error("BlockNetworkManager inconsistency detected"); - printDebugInfo(); - throw e; - } - } - - public void clearLevel(LEVEL level) { - - networksByLevelBlockPos.remove(level); - networksByLevelChunk.remove(level); - networksByLevel.remove(level); - onChange(); - } - - public void clear() { - - networksByLevelBlockPos.clear(); - networksByLevelChunk.clear(); - networksByLevel.clear(); - onChange(); - } - - public void printDebugInfo() { - - SFM.LOGGER.info("=== BlockNetworkManager Debug Info ==="); - - SFM.LOGGER.info("Networks by Level:"); - for (Map.Entry> entry : networksByLevel.entrySet()) { - LEVEL level = entry.getKey(); - Set networks = entry.getValue(); - SFM.LOGGER.info(" Level {}: {} networks", level, networks.size()); - int i = 0; - for (NETWORK network : networks) { - SFM.LOGGER.info( - " Network {} @ {}: {} members", - i, - Integer.toHexString(System.identityHashCode(network)), - network.members().size() - ); - int iMember = 0; - for (BlockPos blockPos : network.members().keysAsBlockPosSet()) { - SFM.LOGGER.info(" Member {}: {}", iMember, blockPos); - } - i++; - } - } - - SFM.LOGGER.info("Networks by Level Position:"); - for (Map.Entry> entry : networksByLevelBlockPos.entrySet()) { - LEVEL level = entry.getKey(); - BlockPosMap positionMap = entry.getValue(); - SFM.LOGGER.info(" Level {}: {} tracked positions", level, positionMap.size()); - } - - SFM.LOGGER.info("Networks by Level Chunk:"); - for (Map.Entry>> entry : networksByLevelChunk.entrySet()) { - LEVEL level = entry.getKey(); - ChunkPosMap> chunkMap = entry.getValue(); - SFM.LOGGER.info(" Level {}: {} chunks", level, chunkMap.size()); - for (Long2ObjectMap.Entry> chunkEntry : chunkMap.entrySet()) { - ChunkPos chunkPos = new ChunkPos(chunkEntry.getLongKey()); - Set networksInChunk = chunkEntry.getValue(); - SFM.LOGGER.info(" Chunk [{}, {}]: {} networks", chunkPos.x, chunkPos.z, networksInChunk.size()); - int i = 0; - for (NETWORK network : networksInChunk) { - SFM.LOGGER.info( - " Network {} @ {}: {} members", - i, - Integer.toHexString(System.identityHashCode(network)), - network.members().size() - ); - i++; - } - } - } - - SFM.LOGGER.info("=== End BlockNetworkManager Debug Info ==="); - } - - public boolean isEmpty() { - - if (SFMEnvironmentUtils.isInIDE()) { - return networksByLevelBlockPos.isEmpty(); - } else { - boolean a = networksByLevelBlockPos.isEmpty(); - boolean b = networksByLevelChunk.isEmpty(); - boolean c = networksByLevel.isEmpty(); - if (a != b || b != c) { - throw new IllegalStateException("BlockNetworkManager inconsistency"); - } - return a; - } - - } - - public boolean containsLevel(LEVEL testLevel) { - - return networksByLevel.containsKey(testLevel); - } - - public int networkCount() { - - int rtn = 0; - for (Map.Entry> entry : networksByLevel.entrySet()) { - rtn += entry.getValue().size(); - } - return rtn; - } - - /// Remove a network from all tracking structures. - /// Used when clearing a large network for lazy rebuild. - public void untrackNetwork(NETWORK network) { - - LEVEL level = network.level(); - - // Remove all position lookups for this network - BlockPosMap networksByBlockPos = networksByLevelBlockPos.get(level); - if (networksByBlockPos != null) { - networksByBlockPos.removeBlockPositions(network.members().keysAsLongSet()); - if (networksByBlockPos.isEmpty()) { - networksByLevelBlockPos.remove(level); - } - } - - // Remove from chunk lookups - ChunkPosMap> networksByChunkPos = networksByLevelChunk.get(level); - if (networksByChunkPos != null) { - for ( - LongIterator iterator = network.memberBlockPositionsByChunk().keySet().iterator(); - iterator.hasNext(); - ) { - long chunkPosLong = iterator.nextLong(); - Set networksInChunk = networksByChunkPos.get(chunkPosLong); - if (networksInChunk != null) { - networksInChunk.remove(network); - if (networksInChunk.isEmpty()) { - networksByChunkPos.remove(chunkPosLong); - } - } - } - if (networksByChunkPos.isEmpty()) { - networksByLevelChunk.remove(level); - } - } - - // Remove from level lookup - Set networksInLevel = networksByLevel.get(level); - if (networksInLevel != null) { - networksInLevel.remove(network); - if (networksInLevel.isEmpty()) { - networksByLevel.remove(level); - } - } - onChange(); - } - - private void printChangeDiagnostics() { - - boolean enabled = false; - if (!enabled) return; - if (!SFMEnvironmentUtils.isInIDE()) return; - SFM.LOGGER.info("Network lookup changed"); - SFM.LOGGER.info("NETWORKS_BY_LEVEL:"); - for (Map.Entry> entry : networksByLevel.entrySet()) { - LEVEL level = entry.getKey(); - Set networks = entry.getValue(); - SFM.LOGGER.debug("Level {} has {} networks", level, networks.size()); - StringBuilder builder = new StringBuilder(); - for (NETWORK network : networks) { - builder.append(network.members().size()).append(" members; "); - } - SFM.LOGGER.debug(builder.toString()); - } - SFM.LOGGER.info("NETWORKS_BY_CABLE_POSITION:"); - for (Map.Entry> entry : networksByLevelBlockPos.entrySet()) { - LEVEL level = entry.getKey(); - BlockPosMap networksByCablePosition = entry.getValue(); - SFM.LOGGER.debug("Level {} has {} cables", level, networksByCablePosition.size()); - } - } - - private void trackMemberBlockPosForNetwork( - BlockPos memberBlockPos, - NETWORK network - ) { - - // Get the level - LEVEL level = network.level(); - - // Update the position lookup - BlockPosMap networksByBlockPos = networksByLevelBlockPos.computeIfAbsent( - level, - k -> new BlockPosMap<>() - ); - networksByBlockPos.put(memberBlockPos, network); - - // Update the chunk lookup - ChunkPosMap> networksByChunkPos = networksByLevelChunk.computeIfAbsent( - level, - k -> new ChunkPosMap<>() - ); - networksByChunkPos.computeIfAbsent(new ChunkPos(memberBlockPos), k -> Sets.newIdentityHashSet()).add(network); - - // Update the level lookup - networksByLevel.computeIfAbsent(level, k -> Sets.newIdentityHashSet()).add(network); - } - - private void trackNetworkTransfer( - NETWORK oldNetwork, - NETWORK newNetwork - ) { - // Get the level - LEVEL level = oldNetwork.level(); - if (newNetwork.level() != level) - throw new IllegalStateException("Cannot transfer network ownership across levels"); - - // Update the position lookup based on the NEW network's members - // (not the old network, since during splits the new network only has a subset of members) - BlockPosMap networksByLevelBlockPosition = networksByLevelBlockPos.computeIfAbsent( - level, - k -> new BlockPosMap<>() - ); - LongSet newNetworkMemberBlockPositions = newNetwork.members().keysAsLongSet(); - LongIterator newNetworkMemberBlockPositionIterator = newNetworkMemberBlockPositions.longIterator(); - while (newNetworkMemberBlockPositionIterator.hasNext()) { - long newNetworkMemberBlockPos = newNetworkMemberBlockPositionIterator.nextLong(); - // Clobber the old entries - networksByLevelBlockPosition.put(newNetworkMemberBlockPos, newNetwork); - } - - // Update the chunk lookup based on the NEW network's chunk positions - ChunkPosMap> networksByLevelChunk = this.networksByLevelChunk.computeIfAbsent( - level, - k -> new ChunkPosMap<>() - ); - ChunkPosMap newNetworkMemberBlockPositionsByChunk = newNetwork.memberBlockPositionsByChunk(); - for (LongIterator iterator = newNetworkMemberBlockPositionsByChunk.keySet().iterator(); iterator.hasNext(); ) { - long chunkPosLong = iterator.nextLong(); - Set networksInChunk = networksByLevelChunk.computeIfAbsent( - chunkPosLong, - k -> Sets.newIdentityHashSet() - ); - // Remove the old network from this chunk (it may or may not be present) - networksInChunk.remove(oldNetwork); - // Add the new network to this chunk - networksInChunk.add(newNetwork); - } - - // Also need to remove the old network from any chunks it was in but the new network is not - ChunkPosMap oldNetworkMemberBlockPositionsByChunk = oldNetwork.memberBlockPositionsByChunk(); - for (LongIterator iterator = oldNetworkMemberBlockPositionsByChunk.keySet().iterator(); iterator.hasNext(); ) { - long chunkPosLong = iterator.nextLong(); - // Only process chunks that the new network doesn't occupy - if (newNetworkMemberBlockPositionsByChunk.containsKey(chunkPosLong)) continue; - Set networksInChunk = networksByLevelChunk.get(chunkPosLong); - if (networksInChunk != null) { - networksInChunk.remove(oldNetwork); - if (networksInChunk.isEmpty()) { - networksByLevelChunk.remove(chunkPosLong); - } - } - } - - // Update the level lookup - Set networksInLevel = this.networksByLevel.computeIfAbsent( - level, - k -> Sets.newIdentityHashSet() - ); - // Remove the old network - networksInLevel.remove(oldNetwork); - // Add the new network - networksInLevel.add(newNetwork); - } - - /// Remove the lookup table entries for the given position. - /// This DOES NOT perform network splitting! - private void untrackMemberFromNetwork( - BlockPos memberBlockPos, - NETWORK network - ) { - - LEVEL level = network.level(); - ChunkPos memberChunkPos = new ChunkPos(memberBlockPos); - - // Remove the member from the network - network.removeMember(memberBlockPos); - - // Remove the block pos from the position lookup - BlockPosMap networksByBlockPos = networksByLevelBlockPos.get(level); - if (networksByBlockPos != null) { - networksByBlockPos.remove(memberBlockPos); - if (networksByBlockPos.isEmpty()) { - networksByLevelBlockPos.remove(level); - } - } - - // Check if the network still uses the chunk - if (!network.usesChunk(memberChunkPos)) { - // Remove the chunk pos from the chunk pos lookup - ChunkPosMap> networksByChunkPos = networksByLevelChunk.get(level); - if (networksByChunkPos != null) { - Set listOfNetworksInChunk = networksByChunkPos.get(memberChunkPos); - if (listOfNetworksInChunk != null) { - listOfNetworksInChunk.remove(network); - if (listOfNetworksInChunk.isEmpty()) { - networksByChunkPos.remove(memberChunkPos); - } - } - if (networksByChunkPos.isEmpty()) { - networksByLevelChunk.remove(level); - } - } - } - - // Remove the network from the level if it is now empty - if (network.isEmpty()) { - Set networksInLevel = networksByLevel.get(level); - if (networksInLevel != null) { - networksInLevel.remove(network); - if (networksInLevel.isEmpty()) { - networksByLevel.remove(level); - } - } - } - - } - -} +package ca.teamdman.sfm.common.block_network; + +import ca.teamdman.sfm.SFM; +import ca.teamdman.sfm.common.util.*; +import com.google.common.collect.Sets; +import it.unimi.dsi.fastutil.longs.Long2ObjectMap; +import it.unimi.dsi.fastutil.longs.LongIterator; +import it.unimi.dsi.fastutil.longs.LongSet; +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.level.ChunkPos; +import org.jetbrains.annotations.Nullable; + +import java.util.*; + +/// A data structure for handling networks of blocks governed by contiguous-touching rules in a level. +/// +/// Because this area deals with both block positions and chunk positions, variables should be named to clearly indicate which is being used. +/// E.g., `memberPos` should be `memberBlockPos` +/// +/// The {@link LEVEL} generic is used to enable unit testing without instantiating full Minecraft level objects. +public class BlockNetworkManager> { + /// The maximum number of members in a network before skipping expensive split operations. + /// Networks larger than this will be cleared and lazily rebuilt instead of split. + public static final int SPLIT_SIZE_THRESHOLD = 256; + + private final Map> networksByLevelBlockPos = new Object2ObjectOpenHashMap<>(); + + private final Map>> networksByLevelChunk = new Object2ObjectOpenHashMap<>(); + + private final Map> networksByLevel = new Object2ObjectOpenHashMap<>(); + + private final BlockNetworkMemberFilterMapper memberFilterMapper; + + private final BlockNetworkConstructor networkConstructor; + + public BlockNetworkManager( + BlockNetworkMemberFilterMapper memberFilterMapper, + BlockNetworkConstructor networkConstructor + ) { + + this.memberFilterMapper = memberFilterMapper; + this.networkConstructor = networkConstructor; + } + + /// Called when the network structure changes. + /// Override to add custom behavior like additional logging. + /// Default implementation prints diagnostics and asserts invariants in IDE. + protected void onChange() { + printChangeDiagnostics(); + if (SFMEnvironmentUtils.isInIDE()) { + assertInvariants(); + } + } + + public @Nullable NETWORK getNetwork( + LEVEL level, + BlockPos blockPos + ) { + + BlockPosMap blockPosMap = networksByLevelBlockPos.get(level); + if (blockPosMap == null) return null; + return blockPosMap.get(blockPos); + } + + public BlockPosMap getNetworksForLevel(LEVEL level) { + + BlockPosMap blockPosMap = networksByLevelBlockPos.get(level); + if (blockPosMap == null) return new BlockPosMap<>(); + return blockPosMap; + } + + public void clearChunk( + LEVEL level, + ChunkPos chunkPos + ) { + + ChunkPosMap> levelChunkPosMap = networksByLevelChunk.get(level); + if (levelChunkPosMap == null) return; + Set networksForChunk = levelChunkPosMap.get(chunkPos); + if (networksForChunk == null) return; + for (NETWORK network : networksForChunk) { + network.purgeChunk(chunkPos); + } + onChange(); + } + + public @Nullable NETWORK getOrRegisterNetworkFromMemberPosition( + LEVEL level, + BlockPos memberBlockPos + ) { + // Return existing network if one present. + @Nullable NETWORK existing = getNetwork(level, memberBlockPos); + if (existing != null) return existing; + + // No network exists for this position yet. + // We will update an adjacent network if one exists to include this position. + // Otherwise, we will create a new network. + + // Ensure the position is a valid member + @Nullable T member = memberFilterMapper.getNetworkMember(level, memberBlockPos); + if (member == null) return null; + + // Scan neighbours to find networks and unclaimed members + ArrayDeque unclaimedNeighbourPositions = new ArrayDeque<>(6); + List neighbouringNetworks = new ArrayList<>(); + + BlockPos.MutableBlockPos neighbourBlockPosition = new BlockPos.MutableBlockPos(); + for (Direction direction : SFMDirections.DIRECTIONS_WITHOUT_NULL) { + neighbourBlockPosition.set(memberBlockPos).move(direction); + @Nullable NETWORK neighbourNetwork = getNetwork(level, neighbourBlockPosition); + if (neighbourNetwork == null) { + /// We do not need to check if this neighbour block is a valid candidate. + /// {@link BlockNetwork#discoverCandidatesFromLevel(BlockPos)} will check for us. + unclaimedNeighbourPositions.add(neighbourBlockPosition.immutable()); + } else { + neighbouringNetworks.add(neighbourNetwork); + } + } + + // Determine the result network + NETWORK resultNetwork; + Iterator neighbouringNetworkIterator = neighbouringNetworks.iterator(); + if (neighbouringNetworkIterator.hasNext()) { + // A neighbour network exists, it will absorb the other networks + resultNetwork = neighbouringNetworkIterator.next(); + // Update membership + resultNetwork.addMember(memberBlockPos, member); + // Update tracking + trackMemberBlockPosForNetwork(memberBlockPos, resultNetwork); + } else { + // No neighbouring networks exist, we construct a new network + resultNetwork = networkConstructor.create(level, memberFilterMapper); + // Apply discovery; the candidates include the member position itself + resultNetwork.discoverCandidatesFromLevel(memberBlockPos).forEach(pair -> { + // Update membership + resultNetwork.addMember(pair); + // Update tracking + trackMemberBlockPosForNetwork(pair.getFirst(), resultNetwork); + }); + } + + // Merge any neighbouring networks + while (neighbouringNetworkIterator.hasNext()) { + // Get the old network + NETWORK oldNetwork = neighbouringNetworkIterator.next(); + // Merge the old network into the result network + resultNetwork.addAllFromOtherNetwork(oldNetwork); + // Update tracking + trackNetworkTransfer(oldNetwork, resultNetwork); + } + + // Add any unclaimed members + for (BlockPos unclaimedNeighbourPosition : unclaimedNeighbourPositions) { + resultNetwork.discoverCandidatesFromLevel(unclaimedNeighbourPosition).forEach(pair -> { + // Update membership + resultNetwork.addMember(pair); + // Update tracking + trackMemberBlockPosForNetwork(pair.getFirst(), resultNetwork); + }); + } + + onChange(); + return resultNetwork; + } + + /// MUST be called to keep the networks in sync + public @Nullable NETWORK onMemberAddedToLevel( + LEVEL level, + BlockPos memberBlockPos + ) { + + return getOrRegisterNetworkFromMemberPosition( + level, + memberBlockPos + ); + } + + /// MUST be called to keep the networks in sync + @SuppressWarnings("unchecked") + public List onMemberRemovedFromLevel( + LEVEL level, + BlockPos memberBlockPos + ) { + + // Identify the network associated with the member position + NETWORK oldNetwork = getNetwork(level, memberBlockPos); + if (oldNetwork == null) return List.of(); + + // Untrack the position as the member has been removed from the level + untrackMemberFromNetwork(memberBlockPos, oldNetwork); + + // For large networks, skip expensive split and clear the network for lazy rebuild + if (oldNetwork.size() > SPLIT_SIZE_THRESHOLD) { + untrackNetwork(oldNetwork); + if (SFMEnvironmentUtils.isInIDE()) { + assertNetworkForgotten(oldNetwork); + } + onChange(); + return List.of(); + } + + // Identify the networks that result from the removal of the position + // The cast is safe because splitRemoveMember uses the networkConstructor which produces NETWORK instances + List resultingNetworks = (List) oldNetwork.splitRemoveMember(memberBlockPos); + + // Track the resulting networks + for (NETWORK newNetwork : resultingNetworks) { + trackNetworkTransfer(oldNetwork, newNetwork); + } + + // The old network should no longer be tracked as all lookup table entries + // have been clobbered by the split after untracking the removed member position + if (SFMEnvironmentUtils.isInIDE()) { + assertNetworkForgotten(oldNetwork); + } + + onChange(); + return resultingNetworks; + } + + + public void assertNetworkForgotten(BlockNetwork network) { + + LEVEL level = network.level(); + + // Check the level lookup + if (networksByLevel.getOrDefault(level, Collections.emptySet()).contains(network)) { + throw new IllegalStateException("Network still tracked in level lookup"); + } + + // Check the chunk lookup + if (networksByLevelChunk + .getOrDefault(level, new ChunkPosMap<>()) + .values() + .stream() + .anyMatch(list -> list.contains(network))) { + throw new IllegalStateException("Network still tracked in chunk lookup"); + } + + // Check the block position lookup + if (networksByLevelBlockPos.getOrDefault(level, new BlockPosMap<>()).values().contains(network)) { + throw new IllegalStateException("Network still tracked in block position lookup"); + } + } + + public void assertInvariants() { + + try { + for (Map.Entry> entry : networksByLevel.entrySet()) { + LEVEL level = entry.getKey(); + BlockPosMap networksByPositionLookup = networksByLevelBlockPos.get(level); + if (networksByPositionLookup == null) { + throw new IllegalStateException("Level " + level + " has no block position lookup"); + } + Set networksForLevel = entry.getValue(); + BlockPosSet seen = new BlockPosSet(); + for (NETWORK network : networksForLevel) { + for (BlockPos blockPos : network.members().keysAsBlockPosSet()) { + // Assert that no position belongs to multiple networks in this level + boolean modified = seen.add(blockPos); + if (!modified) { + throw new IllegalStateException("Position " + + blockPos + + " is in multiple networks in level " + + level); + } + + // Assert that the position is present in the lookup map + NETWORK foundNetwork = networksByPositionLookup.get(blockPos); + if (foundNetwork == null) { + throw new IllegalStateException("Position " + + blockPos + + " is not in the position lookup for level " + + level); + } else if (foundNetwork != network) { + throw new IllegalStateException("Position " + + blockPos + + " is in two networks, expected " + + network + + " but found " + + foundNetwork); + } + + // Assert that the position is present in the chunk lookup map + Set networksInChunk = networksByLevelChunk.get(level).get(blockPos); + if (networksInChunk == null) { + throw new IllegalStateException("Position " + + blockPos + + " is not in a tracked chunk for level " + + level); + } + if (!networksInChunk.contains(network)) { + throw new IllegalStateException("Position " + + blockPos + + " is in network " + + foundNetwork + + " but not " + + network); + } + } + } + } + + for (Map.Entry>> entry : networksByLevelChunk.entrySet()) { + LEVEL level = entry.getKey(); + ChunkPosMap> networksByChunk = entry.getValue(); + Set networksInLevel = networksByLevel.get(level); + for (Long2ObjectMap.Entry> networksInChunk : networksByChunk.entrySet()) { + if (networksInLevel == null) { + throw new IllegalStateException("Networks in chunk " + + new ChunkPos(networksInChunk.getLongKey()) + + " (" + networksByChunk.size() + " entries) are not in level " + + level); + } else if (!networksInLevel.containsAll(networksInChunk.getValue())) { + long chunkPosLong = networksInChunk.getLongKey(); + throw new IllegalStateException("Networks in chunk " + + new ChunkPos(chunkPosLong) + + " are not in level " + + level); + } + } + } + + for (Map.Entry> entry : networksByLevelBlockPos.entrySet()) { + LEVEL level = entry.getKey(); + BlockPosMap networksByBlockPos = entry.getValue(); + for (Long2ObjectMap.Entry posEntry : networksByBlockPos.long2ObjectEntrySet()) { + BlockPos blockPos = BlockPos.of(posEntry.getLongKey()); + NETWORK network = posEntry.getValue(); + if (!Objects.requireNonNull(networksByLevelChunk.get(level).get(blockPos)).contains(network)) { + throw new IllegalStateException("Network " + + network + + " is not in chunk " + + new ChunkPos(blockPos)); + } + if (!networksByLevel.get(level).contains(network)) { + throw new IllegalStateException("Network " + network + " is not in level " + level); + } + } + } + } catch (IllegalStateException e) { + SFM.LOGGER.error("BlockNetworkManager inconsistency detected"); + printDebugInfo(); + throw e; + } + } + + public void clearLevel(LEVEL level) { + + networksByLevelBlockPos.remove(level); + networksByLevelChunk.remove(level); + networksByLevel.remove(level); + onChange(); + } + + public void clear() { + + networksByLevelBlockPos.clear(); + networksByLevelChunk.clear(); + networksByLevel.clear(); + onChange(); + } + + public void printDebugInfo() { + + SFM.LOGGER.info("=== BlockNetworkManager Debug Info ==="); + + SFM.LOGGER.info("Networks by Level:"); + for (Map.Entry> entry : networksByLevel.entrySet()) { + LEVEL level = entry.getKey(); + Set networks = entry.getValue(); + SFM.LOGGER.info(" Level {}: {} networks", level, networks.size()); + int i = 0; + for (NETWORK network : networks) { + SFM.LOGGER.info( + " Network {} @ {}: {} members", + i, + Integer.toHexString(System.identityHashCode(network)), + network.members().size() + ); + int iMember = 0; + for (BlockPos blockPos : network.members().keysAsBlockPosSet()) { + SFM.LOGGER.info(" Member {}: {}", iMember, blockPos); + } + i++; + } + } + + SFM.LOGGER.info("Networks by Level Position:"); + for (Map.Entry> entry : networksByLevelBlockPos.entrySet()) { + LEVEL level = entry.getKey(); + BlockPosMap positionMap = entry.getValue(); + SFM.LOGGER.info(" Level {}: {} tracked positions", level, positionMap.size()); + } + + SFM.LOGGER.info("Networks by Level Chunk:"); + for (Map.Entry>> entry : networksByLevelChunk.entrySet()) { + LEVEL level = entry.getKey(); + ChunkPosMap> chunkMap = entry.getValue(); + SFM.LOGGER.info(" Level {}: {} chunks", level, chunkMap.size()); + for (Long2ObjectMap.Entry> chunkEntry : chunkMap.entrySet()) { + ChunkPos chunkPos = new ChunkPos(chunkEntry.getLongKey()); + Set networksInChunk = chunkEntry.getValue(); + SFM.LOGGER.info(" Chunk [{}, {}]: {} networks", chunkPos.x, chunkPos.z, networksInChunk.size()); + int i = 0; + for (NETWORK network : networksInChunk) { + SFM.LOGGER.info( + " Network {} @ {}: {} members", + i, + Integer.toHexString(System.identityHashCode(network)), + network.members().size() + ); + i++; + } + } + } + + SFM.LOGGER.info("=== End BlockNetworkManager Debug Info ==="); + } + + public boolean isEmpty() { + + if (SFMEnvironmentUtils.isInIDE()) { + return networksByLevelBlockPos.isEmpty(); + } else { + boolean a = networksByLevelBlockPos.isEmpty(); + boolean b = networksByLevelChunk.isEmpty(); + boolean c = networksByLevel.isEmpty(); + if (a != b || b != c) { + throw new IllegalStateException("BlockNetworkManager inconsistency"); + } + return a; + } + + } + + public boolean containsLevel(LEVEL testLevel) { + + return networksByLevel.containsKey(testLevel); + } + + public int networkCount() { + + int rtn = 0; + for (Map.Entry> entry : networksByLevel.entrySet()) { + rtn += entry.getValue().size(); + } + return rtn; + } + + /// Remove a network from all tracking structures. + /// Used when clearing a large network for lazy rebuild. + public void untrackNetwork(NETWORK network) { + + LEVEL level = network.level(); + + // Remove all position lookups for this network + BlockPosMap networksByBlockPos = networksByLevelBlockPos.get(level); + if (networksByBlockPos != null) { + networksByBlockPos.removeBlockPositions(network.members().keysAsLongSet()); + if (networksByBlockPos.isEmpty()) { + networksByLevelBlockPos.remove(level); + } + } + + // Remove from chunk lookups + ChunkPosMap> networksByChunkPos = networksByLevelChunk.get(level); + if (networksByChunkPos != null) { + for ( + LongIterator iterator = network.memberBlockPositionsByChunk().keySet().iterator(); + iterator.hasNext(); + ) { + long chunkPosLong = iterator.nextLong(); + Set networksInChunk = networksByChunkPos.get(chunkPosLong); + if (networksInChunk != null) { + networksInChunk.remove(network); + if (networksInChunk.isEmpty()) { + networksByChunkPos.remove(chunkPosLong); + } + } + } + if (networksByChunkPos.isEmpty()) { + networksByLevelChunk.remove(level); + } + } + + // Remove from level lookup + Set networksInLevel = networksByLevel.get(level); + if (networksInLevel != null) { + networksInLevel.remove(network); + if (networksInLevel.isEmpty()) { + networksByLevel.remove(level); + } + } + onChange(); + } + + private void printChangeDiagnostics() { + + boolean enabled = false; + if (!enabled) return; + if (!SFMEnvironmentUtils.isInIDE()) return; + SFM.LOGGER.info("Network lookup changed"); + SFM.LOGGER.info("NETWORKS_BY_LEVEL:"); + for (Map.Entry> entry : networksByLevel.entrySet()) { + LEVEL level = entry.getKey(); + Set networks = entry.getValue(); + SFM.LOGGER.debug("Level {} has {} networks", level, networks.size()); + StringBuilder builder = new StringBuilder(); + for (NETWORK network : networks) { + builder.append(network.members().size()).append(" members; "); + } + SFM.LOGGER.debug(builder.toString()); + } + SFM.LOGGER.info("NETWORKS_BY_CABLE_POSITION:"); + for (Map.Entry> entry : networksByLevelBlockPos.entrySet()) { + LEVEL level = entry.getKey(); + BlockPosMap networksByCablePosition = entry.getValue(); + SFM.LOGGER.debug("Level {} has {} cables", level, networksByCablePosition.size()); + } + } + + private void trackMemberBlockPosForNetwork( + BlockPos memberBlockPos, + NETWORK network + ) { + + // Get the level + LEVEL level = network.level(); + + // Update the position lookup + BlockPosMap networksByBlockPos = networksByLevelBlockPos.computeIfAbsent( + level, + k -> new BlockPosMap<>() + ); + networksByBlockPos.put(memberBlockPos, network); + + // Update the chunk lookup + ChunkPosMap> networksByChunkPos = networksByLevelChunk.computeIfAbsent( + level, + k -> new ChunkPosMap<>() + ); + networksByChunkPos.computeIfAbsent(new ChunkPos(memberBlockPos), k -> Sets.newIdentityHashSet()).add(network); + + // Update the level lookup + networksByLevel.computeIfAbsent(level, k -> Sets.newIdentityHashSet()).add(network); + } + + private void trackNetworkTransfer( + NETWORK oldNetwork, + NETWORK newNetwork + ) { + // Get the level + LEVEL level = oldNetwork.level(); + if (newNetwork.level() != level) + throw new IllegalStateException("Cannot transfer network ownership across levels"); + + // Update the position lookup based on the NEW network's members + // (not the old network, since during splits the new network only has a subset of members) + BlockPosMap networksByLevelBlockPosition = networksByLevelBlockPos.computeIfAbsent( + level, + k -> new BlockPosMap<>() + ); + LongSet newNetworkMemberBlockPositions = newNetwork.members().keysAsLongSet(); + LongIterator newNetworkMemberBlockPositionIterator = newNetworkMemberBlockPositions.longIterator(); + while (newNetworkMemberBlockPositionIterator.hasNext()) { + long newNetworkMemberBlockPos = newNetworkMemberBlockPositionIterator.nextLong(); + // Clobber the old entries + networksByLevelBlockPosition.put(newNetworkMemberBlockPos, newNetwork); + } + + // Update the chunk lookup based on the NEW network's chunk positions + ChunkPosMap> networksByLevelChunk = this.networksByLevelChunk.computeIfAbsent( + level, + k -> new ChunkPosMap<>() + ); + ChunkPosMap newNetworkMemberBlockPositionsByChunk = newNetwork.memberBlockPositionsByChunk(); + for (LongIterator iterator = newNetworkMemberBlockPositionsByChunk.keySet().iterator(); iterator.hasNext(); ) { + long chunkPosLong = iterator.nextLong(); + Set networksInChunk = networksByLevelChunk.computeIfAbsent( + chunkPosLong, + k -> Sets.newIdentityHashSet() + ); + // Remove the old network from this chunk (it may or may not be present) + networksInChunk.remove(oldNetwork); + // Add the new network to this chunk + networksInChunk.add(newNetwork); + } + + // Also need to remove the old network from any chunks it was in but the new network is not + ChunkPosMap oldNetworkMemberBlockPositionsByChunk = oldNetwork.memberBlockPositionsByChunk(); + for (LongIterator iterator = oldNetworkMemberBlockPositionsByChunk.keySet().iterator(); iterator.hasNext(); ) { + long chunkPosLong = iterator.nextLong(); + // Only process chunks that the new network doesn't occupy + if (newNetworkMemberBlockPositionsByChunk.containsKey(chunkPosLong)) continue; + Set networksInChunk = networksByLevelChunk.get(chunkPosLong); + if (networksInChunk != null) { + networksInChunk.remove(oldNetwork); + if (networksInChunk.isEmpty()) { + networksByLevelChunk.remove(chunkPosLong); + } + } + } + + // Update the level lookup + Set networksInLevel = this.networksByLevel.computeIfAbsent( + level, + k -> Sets.newIdentityHashSet() + ); + // Remove the old network + networksInLevel.remove(oldNetwork); + // Add the new network + networksInLevel.add(newNetwork); + } + + /// Remove the lookup table entries for the given position. + /// This DOES NOT perform network splitting! + private void untrackMemberFromNetwork( + BlockPos memberBlockPos, + NETWORK network + ) { + + LEVEL level = network.level(); + ChunkPos memberChunkPos = new ChunkPos(memberBlockPos); + + // Remove the member from the network + network.removeMember(memberBlockPos); + + // Remove the block pos from the position lookup + BlockPosMap networksByBlockPos = networksByLevelBlockPos.get(level); + if (networksByBlockPos != null) { + networksByBlockPos.remove(memberBlockPos); + if (networksByBlockPos.isEmpty()) { + networksByLevelBlockPos.remove(level); + } + } + + // Check if the network still uses the chunk + if (!network.usesChunk(memberChunkPos)) { + // Remove the chunk pos from the chunk pos lookup + ChunkPosMap> networksByChunkPos = networksByLevelChunk.get(level); + if (networksByChunkPos != null) { + Set listOfNetworksInChunk = networksByChunkPos.get(memberChunkPos); + if (listOfNetworksInChunk != null) { + listOfNetworksInChunk.remove(network); + if (listOfNetworksInChunk.isEmpty()) { + networksByChunkPos.remove(memberChunkPos); + } + } + if (networksByChunkPos.isEmpty()) { + networksByLevelChunk.remove(level); + } + } + } + + // Remove the network from the level if it is now empty + if (network.isEmpty()) { + Set networksInLevel = networksByLevel.get(level); + if (networksInLevel != null) { + networksInLevel.remove(network); + if (networksInLevel.isEmpty()) { + networksByLevel.remove(level); + } + } + } + + } + +} diff --git a/src/main/java/ca/teamdman/sfm/common/block_network/CableNetwork.java b/src/main/java/ca/teamdman/sfm/common/block_network/CableNetwork.java index 5ad1abc53..a819c4ad5 100644 --- a/src/main/java/ca/teamdman/sfm/common/block_network/CableNetwork.java +++ b/src/main/java/ca/teamdman/sfm/common/block_network/CableNetwork.java @@ -1,6 +1,10 @@ package ca.teamdman.sfm.common.block_network; +import ca.teamdman.sfm.common.blockentity.LibraryBlockEntity; import ca.teamdman.sfm.common.blockentity.ManagerBlockEntity; +import ca.teamdman.sfm.common.label.LabelPositionHolder; +import ca.teamdman.sfml.program_builder.LibraryDefinitions; +import ca.teamdman.sfml.program_builder.LibraryResolver; import ca.teamdman.sfm.common.capability.SFMBlockCapabilityDiscovery; import ca.teamdman.sfm.common.capability.SFMBlockCapabilityKind; import ca.teamdman.sfm.common.capability.SFMBlockCapabilityResult; @@ -13,7 +17,10 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.util.HashSet; import java.util.List; +import java.util.Optional; +import java.util.Set; import java.util.stream.Stream; /// A cable network extends {@link BlockNetwork} to add capability caching for blocks adjacent to cables. @@ -22,6 +29,23 @@ public class CableNetwork extends BlockNetwork { protected final SFMBlockCapabilityCacheForLevel levelCapabilityCache; + /** + * Cached label positions for auto-discovered blocks (e.g., library blocks). + * This is populated lazily and cleared when the network is rebuilt. + */ + private @Nullable LabelPositionHolder autoLabelCache = null; + + /** + * Tracks the tick when a delayed notification should fire. + * Set to -1 when no notification is pending. + */ + private long pendingNotificationTick = -1; + + /** + * Delay in ticks before a batched notification fires. + */ + private static final int NOTIFICATION_DELAY_TICKS = 5; + public CableNetwork( Level level, BlockNetworkMemberFilterMapper memberFilterMapper @@ -139,12 +163,140 @@ public Stream getCapabilityProviderPositions() { return levelCapabilityCache.getPositions(); } + /** + * Gets or rebuilds the auto-discovered label cache. + * This cache contains positions of special blocks on the network: + * - Manager blocks (cables): labeled with {@link ManagerBlockEntity#MANAGER_LABEL} + * - Library blocks (adjacent to cables): labeled with {@link LibraryBlockEntity#LIBRARY_LABEL} + * + * @return the auto-discovered label position holder + */ + public LabelPositionHolder getOrRebuildAutoLabels() { + if (autoLabelCache != null) { + return autoLabelCache; + } + + autoLabelCache = LabelPositionHolder.empty(); + + // Discover managers and libraries (which can be cables themselves) and adjacent blocks + BlockPosSet visitedAdjacent = new BlockPosSet(); + BlockPos.MutableBlockPos target = new BlockPos.MutableBlockPos(); + Level level = getLevel(); + + for (BlockPos cablePos : members().keysAsBlockPosSet()) { + // Check if the cable itself is a manager + if (level.getBlockEntity(cablePos) instanceof ManagerBlockEntity) { + autoLabelCache.add(ManagerBlockEntity.MANAGER_LABEL, cablePos); + } + + // Check if the cable itself is a library (LibraryBlock implements ICableBlock) + if (level.getBlockEntity(cablePos) instanceof LibraryBlockEntity) { + autoLabelCache.add(LibraryBlockEntity.LIBRARY_LABEL, cablePos); + } + + // Check adjacent positions for library blocks + for (Direction direction : SFMDirections.DIRECTIONS_WITHOUT_NULL) { + target.set(cablePos).move(direction); + + // Skip if already visited + if (!visitedAdjacent.add(target)) { + continue; + } + + // Check if this is a library block + if (level.getBlockEntity(target) instanceof LibraryBlockEntity) { + autoLabelCache.add(LibraryBlockEntity.LIBRARY_LABEL, target.immutable()); + } + } + } + + return autoLabelCache; + } + @Override void purgeChunk(ChunkPos chunkPos) { levelCapabilityCache.bustCacheForChunk(chunkPos); + autoLabelCache = null; super.purgeChunk(chunkPos); } + /** + * Invalidates the auto-label cache without notifying managers. + * Use this when you need to capture manager positions before a change, + * then notify them manually after the change is complete. + */ + public void invalidateAutoLabelCache() { + autoLabelCache = null; + } + + /** + * Invalidates the auto-label cache and schedules a delayed notification to all + * managers and library blocks on this network. Multiple rapid calls will reset + * the delay, ensuring only the final state is processed. + */ + public void invalidateAutoLabelsAndNotifyDependents() { + // Invalidate the cache immediately + autoLabelCache = null; + + // Schedule notification after delay (resets if called again) + long currentTick = getLevel().getGameTime(); + pendingNotificationTick = currentTick + NOTIFICATION_DELAY_TICKS; + + // Register this network for delayed processing + CableNetworkManager.schedulePendingNotification(this); + } + + /** + * Called by CableNetworkManager when the pending notification delay has elapsed. + * Sends notifications to all managers and libraries on the network. + */ + public void processPendingNotification() { + long currentTick = getLevel().getGameTime(); + if (pendingNotificationTick < 0 || currentTick < pendingNotificationTick) { + // Not yet time, or no pending notification + return; + } + + // Clear pending state + pendingNotificationTick = -1; + + // Get current positions (cache was already invalidated) + Set managerPositions = getOrRebuildAutoLabels() + .getPositions(ManagerBlockEntity.MANAGER_LABEL); + Set libraryPositions = getOrRebuildAutoLabels() + .getPositions(LibraryBlockEntity.LIBRARY_LABEL); + + Level level = getLevel(); + + // Notify all managers to re-validate their programs + for (BlockPos pos : managerPositions) { + if (level.getBlockEntity(pos) instanceof ManagerBlockEntity manager) { + manager.rebuildProgramAndUpdateDisk(); + } + } + + // Notify all library blocks to recompile their disks + for (BlockPos pos : libraryPositions) { + if (level.getBlockEntity(pos) instanceof LibraryBlockEntity library) { + library.recompileAllDisks(); + } + } + } + + /** + * @return true if this network has a pending notification scheduled + */ + public boolean hasPendingNotification() { + return pendingNotificationTick >= 0; + } + + /** + * @return the tick when the pending notification should fire, or -1 if none + */ + public long getPendingNotificationTick() { + return pendingNotificationTick; + } + @Override void addAllFromOtherNetwork(BlockNetwork other) { super.addAllFromOtherNetwork(other); @@ -169,6 +321,61 @@ List> splitRemoveMember(BlockPos blockPos) { return branches; } + /** + * Creates a library resolver that finds definitions from library blocks on this cable network. + * Uses the auto-discovered label cache for O(1) library block lookup. + * + * @return A library resolver for this network + */ + public LibraryResolver createLibraryResolver() { + return createLibraryResolver(new HashSet<>()); + } + + /** + * Creates a library resolver that finds definitions from library blocks on this cable network. + * Uses the auto-discovered label cache for O(1) library block lookup. + * Supports tracking circular dependencies across nested library resolutions. + * + * @param librariesBeingResolved Shared set for tracking circular dependencies across resolution calls + * @return A library resolver for this network + */ + public LibraryResolver createLibraryResolver(Set librariesBeingResolved) { + return libraryName -> { + // Check for circular dependency before resolving + if (librariesBeingResolved.contains(libraryName)) { + throw new IllegalArgumentException("Circular library dependency detected: " + libraryName); + } + + // O(1) lookup for all library positions via auto-discovered labels + Set libraryPositions = getOrRebuildAutoLabels() + .getPositions(LibraryBlockEntity.LIBRARY_LABEL); + + Level level = getLevel(); + + // Track this library to detect circular dependencies + librariesBeingResolved.add(libraryName); + try { + // O(N) scan of libraries where N is the number of library blocks (typically small) + for (BlockPos pos : libraryPositions) { + if (!(level.getBlockEntity(pos) instanceof LibraryBlockEntity library)) { + continue; + } + + // Create a nested resolver that shares the circular dependency tracking + LibraryResolver nestedResolver = createLibraryResolver(librariesBeingResolved); + LibraryDefinitions defs = library.getDefinitionsForLibrary(libraryName, nestedResolver); + if (defs != null) { + return Optional.of(defs); + } + } + } finally { + librariesBeingResolved.remove(libraryName); + } + + return Optional.empty(); + }; + } + /// Transfer capability cache entries from this network to a branch network. /// Only transfers entries for positions adjacent to cables in the branch network. private void transferCapabilityCacheToBranch(CableNetwork branch) { diff --git a/src/main/java/ca/teamdman/sfm/common/block_network/CableNetworkManager.java b/src/main/java/ca/teamdman/sfm/common/block_network/CableNetworkManager.java index f047e9f6e..52a4fd4d5 100644 --- a/src/main/java/ca/teamdman/sfm/common/block_network/CableNetworkManager.java +++ b/src/main/java/ca/teamdman/sfm/common/block_network/CableNetworkManager.java @@ -1,16 +1,24 @@ package ca.teamdman.sfm.common.block_network; +import ca.teamdman.sfm.common.blockentity.LibraryBlockEntity; import ca.teamdman.sfm.common.blockentity.ManagerBlockEntity; import ca.teamdman.sfm.common.event_bus.SFMSubscribeEvent; +import ca.teamdman.sfm.common.util.BlockPosMap; +import ca.teamdman.sfm.common.util.SFMDirections; import ca.teamdman.sfm.common.util.Unit; import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.level.Level; +import net.minecraftforge.event.TickEvent; import net.minecraftforge.event.level.ChunkEvent; import net.minecraftforge.event.level.LevelEvent; +import java.util.Iterator; import java.util.List; import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -38,6 +46,8 @@ public class CableNetworkManager { CableNetwork::new ); + private static final Set NETWORKS_WITH_PENDING_NOTIFICATIONS = ConcurrentHashMap.newKeySet(); + public static Optional getOrRegisterNetworkFromManagerPosition(ManagerBlockEntity tile) { Level level = tile.getLevel(); assert level != null; @@ -57,14 +67,68 @@ public static void unregisterNetworkForTestingPurposes(CableNetwork network) { NETWORK_MANAGER.untrackNetwork(network); } + public static BlockPosMap getNetworksForLevel(Level level) { + return NETWORK_MANAGER.getNetworksForLevel(level); + } + public static void onCablePlaced(Level level, BlockPos pos) { if (level.isClientSide()) return; - NETWORK_MANAGER.onMemberAddedToLevel(level, pos); + CableNetwork network = NETWORK_MANAGER.onMemberAddedToLevel(level, pos); + if (network != null) { + // Invalidate and rebuild auto labels to discover newly connected blocks + // Uses delayed notification to batch rapid changes + network.invalidateAutoLabelsAndNotifyDependents(); + } } public static void onCableRemoved(Level level, BlockPos cablePos) { if (level.isClientSide()) return; - NETWORK_MANAGER.onMemberRemovedFromLevel(level, cablePos); + List resultingNetworks = NETWORK_MANAGER.onMemberRemovedFromLevel(level, cablePos); + + // Notify all remaining networks to recompile their dependents + // (network topology changed, libraries may have become inaccessible) + // Uses delayed notification to batch rapid changes + for (CableNetwork remainingNetwork : resultingNetworks) { + remainingNetwork.invalidateAutoLabelsAndNotifyDependents(); + } + + // Always notify blocks adjacent to the removed cable, after network changes are complete + // This handles both networked and standalone cables + notifyBlocksAdjacentToRemovedCable(level, cablePos); + } + + /** + * Notifies libraries and managers directly adjacent to a removed cable. + * Called regardless of whether a network existed, to handle standalone cables. + */ + private static void notifyBlocksAdjacentToRemovedCable(Level level, BlockPos cablePos) { + BlockPos.MutableBlockPos adjacentPos = new BlockPos.MutableBlockPos(); + for (Direction direction : SFMDirections.DIRECTIONS_WITHOUT_NULL) { + adjacentPos.set(cablePos).move(direction); + if (level.getBlockEntity(adjacentPos) instanceof LibraryBlockEntity) { + // Check if this library is still connected to a meaningful network + // (one with other cables besides just the library itself) + boolean stillOnMeaningfulNetwork = NETWORK_MANAGER.getNetworksForLevel(level) + .values().stream() + .anyMatch(net -> net.containsCablePosition(adjacentPos) && net.getCableCount() > 1); + if (!stillOnMeaningfulNetwork) { + // Library is isolated - notify via its own network (batched) to recompile + getOrRegisterNetworkFromCablePosition(level, adjacentPos.immutable()) + .ifPresent(CableNetwork::invalidateAutoLabelsAndNotifyDependents); + } + } else if (level.getBlockEntity(adjacentPos) instanceof ManagerBlockEntity) { + // Check if this manager is still connected to a meaningful network + // (one with other cables besides just the manager itself) + boolean stillOnMeaningfulNetwork = NETWORK_MANAGER.getNetworksForLevel(level) + .values().stream() + .anyMatch(net -> net.containsCablePosition(adjacentPos) && net.getCableCount() > 1); + if (!stillOnMeaningfulNetwork) { + // Manager is isolated - notify via its own network (batched) to rebuild + getOrRegisterNetworkFromCablePosition(level, adjacentPos.immutable()) + .ifPresent(CableNetwork::invalidateAutoLabelsAndNotifyDependents); + } + } + } } public static void purgeCableNetworkForManager(ManagerBlockEntity manager) { @@ -99,6 +163,7 @@ public static List getBadCableCachePositions(Level level) { public static void clear() { NETWORK_MANAGER.clear(); + NETWORKS_WITH_PENDING_NOTIFICATIONS.clear(); } @SFMSubscribeEvent @@ -113,5 +178,44 @@ public static void onChunkUnload(ChunkEvent.Unload event) { public static void onLevelUnload(LevelEvent.Unload event) { if (!(event.getLevel() instanceof ServerLevel level)) return; NETWORK_MANAGER.clearLevel(level); + // Remove any pending notifications for this level + NETWORKS_WITH_PENDING_NOTIFICATIONS.removeIf(net -> net.getLevel() == level); + } + + @SFMSubscribeEvent + public static void onServerTick(TickEvent.ServerTickEvent event) { + if (event.phase != TickEvent.Phase.END) return; + processPendingNotifications(); + } + + /** + * Registers a network for delayed notification processing. + * Called by CableNetwork when a notification is scheduled. + */ + public static void schedulePendingNotification(CableNetwork network) { + NETWORKS_WITH_PENDING_NOTIFICATIONS.add(network); + } + + /** + * Processes all networks with pending notifications that are ready to fire. + */ + private static void processPendingNotifications() { + if (NETWORKS_WITH_PENDING_NOTIFICATIONS.isEmpty()) return; + + Iterator iterator = NETWORKS_WITH_PENDING_NOTIFICATIONS.iterator(); + while (iterator.hasNext()) { + CableNetwork network = iterator.next(); + long pendingTick = network.getPendingNotificationTick(); + if (pendingTick < 0) { + // No longer pending + iterator.remove(); + continue; + } + long currentTick = network.getLevel().getGameTime(); + if (currentTick >= pendingTick) { + network.processPendingNotification(); + iterator.remove(); + } + } } } diff --git a/src/main/java/ca/teamdman/sfm/common/blockentity/LibraryBlockEntity.java b/src/main/java/ca/teamdman/sfm/common/blockentity/LibraryBlockEntity.java new file mode 100644 index 000000000..afafc6ba9 --- /dev/null +++ b/src/main/java/ca/teamdman/sfm/common/blockentity/LibraryBlockEntity.java @@ -0,0 +1,462 @@ +package ca.teamdman.sfm.common.blockentity; + +import ca.teamdman.sfm.common.block_network.CableNetwork; +import ca.teamdman.sfm.common.block_network.CableNetworkManager; +import ca.teamdman.sfm.common.containermenu.LibraryContainerMenu; +import ca.teamdman.sfm.common.item.DiskItem; +import ca.teamdman.sfm.common.localization.LocalizationKeys; +import ca.teamdman.sfm.common.registry.SFMBlockEntities; +import ca.teamdman.sfm.common.registry.SFMItems; +import ca.teamdman.sfm.common.util.SFMContainerUtil; +import ca.teamdman.sfml.ast.Program; +import ca.teamdman.sfml.program_builder.LibraryDefinitions; +import ca.teamdman.sfml.program_builder.LibraryResolver; +import ca.teamdman.sfml.program_builder.ProgramBuilder; +import net.minecraft.core.BlockPos; +import net.minecraft.core.NonNullList; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.game.ClientGamePacketListener; +import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; +import net.minecraft.world.ContainerHelper; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.entity.BaseContainerBlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import org.jetbrains.annotations.Nullable; + +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * Block entity for library blocks that store disks containing SFML definitions. + * Library blocks can hold multiple disks, and each disk's NAME becomes the importable path. + * Libraries are auto-discovered on the cable network. + */ +public class LibraryBlockEntity extends BaseContainerBlockEntity { + + /** + * Reserved label used to identify library blocks in the cable network. + * This label is auto-registered when the network discovers adjacent library blocks. + */ + public static final String LIBRARY_LABEL = "sfm:library"; + + public static final int DISK_SLOT_COUNT = 10; + + private final NonNullList items = NonNullList.withSize(DISK_SLOT_COUNT, ItemStack.EMPTY); + + public LibraryBlockEntity(BlockPos pos, BlockState state) { + super(SFMBlockEntities.LIBRARY_BLOCK_ENTITY.get(), pos, state); + } + + /** + * Gets library entries with their slot indices from inserted disks. + * Disks without a NAME statement are shown with a placeholder name. + */ + public List getLibraryEntries() { + return LibraryContainerMenu.extractLibraryEntries(this, items.size()); + } + + /** + * Gets library definitions from a disk with a matching NAME. + * This overload uses no library resolver (for backward compatibility). + * + * @param libraryName The name to search for + * @return The parsed definitions, or null if not found + */ + public @Nullable LibraryDefinitions getDefinitionsForLibrary(String libraryName) { + return getDefinitionsForLibrary(libraryName, LibraryResolver.NONE); + } + + /** + * Gets library definitions from a disk with a matching NAME. + * Supports resolving USE statements within the library disk. + * + * @param libraryName The name to search for + * @param resolver The library resolver for resolving USE statements (handles circular dependency tracking) + * @return The parsed definitions, or null if not found + */ + public @Nullable LibraryDefinitions getDefinitionsForLibrary( + String libraryName, + LibraryResolver resolver + ) { + for (int i = 0; i < DISK_SLOT_COUNT; i++) { + ItemStack disk = getItem(i); + if (!DiskItem.isValidDisk(disk)) continue; + + String source = DiskItem.getProgramString(disk); + String name = DiskItem.extractName(source); + + if (libraryName.equals(name)) { + return parseLibraryDefinitions(source, resolver); + } + } + return null; + } + + /** + * Parses library definitions from source code. + * Extracts protocols, structs, and macros, including those imported via USE statements. + * + * @param source The source code to parse + * @param resolver The library resolver for resolving USE statements (handles circular dependency tracking) + * @return The parsed library definitions + */ + public LibraryDefinitions parseLibraryDefinitions( + String source, + LibraryResolver resolver + ) { + // Use ProgramBuilder to parse the full program, including USE statements + var buildResult = new ProgramBuilder(source) + .withLibraryResolver(resolver) + .useCache(false) + .build(); + + // Check for errors + if (!buildResult.metadata().errors().isEmpty()) { + String errorMsg = buildResult.metadata().errors().stream() + .map(e -> { + Object[] args = e.getArgs(); + if (args.length > 0) { + return String.valueOf(args[0]); + } + return e.getKey(); + }) + .reduce((a, b) -> a + ", " + b) + .orElse("Unknown error"); + throw new IllegalArgumentException(errorMsg); + } + + Program program = buildResult.program(); + if (program == null) { + throw new IllegalArgumentException("Failed to parse library definitions"); + } + + // Extract definitions from the parsed program (includes imported definitions) + return new LibraryDefinitions( + program.protocolDefinitions(), + program.structDefinitions(), + program.macroDefinitions() + ); + } + + /** + * Creates a library resolver for use when compiling disks in this library block. + * First checks disks in this library block, then checks other libraries on the network. + * + * @return A library resolver that can find libraries in this block and on the network + */ + public LibraryResolver createLibraryResolver() { + if (level == null) { + // Fallback: only resolve from this library block's disks + return createLocalLibraryResolver(new HashSet<>()); + } + + // Get or register the cable network at this position + // LibraryBlock implements ICableBlock, so it IS a cable + // This ensures network discovery happens even after world reload + Optional networkOpt = CableNetworkManager + .getOrRegisterNetworkFromCablePosition(level, worldPosition); + + if (networkOpt.isEmpty()) { + // Not connected to network: only resolve from this library block's disks + return createLocalLibraryResolver(new HashSet<>()); + } + + // Connected to network: use the network's resolver (which includes all library blocks) + return networkOpt.get().createLibraryResolver(); + } + + /** + * Creates a library resolver that only resolves from this library block's disks. + * Used when not connected to a cable network. + * + * @param librariesBeingResolved Shared set for tracking circular dependencies + * @return A library resolver for this block only + */ + private LibraryResolver createLocalLibraryResolver(Set librariesBeingResolved) { + return libraryName -> { + // Check for circular dependency + if (librariesBeingResolved.contains(libraryName)) { + throw new IllegalArgumentException("Circular library dependency detected: " + libraryName); + } + + librariesBeingResolved.add(libraryName); + try { + // Create a nested resolver for any USE statements in the resolved library + LibraryResolver nestedResolver = createLocalLibraryResolver(librariesBeingResolved); + LibraryDefinitions defs = getDefinitionsForLibrary(libraryName, nestedResolver); + return Optional.ofNullable(defs); + } finally { + librariesBeingResolved.remove(libraryName); + } + }; + } + + @Override + protected void saveAdditional(CompoundTag tag) { + super.saveAdditional(tag); + ContainerHelper.saveAllItems(tag, items); + } + + @Override + public void load(CompoundTag tag) { + super.load(tag); + + // Backward compatibility: migrate old source code format to disk + if (tag.contains("Source")) { + String legacySource = tag.getString("Source"); + if (!legacySource.isEmpty()) { + ItemStack disk = new ItemStack(SFMItems.DISK_ITEM.get()); + DiskItem.setProgram(disk, legacySource); + items.set(0, disk); + } + } else { + ContainerHelper.loadAllItems(tag, items); + } + } + + @Override + protected Component getDefaultName() { + return LocalizationKeys.LIBRARY_CONTAINER.getComponent(); + } + + @Override + protected AbstractContainerMenu createMenu(int containerId, Inventory inventory) { + return new LibraryContainerMenu(containerId, inventory, this); + } + + @Override + public int getContainerSize() { + return items.size(); + } + + @Override + public boolean isEmpty() { + for (ItemStack item : items) { + if (!item.isEmpty()) return false; + } + return true; + } + + @Override + public ItemStack getItem(int slot) { + if (slot < 0 || slot >= items.size()) return ItemStack.EMPTY; + return items.get(slot); + } + + @Override + public ItemStack removeItem(int slot, int amount) { + ItemStack result = ContainerHelper.removeItem(items, slot, amount); + setChanged(); + return result; + } + + @Override + public ItemStack removeItemNoUpdate(int slot) { + ItemStack result = ContainerHelper.takeItem(items, slot); + setChanged(); + return result; + } + + @Override + public void setItem(int slot, ItemStack stack) { + if (slot < 0 || slot >= items.size()) return; + items.set(slot, stack); + setChanged(); + } + + @Override + public int getMaxStackSize() { + return 1; + } + + @Override + public boolean canPlaceItem(int slot, ItemStack stack) { + return stack.getItem() instanceof DiskItem; + } + + @Override + public boolean stillValid(Player player) { + return SFMContainerUtil.stillValid(this, player); + } + + @Override + public void clearContent() { + items.clear(); + } + + // Client-side cache of disk mask for rendering + private int clientDiskMask = 0; + private int clientErrorMask = 0; + private int clientWarningMask = 0; + + @Override + public void setChanged() { + super.setChanged(); + // Sync to client for renderer updates + if (level != null && !level.isClientSide()) { + level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 3); + // Notify cable networks that library configuration may have changed + notifyNetworkLibraryChanged(); + } + } + + /** + * Notifies any cable networks adjacent to this library block that the library + * configuration has changed, causing managers and other library disks to recompile. + * If not connected to any network, recompiles local disks directly. + */ + private void notifyNetworkLibraryChanged() { + if (level == null || level.isClientSide()) return; + + // Get or register the cable network at this position + // LibraryBlock implements ICableBlock, so it IS a cable + // This ensures network discovery happens even after world reload + Optional networkOpt = CableNetworkManager + .getOrRegisterNetworkFromCablePosition(level, worldPosition); + + if (networkOpt.isPresent()) { + // On a network: notify dependents to recompile + networkOpt.get().invalidateAutoLabelsAndNotifyDependents(); + } else { + // Not on a network: recompile local disks to update errors + // (e.g., resolved circular dependencies) + recompileAllDisks(); + } + } + + /** + * Recompiles all disks in this library block. + * Called when a library on the network changes to update circular dependency errors. + */ + public void recompileAllDisks() { + if (level == null || level.isClientSide()) return; + + var resolver = createLibraryResolver(); + for (int i = 0; i < DISK_SLOT_COUNT; i++) { + ItemStack disk = getItem(i); + if (!DiskItem.isValidDisk(disk)) continue; + + DiskItem.compileAndUpdateErrorsAndWarnings(disk, null, true, resolver); + } + + // Sync to client for renderer updates (without triggering network notification) + level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 3); + } + + // Client sync methods for BlockEntityRenderer + @Override + public CompoundTag getUpdateTag() { + CompoundTag tag = super.getUpdateTag(); + tag.putInt("DiskMask", computeDiskMask()); + tag.putInt("ErrorMask", computeErrorMask()); + tag.putInt("WarningMask", computeWarningMask()); + return tag; + } + + @Override + public void handleUpdateTag(CompoundTag tag) { + super.handleUpdateTag(tag); + clientDiskMask = tag.getInt("DiskMask"); + clientErrorMask = tag.getInt("ErrorMask"); + clientWarningMask = tag.getInt("WarningMask"); + } + + @Override + public Packet getUpdatePacket() { + return ClientboundBlockEntityDataPacket.create(this); + } + + @Override + public void onDataPacket(net.minecraft.network.Connection net, ClientboundBlockEntityDataPacket pkt) { + CompoundTag tag = pkt.getTag(); + if (tag != null) { + clientDiskMask = tag.getInt("DiskMask"); + clientErrorMask = tag.getInt("ErrorMask"); + clientWarningMask = tag.getInt("WarningMask"); + } + } + + /** + * Computes a bitmask indicating which slots have disks (server-side). + */ + private int computeDiskMask() { + int mask = 0; + for (int i = 0; i < items.size(); i++) { + if (DiskItem.isValidDisk(items.get(i))) { + mask |= (1 << i); + } + } + return mask; + } + + /** + * Computes a bitmask indicating which slots have disks with errors (server-side). + */ + private int computeErrorMask() { + int mask = 0; + for (int i = 0; i < items.size(); i++) { + ItemStack disk = items.get(i); + if (DiskItem.isValidDisk(disk) && !DiskItem.getErrors(disk).isEmpty()) { + mask |= (1 << i); + } + } + return mask; + } + + /** + * Computes a bitmask indicating which slots have disks with warnings (server-side). + */ + private int computeWarningMask() { + int mask = 0; + for (int i = 0; i < items.size(); i++) { + ItemStack disk = items.get(i); + if (DiskItem.isValidDisk(disk) && !DiskItem.getWarnings(disk).isEmpty()) { + mask |= (1 << i); + } + } + return mask; + } + + /** + * Returns a bitmask indicating which slots have disks. + * Bit 0 = slot 0, bit 1 = slot 1, etc. + * Used by the BlockEntityRenderer to show disk indicators. + */ + public int getDiskSlotMask() { + if (level != null && level.isClientSide()) { + return clientDiskMask; + } + return computeDiskMask(); + } + + /** + * Returns a bitmask indicating which slots have disks with errors. + * Bit 0 = slot 0, bit 1 = slot 1, etc. + * Used by the BlockEntityRenderer to show error indicators. + */ + public int getErrorSlotMask() { + if (level != null && level.isClientSide()) { + return clientErrorMask; + } + return computeErrorMask(); + } + + /** + * Returns a bitmask indicating which slots have disks with warnings. + * Bit 0 = slot 0, bit 1 = slot 1, etc. + * Used by the BlockEntityRenderer to show warning indicators. + */ + public int getWarningSlotMask() { + if (level != null && level.isClientSide()) { + return clientWarningMask; + } + return computeWarningMask(); + } +} diff --git a/src/main/java/ca/teamdman/sfm/common/blockentity/ManagerBlockEntity.java b/src/main/java/ca/teamdman/sfm/common/blockentity/ManagerBlockEntity.java index 3b4e42e8c..d1a92893f 100644 --- a/src/main/java/ca/teamdman/sfm/common/blockentity/ManagerBlockEntity.java +++ b/src/main/java/ca/teamdman/sfm/common/blockentity/ManagerBlockEntity.java @@ -1,6 +1,8 @@ package ca.teamdman.sfm.common.blockentity; import ca.teamdman.sfm.SFM; +import ca.teamdman.sfm.common.block_network.CableNetwork; +import ca.teamdman.sfm.common.block_network.CableNetworkManager; import ca.teamdman.sfm.common.config.SFMConfig; import ca.teamdman.sfm.common.config.SFMConfigTracker; import ca.teamdman.sfm.common.containermenu.ManagerContainerMenu; @@ -21,6 +23,7 @@ import ca.teamdman.sfm.common.timing.SFMInstant; import ca.teamdman.sfm.common.util.SFMContainerUtil; import ca.teamdman.sfml.ast.Program; +import ca.teamdman.sfml.program_builder.LibraryResolver; import com.google.common.base.Joiner; import net.minecraft.ChatFormatting; import net.minecraft.CrashReportCategory; @@ -44,9 +47,16 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Optional; import java.util.Set; public class ManagerBlockEntity extends BaseContainerBlockEntity { + /** + * Reserved label used to identify manager blocks in the cable network. + * This label is auto-registered when the network discovers manager blocks. + */ + public static final String MANAGER_LABEL = "sfm:manager"; + public static final int TICK_TIME_HISTORY_SIZE = 20; public final TranslatableLogger logger; @@ -354,16 +364,36 @@ public void rebuildProgramAndUpdateDisk() { this.program = null; } else { this.incrementRebuildWarningsCooldown(); + LibraryResolver resolver = createLibraryResolver(); this.program = DiskItem.compileAndUpdateErrorsAndWarnings( disk, this, - this.shouldRebuildWarnings() + this.shouldRebuildWarnings(), + resolver ); } this.configRevision = SFMConfig.SERVER_CONFIG.getRevision(); sendUpdatePacket(); } + /** + * Creates a library resolver that finds definitions from library blocks on the cable network. + * Delegates to the network's resolver implementation. + */ + public LibraryResolver createLibraryResolver() { + if (level == null) { + return LibraryResolver.NONE; + } + + // Get the cable network for this manager + Optional networkOpt = CableNetworkManager.getOrRegisterNetworkFromManagerPosition(this); + if (networkOpt.isEmpty()) { + return LibraryResolver.NONE; + } + + return networkOpt.get().createLibraryResolver(); + } + @Override public int getContainerSize() { diff --git a/src/main/java/ca/teamdman/sfm/common/blockentity/ToughCableFacadeBlockEntity.java b/src/main/java/ca/teamdman/sfm/common/blockentity/ToughCableFacadeBlockEntity.java index 5a53463a7..2b6dd6310 100644 --- a/src/main/java/ca/teamdman/sfm/common/blockentity/ToughCableFacadeBlockEntity.java +++ b/src/main/java/ca/teamdman/sfm/common/blockentity/ToughCableFacadeBlockEntity.java @@ -1,29 +1,29 @@ -package ca.teamdman.sfm.common.blockentity; - -import ca.teamdman.sfm.common.registry.SFMBlockEntities; -import net.minecraft.core.BlockPos; -import net.minecraft.world.level.block.state.BlockState; -import net.minecraftforge.client.model.data.ModelData; - -public class ToughCableFacadeBlockEntity extends CommonFacadeBlockEntity { - public ToughCableFacadeBlockEntity( - BlockPos pos, - BlockState state - ) { - - super(SFMBlockEntities.TOUGH_CABLE_FACADE_BLOCK_ENTITY.get(), pos, state); - } - - @Override - public ModelData getModelData() { - - if (getFacadeData() != null) { - return ModelData - .builder() - .with(FACADE_BLOCK_STATE_MODEL_PROPERTY, getFacadeData().facadeBlockState()) - .build(); - } - return ModelData.EMPTY; - } - -} +package ca.teamdman.sfm.common.blockentity; + +import ca.teamdman.sfm.common.registry.SFMBlockEntities; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraftforge.client.model.data.ModelData; + +public class ToughCableFacadeBlockEntity extends CommonFacadeBlockEntity { + public ToughCableFacadeBlockEntity( + BlockPos pos, + BlockState state + ) { + + super(SFMBlockEntities.TOUGH_CABLE_FACADE_BLOCK_ENTITY.get(), pos, state); + } + + @Override + public ModelData getModelData() { + + if (getFacadeData() != null) { + return ModelData + .builder() + .with(FACADE_BLOCK_STATE_MODEL_PROPERTY, getFacadeData().facadeBlockState()) + .build(); + } + return ModelData.EMPTY; + } + +} diff --git a/src/main/java/ca/teamdman/sfm/common/blockentity/ToughFancyCableFacadeBlockEntity.java b/src/main/java/ca/teamdman/sfm/common/blockentity/ToughFancyCableFacadeBlockEntity.java index c74f6095c..f0bb3202e 100644 --- a/src/main/java/ca/teamdman/sfm/common/blockentity/ToughFancyCableFacadeBlockEntity.java +++ b/src/main/java/ca/teamdman/sfm/common/blockentity/ToughFancyCableFacadeBlockEntity.java @@ -1,32 +1,32 @@ -package ca.teamdman.sfm.common.blockentity; - -import ca.teamdman.sfm.common.registry.SFMBlockEntities; -import net.minecraft.core.BlockPos; -import net.minecraft.world.level.block.state.BlockState; -import net.minecraftforge.client.model.data.ModelData; - -import static ca.teamdman.sfm.common.blockentity.FancyCableFacadeBlockEntity.FACADE_DIRECTION; - -public class ToughFancyCableFacadeBlockEntity extends CommonFacadeBlockEntity { - public ToughFancyCableFacadeBlockEntity( - BlockPos pos, - BlockState state - ) { - - super(SFMBlockEntities.TOUGH_FANCY_CABLE_FACADE_BLOCK_ENTITY.get(), pos, state); - } - - @Override - public ModelData getModelData() { - - if (getFacadeData() != null) { - - return ModelData.builder() - .with(IFacadeBlockEntity.FACADE_BLOCK_STATE_MODEL_PROPERTY, getFacadeData().facadeBlockState()) - .with(FACADE_DIRECTION, getFacadeData().facadeDirection()) - .build(); - } - return ModelData.EMPTY; - } - -} +package ca.teamdman.sfm.common.blockentity; + +import ca.teamdman.sfm.common.registry.SFMBlockEntities; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraftforge.client.model.data.ModelData; + +import static ca.teamdman.sfm.common.blockentity.FancyCableFacadeBlockEntity.FACADE_DIRECTION; + +public class ToughFancyCableFacadeBlockEntity extends CommonFacadeBlockEntity { + public ToughFancyCableFacadeBlockEntity( + BlockPos pos, + BlockState state + ) { + + super(SFMBlockEntities.TOUGH_FANCY_CABLE_FACADE_BLOCK_ENTITY.get(), pos, state); + } + + @Override + public ModelData getModelData() { + + if (getFacadeData() != null) { + + return ModelData.builder() + .with(IFacadeBlockEntity.FACADE_BLOCK_STATE_MODEL_PROPERTY, getFacadeData().facadeBlockState()) + .with(FACADE_DIRECTION, getFacadeData().facadeDirection()) + .build(); + } + return ModelData.EMPTY; + } + +} diff --git a/src/main/java/ca/teamdman/sfm/common/blockentity/TunnelledCableBlockEntity.java b/src/main/java/ca/teamdman/sfm/common/blockentity/TunnelledCableBlockEntity.java index 4f64f754d..528ee7b69 100644 --- a/src/main/java/ca/teamdman/sfm/common/blockentity/TunnelledCableBlockEntity.java +++ b/src/main/java/ca/teamdman/sfm/common/blockentity/TunnelledCableBlockEntity.java @@ -1,35 +1,35 @@ -package ca.teamdman.sfm.common.blockentity; - -import ca.teamdman.sfm.common.registry.SFMBlockEntities; -import net.minecraft.core.BlockPos; -import net.minecraft.core.Direction; -import net.minecraft.server.level.ServerLevel; -import net.minecraft.world.level.block.entity.BlockEntity; -import net.minecraft.world.level.block.state.BlockState; -import net.minecraftforge.common.capabilities.Capability; -import net.minecraftforge.common.util.LazyOptional; -import org.jetbrains.annotations.Nullable; - -public class TunnelledCableBlockEntity extends BlockEntity { - public TunnelledCableBlockEntity(BlockPos blockPos, BlockState blockState) { - super(SFMBlockEntities.TUNNELLED_CABLE_BLOCK_ENTITY.get(), blockPos, blockState); - } - - @Override - public LazyOptional getCapability(Capability cap, @Nullable Direction side) { - if (!(this.level instanceof ServerLevel lvl)) { - return LazyOptional.empty(); - } - - if (side == null) { - return super.getCapability(cap, null); - } - - BlockEntity be = lvl.getBlockEntity(this.getBlockPos().offset(side.getOpposite().getNormal())); - if (be == null) { - return LazyOptional.empty(); - } - - return be.getCapability(cap, side); - } -} +package ca.teamdman.sfm.common.blockentity; + +import ca.teamdman.sfm.common.registry.SFMBlockEntities; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraftforge.common.capabilities.Capability; +import net.minecraftforge.common.util.LazyOptional; +import org.jetbrains.annotations.Nullable; + +public class TunnelledCableBlockEntity extends BlockEntity { + public TunnelledCableBlockEntity(BlockPos blockPos, BlockState blockState) { + super(SFMBlockEntities.TUNNELLED_CABLE_BLOCK_ENTITY.get(), blockPos, blockState); + } + + @Override + public LazyOptional getCapability(Capability cap, @Nullable Direction side) { + if (!(this.level instanceof ServerLevel lvl)) { + return LazyOptional.empty(); + } + + if (side == null) { + return super.getCapability(cap, null); + } + + BlockEntity be = lvl.getBlockEntity(this.getBlockPos().offset(side.getOpposite().getNormal())); + if (be == null) { + return LazyOptional.empty(); + } + + return be.getCapability(cap, side); + } +} diff --git a/src/main/java/ca/teamdman/sfm/common/blockentity/TunnelledCableFacadeBlockEntity.java b/src/main/java/ca/teamdman/sfm/common/blockentity/TunnelledCableFacadeBlockEntity.java index d8f7fb256..484386f74 100644 --- a/src/main/java/ca/teamdman/sfm/common/blockentity/TunnelledCableFacadeBlockEntity.java +++ b/src/main/java/ca/teamdman/sfm/common/blockentity/TunnelledCableFacadeBlockEntity.java @@ -1,57 +1,57 @@ -package ca.teamdman.sfm.common.blockentity; - -import ca.teamdman.sfm.common.registry.SFMBlockEntities; -import net.minecraft.core.BlockPos; -import net.minecraft.core.Direction; -import net.minecraft.server.level.ServerLevel; -import net.minecraft.world.level.block.entity.BlockEntity; -import net.minecraft.world.level.block.state.BlockState; -import net.minecraftforge.client.model.data.ModelData; -import net.minecraftforge.common.capabilities.Capability; -import net.minecraftforge.common.util.LazyOptional; -import org.jetbrains.annotations.Nullable; - -public class TunnelledCableFacadeBlockEntity extends CommonFacadeBlockEntity { - public TunnelledCableFacadeBlockEntity( - BlockPos blockPos, - BlockState blockState - ) { - - super(SFMBlockEntities.TUNNELLED_CABLE_FACADE_BLOCK_ENTITY.get(), blockPos, blockState); - } - - @Override - public LazyOptional getCapability( - Capability cap, - @Nullable Direction side - ) { - - if (!(this.level instanceof ServerLevel lvl)) { - return LazyOptional.empty(); - } - - if (side == null) { - return super.getCapability(cap, null); - } - - BlockEntity be = lvl.getBlockEntity(this.getBlockPos().offset(side.getOpposite().getNormal())); - if (be == null) { - return LazyOptional.empty(); - } - - return be.getCapability(cap, side); - } - - @Override - public ModelData getModelData() { - - if (getFacadeData() != null) { - return ModelData - .builder() - .with(FACADE_BLOCK_STATE_MODEL_PROPERTY, getFacadeData().facadeBlockState()) - .build(); - } - return ModelData.EMPTY; - } - -} +package ca.teamdman.sfm.common.blockentity; + +import ca.teamdman.sfm.common.registry.SFMBlockEntities; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraftforge.client.model.data.ModelData; +import net.minecraftforge.common.capabilities.Capability; +import net.minecraftforge.common.util.LazyOptional; +import org.jetbrains.annotations.Nullable; + +public class TunnelledCableFacadeBlockEntity extends CommonFacadeBlockEntity { + public TunnelledCableFacadeBlockEntity( + BlockPos blockPos, + BlockState blockState + ) { + + super(SFMBlockEntities.TUNNELLED_CABLE_FACADE_BLOCK_ENTITY.get(), blockPos, blockState); + } + + @Override + public LazyOptional getCapability( + Capability cap, + @Nullable Direction side + ) { + + if (!(this.level instanceof ServerLevel lvl)) { + return LazyOptional.empty(); + } + + if (side == null) { + return super.getCapability(cap, null); + } + + BlockEntity be = lvl.getBlockEntity(this.getBlockPos().offset(side.getOpposite().getNormal())); + if (be == null) { + return LazyOptional.empty(); + } + + return be.getCapability(cap, side); + } + + @Override + public ModelData getModelData() { + + if (getFacadeData() != null) { + return ModelData + .builder() + .with(FACADE_BLOCK_STATE_MODEL_PROPERTY, getFacadeData().facadeBlockState()) + .build(); + } + return ModelData.EMPTY; + } + +} diff --git a/src/main/java/ca/teamdman/sfm/common/blockentity/TunnelledFancyCableBlockEntity.java b/src/main/java/ca/teamdman/sfm/common/blockentity/TunnelledFancyCableBlockEntity.java index 133b8ad60..b67385208 100644 --- a/src/main/java/ca/teamdman/sfm/common/blockentity/TunnelledFancyCableBlockEntity.java +++ b/src/main/java/ca/teamdman/sfm/common/blockentity/TunnelledFancyCableBlockEntity.java @@ -1,35 +1,35 @@ -package ca.teamdman.sfm.common.blockentity; - -import ca.teamdman.sfm.common.registry.SFMBlockEntities; -import net.minecraft.core.BlockPos; -import net.minecraft.core.Direction; -import net.minecraft.server.level.ServerLevel; -import net.minecraft.world.level.block.entity.BlockEntity; -import net.minecraft.world.level.block.state.BlockState; -import net.minecraftforge.common.capabilities.Capability; -import net.minecraftforge.common.util.LazyOptional; -import org.jetbrains.annotations.Nullable; - -public class TunnelledFancyCableBlockEntity extends BlockEntity { - public TunnelledFancyCableBlockEntity(BlockPos blockPos, BlockState blockState) { - super(SFMBlockEntities.TUNNELLED_FANCY_CABLE_BLOCK_ENTITY.get(), blockPos, blockState); - } - - @Override - public LazyOptional getCapability(Capability cap, @Nullable Direction side) { - if (!(this.level instanceof ServerLevel lvl)) { - return LazyOptional.empty(); - } - - if (side == null) { - return super.getCapability(cap, null); - } - - BlockEntity be = lvl.getBlockEntity(this.getBlockPos().offset(side.getOpposite().getNormal())); - if (be == null) { - return LazyOptional.empty(); - } - - return be.getCapability(cap, side); - } -} +package ca.teamdman.sfm.common.blockentity; + +import ca.teamdman.sfm.common.registry.SFMBlockEntities; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraftforge.common.capabilities.Capability; +import net.minecraftforge.common.util.LazyOptional; +import org.jetbrains.annotations.Nullable; + +public class TunnelledFancyCableBlockEntity extends BlockEntity { + public TunnelledFancyCableBlockEntity(BlockPos blockPos, BlockState blockState) { + super(SFMBlockEntities.TUNNELLED_FANCY_CABLE_BLOCK_ENTITY.get(), blockPos, blockState); + } + + @Override + public LazyOptional getCapability(Capability cap, @Nullable Direction side) { + if (!(this.level instanceof ServerLevel lvl)) { + return LazyOptional.empty(); + } + + if (side == null) { + return super.getCapability(cap, null); + } + + BlockEntity be = lvl.getBlockEntity(this.getBlockPos().offset(side.getOpposite().getNormal())); + if (be == null) { + return LazyOptional.empty(); + } + + return be.getCapability(cap, side); + } +} diff --git a/src/main/java/ca/teamdman/sfm/common/blockentity/TunnelledFancyCableFacadeBlockEntity.java b/src/main/java/ca/teamdman/sfm/common/blockentity/TunnelledFancyCableFacadeBlockEntity.java index 5a84a4425..2db0c0129 100644 --- a/src/main/java/ca/teamdman/sfm/common/blockentity/TunnelledFancyCableFacadeBlockEntity.java +++ b/src/main/java/ca/teamdman/sfm/common/blockentity/TunnelledFancyCableFacadeBlockEntity.java @@ -1,61 +1,61 @@ -package ca.teamdman.sfm.common.blockentity; - -import ca.teamdman.sfm.common.registry.SFMBlockEntities; -import net.minecraft.core.BlockPos; -import net.minecraft.core.Direction; -import net.minecraft.server.level.ServerLevel; -import net.minecraft.world.level.block.entity.BlockEntity; -import net.minecraft.world.level.block.state.BlockState; -import net.minecraftforge.client.model.data.ModelData; -import net.minecraftforge.common.capabilities.Capability; -import net.minecraftforge.common.util.LazyOptional; -import org.jetbrains.annotations.Nullable; - -import static ca.teamdman.sfm.common.blockentity.FancyCableFacadeBlockEntity.FACADE_DIRECTION; - -public class TunnelledFancyCableFacadeBlockEntity extends CommonFacadeBlockEntity { - public TunnelledFancyCableFacadeBlockEntity( - BlockPos blockPos, - BlockState blockState - ) { - - super(SFMBlockEntities.TUNNELLED_FANCY_CABLE_FACADE_BLOCK_ENTITY.get(), blockPos, blockState); - } - - @Override - public LazyOptional getCapability( - Capability cap, - @Nullable Direction side - ) { - - if (!(this.level instanceof ServerLevel lvl)) { - return LazyOptional.empty(); - } - - if (side == null) { - return super.getCapability(cap, null); - } - - BlockEntity be = lvl.getBlockEntity(this.getBlockPos().offset(side.getOpposite().getNormal())); - if (be == null) { - return LazyOptional.empty(); - } - - return be.getCapability(cap, side); - } - - - @Override - public ModelData getModelData() { - - if (getFacadeData() != null) { - - return ModelData.builder() - .with(IFacadeBlockEntity.FACADE_BLOCK_STATE_MODEL_PROPERTY, getFacadeData().facadeBlockState()) - .with(FACADE_DIRECTION, getFacadeData().facadeDirection()) - .build(); - } - return ModelData.EMPTY; - } - -} +package ca.teamdman.sfm.common.blockentity; + +import ca.teamdman.sfm.common.registry.SFMBlockEntities; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraftforge.client.model.data.ModelData; +import net.minecraftforge.common.capabilities.Capability; +import net.minecraftforge.common.util.LazyOptional; +import org.jetbrains.annotations.Nullable; + +import static ca.teamdman.sfm.common.blockentity.FancyCableFacadeBlockEntity.FACADE_DIRECTION; + +public class TunnelledFancyCableFacadeBlockEntity extends CommonFacadeBlockEntity { + public TunnelledFancyCableFacadeBlockEntity( + BlockPos blockPos, + BlockState blockState + ) { + + super(SFMBlockEntities.TUNNELLED_FANCY_CABLE_FACADE_BLOCK_ENTITY.get(), blockPos, blockState); + } + + @Override + public LazyOptional getCapability( + Capability cap, + @Nullable Direction side + ) { + + if (!(this.level instanceof ServerLevel lvl)) { + return LazyOptional.empty(); + } + + if (side == null) { + return super.getCapability(cap, null); + } + + BlockEntity be = lvl.getBlockEntity(this.getBlockPos().offset(side.getOpposite().getNormal())); + if (be == null) { + return LazyOptional.empty(); + } + + return be.getCapability(cap, side); + } + + + @Override + public ModelData getModelData() { + + if (getFacadeData() != null) { + + return ModelData.builder() + .with(IFacadeBlockEntity.FACADE_BLOCK_STATE_MODEL_PROPERTY, getFacadeData().facadeBlockState()) + .with(FACADE_DIRECTION, getFacadeData().facadeDirection()) + .build(); + } + return ModelData.EMPTY; + } + +} diff --git a/src/main/java/ca/teamdman/sfm/common/containermenu/LibraryContainerMenu.java b/src/main/java/ca/teamdman/sfm/common/containermenu/LibraryContainerMenu.java new file mode 100644 index 000000000..437936e69 --- /dev/null +++ b/src/main/java/ca/teamdman/sfm/common/containermenu/LibraryContainerMenu.java @@ -0,0 +1,283 @@ +package ca.teamdman.sfm.common.containermenu; + +import ca.teamdman.sfm.common.blockentity.LibraryBlockEntity; +import ca.teamdman.sfm.common.item.DiskItem; +import ca.teamdman.sfm.common.registry.SFMMenus; +import net.minecraft.core.BlockPos; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.world.Container; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; + +import java.util.ArrayList; +import java.util.List; + +/** + * Container menu for the library block GUI. + * Provides access to disk slots for storing library disks. + */ +public class LibraryContainerMenu extends AbstractContainerMenu { + + // Disk slot layout constants + private static final int DISK_SLOT_START_X = 44; + private static final int DISK_SLOT_START_Y = 20; + private static final int SLOT_SPACING = 18; + private static final int DISK_SLOTS_PER_ROW = 5; + + // Player inventory layout constants + private static final int PLAYER_INV_START_X = 8; + private static final int PLAYER_INV_START_Y = 84; + private static final int PLAYER_HOTBAR_Y = 142; + + /** + * Record to track library name, slot index, and error/warning status. + */ + public record LibraryEntry(String name, int slotIndex, boolean hasErrors, boolean hasWarnings) {} + + /** + * Extracts library entries from a container's disk slots. + * Used by both server-side (LibraryBlockEntity) and client-side (refreshing from synced container). + * + * @param container The container to extract entries from + * @param slotCount The number of disk slots to check + * @return List of library entries with their names and slot indices + */ + public static List extractLibraryEntries(Container container, int slotCount) { + List entries = new ArrayList<>(); + for (int i = 0; i < slotCount; i++) { + ItemStack disk = container.getItem(i); + if (!DiskItem.isValidDisk(disk)) continue; + + String source = DiskItem.getProgramString(disk); + String name = DiskItem.extractName(source); + if (name == null || name.isEmpty()) { + name = "(unnamed)"; + } + boolean hasErrors = !DiskItem.getErrors(disk).isEmpty(); + boolean hasWarnings = !DiskItem.getWarnings(disk).isEmpty(); + entries.add(new LibraryEntry(name, i, hasErrors, hasWarnings)); + } + return entries; + } + + public final Inventory PLAYER_INVENTORY; + public final BlockPos LIBRARY_POSITION; + public final Container CONTAINER; + public List libraryEntries; + + public LibraryContainerMenu( + int windowId, + Inventory inv, + BlockPos blockEntityPos, + Container container, + List libraryEntries + ) { + super(SFMMenus.LIBRARY_MENU.get(), windowId); + this.PLAYER_INVENTORY = inv; + this.LIBRARY_POSITION = blockEntityPos; + this.CONTAINER = container; + this.libraryEntries = new ArrayList<>(libraryEntries); + + // Add disk slots (2 rows of 5) + for (int i = 0; i < LibraryBlockEntity.DISK_SLOT_COUNT; i++) { + int x = DISK_SLOT_START_X + (i % DISK_SLOTS_PER_ROW) * SLOT_SPACING; + int y = DISK_SLOT_START_Y + (i / DISK_SLOTS_PER_ROW) * SLOT_SPACING; + this.addSlot(new Slot(container, i, x, y) { + @Override + public boolean mayPlace(ItemStack stack) { + return stack.getItem() instanceof DiskItem; + } + }); + } + + // Add player inventory slots + for (int i = 0; i < 3; ++i) { + for (int j = 0; j < 9; ++j) { + this.addSlot(new Slot(inv, j + i * 9 + 9, + PLAYER_INV_START_X + j * SLOT_SPACING, + PLAYER_INV_START_Y + i * SLOT_SPACING)); + } + } + + // Add player hotbar slots + for (int k = 0; k < 9; ++k) { + this.addSlot(new Slot(inv, k, PLAYER_INV_START_X + k * SLOT_SPACING, PLAYER_HOTBAR_Y)); + } + } + + public LibraryContainerMenu( + int windowId, + Inventory inventory, + FriendlyByteBuf buf + ) { + this( + windowId, + inventory, + buf.readBlockPos(), + new SimpleClientContainer(LibraryBlockEntity.DISK_SLOT_COUNT), + readLibraryEntries(buf) + ); + } + + public LibraryContainerMenu( + int windowId, + Inventory inventory, + LibraryBlockEntity library + ) { + this( + windowId, + inventory, + library.getBlockPos(), + library, + library.getLibraryEntries() + ); + } + + public static void encode( + LibraryBlockEntity library, + FriendlyByteBuf buf + ) { + buf.writeBlockPos(library.getBlockPos()); + writeLibraryEntries(library.getLibraryEntries(), buf); + } + + private static List readLibraryEntries(FriendlyByteBuf buf) { + int count = buf.readVarInt(); + List entries = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + String name = buf.readUtf(256); + int slotIndex = buf.readVarInt(); + boolean hasErrors = buf.readBoolean(); + boolean hasWarnings = buf.readBoolean(); + entries.add(new LibraryEntry(name, slotIndex, hasErrors, hasWarnings)); + } + return entries; + } + + private static void writeLibraryEntries(List entries, FriendlyByteBuf buf) { + buf.writeVarInt(entries.size()); + for (LibraryEntry entry : entries) { + buf.writeUtf(entry.name(), 256); + buf.writeVarInt(entry.slotIndex()); + buf.writeBoolean(entry.hasErrors()); + buf.writeBoolean(entry.hasWarnings()); + } + } + + @Override + public boolean stillValid(Player player) { + return player.level.getBlockEntity(LIBRARY_POSITION) instanceof LibraryBlockEntity; + } + + @Override + public ItemStack quickMoveStack(Player player, int slotIndex) { + ItemStack result = ItemStack.EMPTY; + Slot slot = this.slots.get(slotIndex); + + if (slot != null && slot.hasItem()) { + ItemStack slotStack = slot.getItem(); + result = slotStack.copy(); + + // Disk slots are 0-9, player inventory is 10-45 + if (slotIndex < LibraryBlockEntity.DISK_SLOT_COUNT) { + // Moving from disk slot to player inventory + if (!this.moveItemStackTo(slotStack, LibraryBlockEntity.DISK_SLOT_COUNT, this.slots.size(), true)) { + return ItemStack.EMPTY; + } + } else { + // Moving from player inventory to disk slots (only if it's a disk) + if (slotStack.getItem() instanceof DiskItem) { + if (!this.moveItemStackTo(slotStack, 0, LibraryBlockEntity.DISK_SLOT_COUNT, false)) { + return ItemStack.EMPTY; + } + } else { + return ItemStack.EMPTY; + } + } + + if (slotStack.isEmpty()) { + slot.set(ItemStack.EMPTY); + } else { + slot.setChanged(); + } + } + + return result; + } + + /** + * Simple container for client-side use when the actual block entity isn't available. + */ + private static class SimpleClientContainer implements Container { + private final ItemStack[] items; + + public SimpleClientContainer(int size) { + this.items = new ItemStack[size]; + for (int i = 0; i < size; i++) { + items[i] = ItemStack.EMPTY; + } + } + + @Override + public int getContainerSize() { + return items.length; + } + + @Override + public boolean isEmpty() { + for (ItemStack item : items) { + if (!item.isEmpty()) return false; + } + return true; + } + + @Override + public ItemStack getItem(int slot) { + return slot >= 0 && slot < items.length ? items[slot] : ItemStack.EMPTY; + } + + @Override + public ItemStack removeItem(int slot, int amount) { + if (slot >= 0 && slot < items.length && !items[slot].isEmpty() && amount > 0) { + return items[slot].split(amount); + } + return ItemStack.EMPTY; + } + + @Override + public ItemStack removeItemNoUpdate(int slot) { + if (slot >= 0 && slot < items.length) { + ItemStack result = items[slot]; + items[slot] = ItemStack.EMPTY; + return result; + } + return ItemStack.EMPTY; + } + + @Override + public void setItem(int slot, ItemStack stack) { + if (slot >= 0 && slot < items.length) { + items[slot] = stack; + } + } + + @Override + public void setChanged() { + } + + @Override + public boolean stillValid(Player player) { + return true; + } + + @Override + public void clearContent() { + for (int i = 0; i < items.length; i++) { + items[i] = ItemStack.EMPTY; + } + } + } +} diff --git a/src/main/java/ca/teamdman/sfm/common/item/DiskItem.java b/src/main/java/ca/teamdman/sfm/common/item/DiskItem.java index e7f1eb814..6656824a8 100644 --- a/src/main/java/ca/teamdman/sfm/common/item/DiskItem.java +++ b/src/main/java/ca/teamdman/sfm/common/item/DiskItem.java @@ -15,6 +15,7 @@ import ca.teamdman.sfm.common.util.SFMItemUtils; import ca.teamdman.sfm.common.util.SFMTranslationUtils; import ca.teamdman.sfml.ast.Program; +import ca.teamdman.sfml.program_builder.LibraryResolver; import ca.teamdman.sfml.program_builder.ProgramBuilder; import net.minecraft.ChatFormatting; import net.minecraft.nbt.CompoundTag; @@ -37,13 +38,48 @@ import java.util.Collections; import java.util.List; import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; public class DiskItem extends Item { + /** + * Pattern to extract NAME from source code. + * Matches: NAME "some_name" (case-insensitive) + * Supports escaped quotes in the name. + */ + private static final Pattern NAME_PATTERN = Pattern.compile( + "(?i)\\bNAME\\s+\"([^\"\\\\]*(\\\\.[^\"\\\\]*)*)\""); + public DiskItem() { super(new Item.Properties().tab(SFMCreativeTabs.TAB)); } + /** + * Checks if an ItemStack is a valid disk item. + * + * @param stack The ItemStack to check + * @return true if the stack is non-empty and contains a DiskItem + */ + public static boolean isValidDisk(ItemStack stack) { + return !stack.isEmpty() && stack.getItem() instanceof DiskItem; + } + + /** + * Extracts the NAME from SFML source code using regex (fast, avoids full ANTLR parsing). + * + * @param source The SFML source code + * @return The extracted name, or null if not found + */ + public static @Nullable String extractName(String source) { + if (source == null || source.isEmpty()) return null; + Matcher matcher = NAME_PATTERN.matcher(source); + if (matcher.find()) { + return matcher.group(1).replace("\\\"", "\""); + } + return null; + } + public static String getProgramString(ItemStack stack) { return stack .getOrCreateTag() @@ -72,6 +108,15 @@ public static void pruneIfDefault(ItemStack stack) { ItemStack stack, @Nullable ManagerBlockEntity manager, boolean updateWarnings + ) { + return compileAndUpdateErrorsAndWarnings(stack, manager, updateWarnings, LibraryResolver.NONE); + } + + public static @Nullable Program compileAndUpdateErrorsAndWarnings( + ItemStack stack, + @Nullable ManagerBlockEntity manager, + boolean updateWarnings, + LibraryResolver libraryResolver ) { if (manager != null) { manager.logger.info(x -> x.accept(LocalizationKeys.PROGRAM_COMPILE_FROM_DISK_BEGIN.get())); @@ -79,7 +124,9 @@ public static void pruneIfDefault(ItemStack stack) { AtomicReference rtn = new AtomicReference<>(null); String programString = getProgramString(stack); - new ProgramBuilder(programString).build() + new ProgramBuilder(programString) + .withLibraryResolver(libraryResolver) + .build() .caseSuccess((successProgram, metadata) -> { if (updateWarnings) { Collection warnings = ProgramLinter.gatherWarnings( diff --git a/src/main/java/ca/teamdman/sfm/common/localization/LocalizationKeys.java b/src/main/java/ca/teamdman/sfm/common/localization/LocalizationKeys.java index 1f37d1f37..1fa8b3105 100644 --- a/src/main/java/ca/teamdman/sfm/common/localization/LocalizationKeys.java +++ b/src/main/java/ca/teamdman/sfm/common/localization/LocalizationKeys.java @@ -484,6 +484,43 @@ public final class LocalizationKeys { "program.sfm.warnings.round_robin_smelly_count", "Round robin by label should be used with more than one label, statement %s" ); + public static final LocalizationEntry PROGRAM_WARNING_UNUSED_STRUCT = new LocalizationEntry( + "program.sfm.warnings.unused_struct", + "Struct \"%s\" is defined but never instantiated." + ); + public static final LocalizationEntry PROGRAM_WARNING_UNUSED_STRUCT_INSTANCE = new LocalizationEntry( + "program.sfm.warnings.unused_struct_instance", + "Struct instance \"%s\" is defined but never used in a USING clause." + ); + public static final LocalizationEntry PROGRAM_WARNING_UNUSED_PROTOCOL = new LocalizationEntry( + "program.sfm.warnings.unused_protocol", + "Protocol \"%s\" is defined but never used." + ); + public static final LocalizationEntry PROGRAM_WARNING_UNUSED_MACRO = new LocalizationEntry( + "program.sfm.warnings.unused_macro", + "Macro \"%s\" is defined but never expanded." + ); + public static final LocalizationEntry PROGRAM_WARNING_UNUSED_LIBRARY = new LocalizationEntry( + "program.sfm.warnings.unused_library", + "Library \"%s\" is imported but none of its definitions are used." + ); + public static final LocalizationEntry PROGRAM_ERROR_LIBRARY_NOT_FOUND = new LocalizationEntry( + "program.sfm.error.library_not_found", + "Library \"%s\" not found in cable network." + ); + public static final LocalizationEntry PROGRAM_ERROR_LIBRARY_HAS_ERRORS = new LocalizationEntry( + "program.sfm.error.library_has_errors", + "Library \"%s\" has compilation errors." + ); + public static final LocalizationEntry LIBRARY_CONTAINER = new LocalizationEntry( + "container.sfm.library", + "SFML Library" + ); + @SuppressWarnings("unused") // used by minecraft without us having to directly reference + public static final LocalizationEntry LIBRARY_BLOCK = new LocalizationEntry( + "block.sfm.library", + "SFML Library Block" + ); public static final LocalizationEntry PROGRAM_ERROR_COMPILE_FAILED = new LocalizationEntry( "program.sfm.error.compile_failed", "Failed to compile." diff --git a/src/main/java/ca/teamdman/sfm/common/net/ServerboundLibraryDiskSetProgramPacket.java b/src/main/java/ca/teamdman/sfm/common/net/ServerboundLibraryDiskSetProgramPacket.java new file mode 100644 index 000000000..40256c06b --- /dev/null +++ b/src/main/java/ca/teamdman/sfm/common/net/ServerboundLibraryDiskSetProgramPacket.java @@ -0,0 +1,101 @@ +package ca.teamdman.sfm.common.net; + +import ca.teamdman.sfm.common.blockentity.LibraryBlockEntity; +import ca.teamdman.sfm.common.containermenu.LibraryContainerMenu; +import ca.teamdman.sfm.common.item.DiskItem; +import ca.teamdman.sfml.ast.Program; +import net.minecraft.core.BlockPos; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.world.item.ItemStack; + +/** + * Packet sent from client to server to update a disk's program in a library block slot. + */ +public record ServerboundLibraryDiskSetProgramPacket( + int containerId, + BlockPos libraryPos, + int slotIndex, + String programString +) implements SFMPacket { + + public static class Daddy implements SFMPacketDaddy { + @Override + public PacketDirection getPacketDirection() { + return PacketDirection.SERVERBOUND; + } + + @Override + public void encode( + ServerboundLibraryDiskSetProgramPacket msg, + FriendlyByteBuf buf + ) { + buf.writeVarInt(msg.containerId); + buf.writeBlockPos(msg.libraryPos); + buf.writeVarInt(msg.slotIndex); + buf.writeUtf(msg.programString, Program.MAX_PROGRAM_LENGTH); + } + + @Override + public ServerboundLibraryDiskSetProgramPacket decode(FriendlyByteBuf buf) { + return new ServerboundLibraryDiskSetProgramPacket( + buf.readVarInt(), + buf.readBlockPos(), + buf.readVarInt(), + buf.readUtf(Program.MAX_PROGRAM_LENGTH) + ); + } + + @Override + public void handle( + ServerboundLibraryDiskSetProgramPacket msg, + SFMPacketHandlingContext context + ) { + var sender = context.sender(); + if (sender == null) { + return; + } + + // Verify the player has the menu open + if (!(sender.containerMenu instanceof LibraryContainerMenu menu)) { + return; + } + if (menu.containerId != msg.containerId) { + return; + } + + // Verify the library block exists at the position + if (!(sender.level.getBlockEntity(msg.libraryPos) instanceof LibraryBlockEntity library)) { + return; + } + + // Verify slot index is valid + if (msg.slotIndex < 0 || msg.slotIndex >= LibraryBlockEntity.DISK_SLOT_COUNT) { + return; + } + + // Get the disk in the slot + ItemStack disk = library.getItem(msg.slotIndex); + if (!DiskItem.isValidDisk(disk)) { + return; + } + + // Update the disk's program + DiskItem.setProgram(disk, msg.programString); + + // Create a library resolver so USE statements can resolve other libraries on the network + var libraryResolver = library.createLibraryResolver(); + DiskItem.compileAndUpdateErrorsAndWarnings(disk, null, true, libraryResolver); + DiskItem.pruneIfDefault(disk); + library.setChanged(); + + // Update the menu's library entries + menu.libraryEntries.clear(); + menu.libraryEntries.addAll(library.getLibraryEntries()); + } + + @Override + public Class getPacketClass() { + return ServerboundLibraryDiskSetProgramPacket.class; + } + } +} diff --git a/src/main/java/ca/teamdman/sfm/common/program/linting/StructDefinitionLinter.java b/src/main/java/ca/teamdman/sfm/common/program/linting/StructDefinitionLinter.java new file mode 100644 index 000000000..a36a26060 --- /dev/null +++ b/src/main/java/ca/teamdman/sfm/common/program/linting/StructDefinitionLinter.java @@ -0,0 +1,60 @@ +package ca.teamdman.sfm.common.program.linting; + +import ca.teamdman.sfm.common.blockentity.ManagerBlockEntity; +import ca.teamdman.sfm.common.label.LabelPositionHolder; +import ca.teamdman.sfml.ast.LetStatement; +import ca.teamdman.sfml.ast.Program; +import ca.teamdman.sfml.ast.StructDefinition; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import org.jetbrains.annotations.Nullable; + +import java.util.HashSet; +import java.util.Set; + +import static ca.teamdman.sfm.common.localization.LocalizationKeys.PROGRAM_WARNING_UNUSED_STRUCT; + +/** + * Linter that validates struct definitions are actually used. + */ +public class StructDefinitionLinter implements IProgramLinter { + @Override + public void gatherWarnings( + Program program, + LabelPositionHolder labelPositionHolder, + @Nullable ManagerBlockEntity managerBlockEntity, + ProblemTracker tracker + ) { + // Skip for library disks (no triggers = definitions are meant to be exported) + if (program.triggers().isEmpty()) { + return; + } + + // Collect all struct names that are instantiated + Set usedStructs = new HashSet<>(); + for (LetStatement letStatement : program.letStatements()) { + usedStructs.add(letStatement.instance().definition().name()); + } + + // Check for unused struct definitions + for (StructDefinition structDef : program.structDefinitions()) { + if (!usedStructs.contains(structDef.name())) { + if (tracker.add(PROGRAM_WARNING_UNUSED_STRUCT.get(structDef.name())).isSaturated()) { + return; + } + } + } + } + + @Override + public void fixWarnings( + Program program, + LabelPositionHolder labels, + ManagerBlockEntity manager, + Level level, + ItemStack disk + ) { + // We can't auto-fix this - removing unused struct definitions would require + // modifying the program source code + } +} diff --git a/src/main/java/ca/teamdman/sfm/common/program/linting/UnusedDefinitionLinter.java b/src/main/java/ca/teamdman/sfm/common/program/linting/UnusedDefinitionLinter.java new file mode 100644 index 000000000..f3a1b33ae --- /dev/null +++ b/src/main/java/ca/teamdman/sfm/common/program/linting/UnusedDefinitionLinter.java @@ -0,0 +1,168 @@ +package ca.teamdman.sfm.common.program.linting; + +import ca.teamdman.sfm.common.blockentity.ManagerBlockEntity; +import ca.teamdman.sfm.common.label.LabelPositionHolder; +import ca.teamdman.sfml.ast.*; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.HashSet; +import java.util.Set; + +import static ca.teamdman.sfm.common.localization.LocalizationKeys.*; + +/** + * Linter that validates definitions (protocols, macros, struct instances) are actually used. + * Consolidates checks for: + * - Unused protocol definitions + * - Unused macro definitions + * - Unused struct instances (let statements) + */ +public class UnusedDefinitionLinter implements IProgramLinter { + @Override + public void gatherWarnings( + Program program, + LabelPositionHolder labelPositionHolder, + @Nullable ManagerBlockEntity managerBlockEntity, + ProblemTracker tracker + ) { + // Skip for library disks (no triggers = definitions are meant to be exported) + if (program.triggers().isEmpty()) { + return; + } + + checkUnusedProtocols(program, tracker); + if (tracker.isSaturated()) return; + + checkUnusedMacros(program, tracker); + if (tracker.isSaturated()) return; + + checkUnusedStructInstances(program, tracker); + } + + private void checkUnusedProtocols(Program program, ProblemTracker tracker) { + // Collect all protocols that are referenced + Set usedProtocols = new HashSet<>(); + + // Protocols implemented by structs + for (StructDefinition structDef : program.structDefinitions()) { + usedProtocols.addAll(structDef.implementedProtocols()); + } + + // Protocols used as macro parameter constraints + for (MacroDefinition macroDef : program.macroDefinitions()) { + for (MacroParameter param : macroDef.parameters()) { + if (param.protocolConstraint() != null) { + usedProtocols.add(param.protocolConstraint()); + } + } + } + + // Check for unused protocol definitions + for (ProtocolDefinition protocolDef : program.protocolDefinitions()) { + if (!usedProtocols.contains(protocolDef.name())) { + if (tracker.add(PROGRAM_WARNING_UNUSED_PROTOCOL.get(protocolDef.name())).isSaturated()) { + return; + } + } + } + } + + private void checkUnusedMacros(Program program, ProblemTracker tracker) { + // Collect all macro names that are used in expand statements + Set usedMacros = new HashSet<>(); + + Deque toVisit = new ArrayDeque<>(); + for (Trigger trigger : program.triggers()) { + toVisit.addAll(trigger.getStatements()); + } + + while (!toVisit.isEmpty()) { + Statement stmt = toVisit.poll(); + + if (stmt instanceof ExpandStatement expand) { + usedMacros.add(expand.macroName()); + } + + toVisit.addAll(stmt.getStatements()); + } + + // Check for unused macro definitions + for (MacroDefinition macroDef : program.macroDefinitions()) { + if (!usedMacros.contains(macroDef.name())) { + if (tracker.add(PROGRAM_WARNING_UNUSED_MACRO.get(macroDef.name())).isSaturated()) { + return; + } + } + } + } + + private void checkUnusedStructInstances(Program program, ProblemTracker tracker) { + // Collect all struct instance variable names that are used + Set usedInstances = new HashSet<>(); + collectUsedInstances(program, usedInstances); + + // Check for unused struct instances + for (LetStatement letStatement : program.letStatements()) { + String variableName = letStatement.variableName(); + if (!usedInstances.contains(variableName)) { + if (tracker.add(PROGRAM_WARNING_UNUSED_STRUCT_INSTANCE.get(variableName)).isSaturated()) { + return; + } + } + } + } + + private void collectUsedInstances(ASTNode node, Set usedInstances) { + if (node instanceof LabelAccess labelAccess) { + StructAccess structAccess = labelAccess.structAccess(); + if (structAccess != null) { + usedInstances.add(structAccess.variableName()); + } + } + + // Recursively check child statements + for (Statement child : node.getStatements()) { + collectUsedInstances(child, usedInstances); + + // Check label access in IO statements + if (child instanceof IOStatement ioStatement) { + LabelAccess labelAccess = ioStatement.labelAccess(); + if (labelAccess.structAccess() != null) { + usedInstances.add(labelAccess.structAccess().variableName()); + } + } + + // Check label access in BoolHas expressions + if (child instanceof IfStatement ifStatement) { + collectUsedInstancesFromBoolExpr(ifStatement, usedInstances); + } + } + } + + private void collectUsedInstancesFromBoolExpr(IfStatement ifStatement, Set usedInstances) { + // Walk the entire AST tree including all BoolExpr nodes + ifStatement.getDescendantStatements().forEach(stmt -> { + if (stmt instanceof IOStatement ioStatement) { + LabelAccess labelAccess = ioStatement.labelAccess(); + if (labelAccess.structAccess() != null) { + usedInstances.add(labelAccess.structAccess().variableName()); + } + } + }); + } + + @Override + public void fixWarnings( + Program program, + LabelPositionHolder labels, + ManagerBlockEntity manager, + Level level, + ItemStack disk + ) { + // Cannot auto-fix - removing unused definitions would require modifying source code + } +} diff --git a/src/main/java/ca/teamdman/sfm/common/registry/SFMBlockEntities.java b/src/main/java/ca/teamdman/sfm/common/registry/SFMBlockEntities.java index 4587fa981..802a60864 100644 --- a/src/main/java/ca/teamdman/sfm/common/registry/SFMBlockEntities.java +++ b/src/main/java/ca/teamdman/sfm/common/registry/SFMBlockEntities.java @@ -3,6 +3,7 @@ import ca.teamdman.sfm.SFM; import ca.teamdman.sfm.common.blockentity.*; +import ca.teamdman.sfm.common.util.SFMEnvironmentUtils; import net.minecraft.world.level.block.entity.BlockEntityType; import net.minecraftforge.eventbus.api.IEventBus; @@ -92,6 +93,20 @@ public static void register(IEventBus bus) { .build(null) ); + public static SFMRegistryObject, BlockEntityType> + LIBRARY_BLOCK_ENTITY = null; + + static { + if (SFMEnvironmentUtils.isInIDE() && SFMBlocks.LIBRARY_BLOCK != null) { + LIBRARY_BLOCK_ENTITY = REGISTERER.register( + "library", + () -> BlockEntityType.Builder + .of(LibraryBlockEntity::new, SFMBlocks.LIBRARY_BLOCK.get()) + .build(null) + ); + } + } + public static final SFMRegistryObject, BlockEntityType> TUNNELLED_CABLE_BLOCK_ENTITY = REGISTERER.register( "tunnelled_cable", diff --git a/src/main/java/ca/teamdman/sfm/common/registry/SFMBlocks.java b/src/main/java/ca/teamdman/sfm/common/registry/SFMBlocks.java index b21946133..a0054e092 100644 --- a/src/main/java/ca/teamdman/sfm/common/registry/SFMBlocks.java +++ b/src/main/java/ca/teamdman/sfm/common/registry/SFMBlocks.java @@ -2,6 +2,7 @@ import ca.teamdman.sfm.SFM; import ca.teamdman.sfm.common.block.*; +import ca.teamdman.sfm.common.util.SFMEnvironmentUtils; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.SoundType; import net.minecraft.world.level.block.state.BlockBehaviour; @@ -97,6 +98,14 @@ public class SFMBlocks { ) ); + public static SFMRegistryObject LIBRARY_BLOCK = null; + + static { + if (SFMEnvironmentUtils.isInIDE()) { + LIBRARY_BLOCK = REGISTERER.register("library", LibraryBlock::new); + } + } + // Tough variants public static final SFMRegistryObject TOUGH_CABLE_BLOCK = REGISTERER.register( diff --git a/src/main/java/ca/teamdman/sfm/common/registry/SFMItems.java b/src/main/java/ca/teamdman/sfm/common/registry/SFMItems.java index f6eaf70e4..4b9d3926a 100644 --- a/src/main/java/ca/teamdman/sfm/common/registry/SFMItems.java +++ b/src/main/java/ca/teamdman/sfm/common/registry/SFMItems.java @@ -99,10 +99,14 @@ public class SFMItems { ); public static SFMRegistryObject BUFFER_ITEM = null; + public static SFMRegistryObject LIBRARY_ITEM = null; static { if (SFMEnvironmentUtils.isInIDE()) { BUFFER_ITEM = register("buffer", SFMBlocks.BUFFER_BLOCK); + if (SFMBlocks.LIBRARY_BLOCK != null) { + LIBRARY_ITEM = register("library", SFMBlocks.LIBRARY_BLOCK); + } } } diff --git a/src/main/java/ca/teamdman/sfm/common/registry/SFMMenus.java b/src/main/java/ca/teamdman/sfm/common/registry/SFMMenus.java index 0fb6d050f..1062420af 100644 --- a/src/main/java/ca/teamdman/sfm/common/registry/SFMMenus.java +++ b/src/main/java/ca/teamdman/sfm/common/registry/SFMMenus.java @@ -3,8 +3,10 @@ import ca.teamdman.sfm.SFM; import ca.teamdman.sfm.client.ClientRayCastHelpers; +import ca.teamdman.sfm.common.blockentity.LibraryBlockEntity; import ca.teamdman.sfm.common.blockentity.ManagerBlockEntity; import ca.teamdman.sfm.common.blockentity.TestBarrelTankBlockEntity; +import ca.teamdman.sfm.common.containermenu.LibraryContainerMenu; import ca.teamdman.sfm.common.containermenu.ManagerContainerMenu; import ca.teamdman.sfm.common.containermenu.TestBarrelTankContainerMenu; import ca.teamdman.sfm.common.util.SFMEnvironmentUtils; @@ -103,6 +105,44 @@ public TestBarrelTankContainerMenu create( }) ); + public static final SFMRegistryObject, MenuType> LIBRARY_MENU = MENU_TYPES.register( + "library", + () -> IForgeMenuType.create( + new IContainerFactory<>() { + @Override + public LibraryContainerMenu create( + int windowId, + Inventory inv, + FriendlyByteBuf data + ) { + return new LibraryContainerMenu( + windowId, + inv, + data + ); + } + + @Override + public LibraryContainerMenu create( + int windowId, + Inventory inv + ) { + if (SFMEnvironmentUtils.isClient()) { + BlockEntity be = ClientRayCastHelpers.getLookBlockEntity(); + if (!(be instanceof LibraryBlockEntity blockEntity)) { + return IContainerFactory.super.create(windowId, inv); + } + return new LibraryContainerMenu(windowId, inv, blockEntity); + } else { + return IContainerFactory.super.create( + windowId, + inv + ); + } + } + }) + ); + public static void register(IEventBus bus) { MENU_TYPES.register(bus); diff --git a/src/main/java/ca/teamdman/sfm/common/registry/SFMPackets.java b/src/main/java/ca/teamdman/sfm/common/registry/SFMPackets.java index 1f9d08cb6..4eb3621d2 100644 --- a/src/main/java/ca/teamdman/sfm/common/registry/SFMPackets.java +++ b/src/main/java/ca/teamdman/sfm/common/registry/SFMPackets.java @@ -60,6 +60,7 @@ public static void register() { registerPacket(new ServerboundLabelGunSetActiveLabelPacket.Daddy()); registerPacket(new ServerboundLabelGunUsePacket.Daddy()); registerPacket(new ServerboundLabelInspectionRequestPacket.Daddy()); + registerPacket(new ServerboundLibraryDiskSetProgramPacket.Daddy()); registerPacket(new ServerboundManagerClearLogsPacket.Daddy()); registerPacket(new ServerboundManagerFixPacket.Daddy()); registerPacket(new ServerboundManagerLogDesireUpdatePacket.Daddy()); diff --git a/src/main/java/ca/teamdman/sfm/common/registry/SFMProgramLinters.java b/src/main/java/ca/teamdman/sfm/common/registry/SFMProgramLinters.java index 584463b5a..a32436e6f 100644 --- a/src/main/java/ca/teamdman/sfm/common/registry/SFMProgramLinters.java +++ b/src/main/java/ca/teamdman/sfm/common/registry/SFMProgramLinters.java @@ -69,6 +69,18 @@ public class SFMProgramLinters { NoSlotStatementProgramLinter::new ); + public static final SFMRegistryObject + STRUCT_DEFINITION_LINTER = REGISTERER.register( + "struct_definition", + StructDefinitionLinter::new + ); + + public static final SFMRegistryObject + UNUSED_DEFINITION_LINTER = REGISTERER.register( + "unused_definition", + UnusedDefinitionLinter::new + ); + static { if (SFMModCompat.isMekanismLoaded()) { SFMMekanismCompat.registerProgramLinters(REGISTERER); diff --git a/src/main/java/ca/teamdman/sfm/common/util/BlockPosMap.java b/src/main/java/ca/teamdman/sfm/common/util/BlockPosMap.java index 67540c9ba..c45271e2d 100644 --- a/src/main/java/ca/teamdman/sfm/common/util/BlockPosMap.java +++ b/src/main/java/ca/teamdman/sfm/common/util/BlockPosMap.java @@ -1,128 +1,128 @@ -package ca.teamdman.sfm.common.util; - -import it.unimi.dsi.fastutil.longs.Long2ObjectFunction; -import it.unimi.dsi.fastutil.longs.Long2ObjectMap; -import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; -import it.unimi.dsi.fastutil.longs.LongSet; -import it.unimi.dsi.fastutil.objects.ObjectCollection; -import net.minecraft.core.BlockPos; -import org.jetbrains.annotations.Nullable; - -@SuppressWarnings("UnusedReturnValue") -public record BlockPosMap( - Long2ObjectOpenHashMap inner -) { - public BlockPosMap() { - - this(new Long2ObjectOpenHashMap<>()); - } - - public boolean isEmpty() { - - return inner.isEmpty(); - } - - public @Nullable T put( - long key, - T value - ) { - - return inner.put(key, value); - } - - public Long2ObjectMap.FastEntrySet long2ObjectEntrySet() { - - return inner.long2ObjectEntrySet(); - } - - public T computeIfAbsent( - long key, - Long2ObjectFunction mappingFunction - ) { - - return inner.computeIfAbsent(key, mappingFunction); - } - - /// Correctness: make sure this is not a {@link net.minecraft.world.level.ChunkPos#asLong} - public @Nullable T get(long blockPosLong) { - - return inner.get(blockPosLong); - } - - public @Nullable T get(BlockPos blockPos) { - - return inner.get(blockPos.asLong()); - } - - /// Correctness: make sure this is not a {@link net.minecraft.world.level.ChunkPos#asLong} - public @Nullable T remove(long blockPosLong) { - - return inner.remove(blockPosLong); - } - - public @Nullable T remove(BlockPos blockPos) { - - return inner.remove(blockPos.asLong()); - } - - public LongSet keysAsLongSet() { - - return inner.keySet(); - } - - public BlockPosSet keysAsBlockPosSet() { - - return new BlockPosSet(inner.keySet()); - } - - public int size() { - - return inner.size(); - } - - public boolean containsKey(long key) { - - return inner.containsKey(key); - } - - public void clear() { - - inner.clear(); - } - - public ObjectCollection values() { - - return inner.values(); - } - - public void putAll(BlockPosMap pos2TankMap) { - - inner.putAll(pos2TankMap.inner); - - } - - public boolean removeBlockPositions(BlockPosSet blockPosSet) { - - return removeBlockPositions(blockPosSet.inner()); - } - - public @Nullable T put( - BlockPos blockPos, - T member - ) { - - return inner.put(blockPos.asLong(), member); - } - - public boolean containsKey(BlockPos blockPos) { - - return inner.containsKey(blockPos.asLong()); - } - - public boolean removeBlockPositions(LongSet blockPosLongSet) { - - return inner.keySet().removeAll(blockPosLongSet); - - } - -} +package ca.teamdman.sfm.common.util; + +import it.unimi.dsi.fastutil.longs.Long2ObjectFunction; +import it.unimi.dsi.fastutil.longs.Long2ObjectMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.longs.LongSet; +import it.unimi.dsi.fastutil.objects.ObjectCollection; +import net.minecraft.core.BlockPos; +import org.jetbrains.annotations.Nullable; + +@SuppressWarnings("UnusedReturnValue") +public record BlockPosMap( + Long2ObjectOpenHashMap inner +) { + public BlockPosMap() { + + this(new Long2ObjectOpenHashMap<>()); + } + + public boolean isEmpty() { + + return inner.isEmpty(); + } + + public @Nullable T put( + long key, + T value + ) { + + return inner.put(key, value); + } + + public Long2ObjectMap.FastEntrySet long2ObjectEntrySet() { + + return inner.long2ObjectEntrySet(); + } + + public T computeIfAbsent( + long key, + Long2ObjectFunction mappingFunction + ) { + + return inner.computeIfAbsent(key, mappingFunction); + } + + /// Correctness: make sure this is not a {@link net.minecraft.world.level.ChunkPos#asLong} + public @Nullable T get(long blockPosLong) { + + return inner.get(blockPosLong); + } + + public @Nullable T get(BlockPos blockPos) { + + return inner.get(blockPos.asLong()); + } + + /// Correctness: make sure this is not a {@link net.minecraft.world.level.ChunkPos#asLong} + public @Nullable T remove(long blockPosLong) { + + return inner.remove(blockPosLong); + } + + public @Nullable T remove(BlockPos blockPos) { + + return inner.remove(blockPos.asLong()); + } + + public LongSet keysAsLongSet() { + + return inner.keySet(); + } + + public BlockPosSet keysAsBlockPosSet() { + + return new BlockPosSet(inner.keySet()); + } + + public int size() { + + return inner.size(); + } + + public boolean containsKey(long key) { + + return inner.containsKey(key); + } + + public void clear() { + + inner.clear(); + } + + public ObjectCollection values() { + + return inner.values(); + } + + public void putAll(BlockPosMap pos2TankMap) { + + inner.putAll(pos2TankMap.inner); + + } + + public boolean removeBlockPositions(BlockPosSet blockPosSet) { + + return removeBlockPositions(blockPosSet.inner()); + } + + public @Nullable T put( + BlockPos blockPos, + T member + ) { + + return inner.put(blockPos.asLong(), member); + } + + public boolean containsKey(BlockPos blockPos) { + + return inner.containsKey(blockPos.asLong()); + } + + public boolean removeBlockPositions(LongSet blockPosLongSet) { + + return inner.keySet().removeAll(blockPosLongSet); + + } + +} diff --git a/src/main/java/ca/teamdman/sfm/common/util/BlockPosSet.java b/src/main/java/ca/teamdman/sfm/common/util/BlockPosSet.java index 99f8cfd91..0eb2a215f 100644 --- a/src/main/java/ca/teamdman/sfm/common/util/BlockPosSet.java +++ b/src/main/java/ca/teamdman/sfm/common/util/BlockPosSet.java @@ -1,117 +1,117 @@ -package ca.teamdman.sfm.common.util; - -import it.unimi.dsi.fastutil.longs.LongArraySet; -import it.unimi.dsi.fastutil.longs.LongIterator; -import it.unimi.dsi.fastutil.longs.LongSet; -import net.minecraft.core.BlockPos; - -import java.util.Iterator; - -public record BlockPosSet(LongSet inner) implements Iterable { - public BlockPosSet() { - - this(new LongArraySet()); - } - - /// @return {@code false} if was already an element of the set, {@code true} otherwise - public boolean add(BlockPos pos) { - - return inner.add(pos.asLong()); - - } - - /// @return {@code true} if the collection was modified - public boolean addAll(BlockPosSet otherChunkPositions) { - - return inner.addAll(otherChunkPositions.inner); - - } - - public LongIterator longIterator() { - - return inner.longIterator(); - } - - public boolean remove(long blockPos) { - - return inner.remove(blockPos); - } - - public boolean isEmpty() { - - return inner.isEmpty(); - } - - public int size() { - - return inner.size(); - } - - public void clear() { - - inner.clear(); - } - - public boolean remove(BlockPos blockPos) { - - return inner.remove(blockPos.asLong()); - - } - - public boolean contains(BlockPos pos) { - - return inner.contains(pos.asLong()); - } - - @Override - public boolean equals(Object o) { - - if (!(o instanceof BlockPosSet that)) return false; - - return inner.equals(that.inner); - } - - @Override - public int hashCode() { - - return inner.hashCode(); - } - - public boolean add(long blockPosLong) { - - return inner.add(blockPosLong); - } - - @Override - public Iterator iterator() { - - return new Iterator() { - final LongIterator inner = BlockPosSet.this.longIterator(); - - @Override - public boolean hasNext() { - - return inner.hasNext(); - } - - @Override - public BlockPos next() { - - return BlockPos.of(inner.nextLong()); - } - - @Override - public void remove() { - - inner.remove(); - } - - @SuppressWarnings("unused") - public int skip(int n) { - - return inner.skip(n); - } - }; - } - -} +package ca.teamdman.sfm.common.util; + +import it.unimi.dsi.fastutil.longs.LongArraySet; +import it.unimi.dsi.fastutil.longs.LongIterator; +import it.unimi.dsi.fastutil.longs.LongSet; +import net.minecraft.core.BlockPos; + +import java.util.Iterator; + +public record BlockPosSet(LongSet inner) implements Iterable { + public BlockPosSet() { + + this(new LongArraySet()); + } + + /// @return {@code false} if was already an element of the set, {@code true} otherwise + public boolean add(BlockPos pos) { + + return inner.add(pos.asLong()); + + } + + /// @return {@code true} if the collection was modified + public boolean addAll(BlockPosSet otherChunkPositions) { + + return inner.addAll(otherChunkPositions.inner); + + } + + public LongIterator longIterator() { + + return inner.longIterator(); + } + + public boolean remove(long blockPos) { + + return inner.remove(blockPos); + } + + public boolean isEmpty() { + + return inner.isEmpty(); + } + + public int size() { + + return inner.size(); + } + + public void clear() { + + inner.clear(); + } + + public boolean remove(BlockPos blockPos) { + + return inner.remove(blockPos.asLong()); + + } + + public boolean contains(BlockPos pos) { + + return inner.contains(pos.asLong()); + } + + @Override + public boolean equals(Object o) { + + if (!(o instanceof BlockPosSet that)) return false; + + return inner.equals(that.inner); + } + + @Override + public int hashCode() { + + return inner.hashCode(); + } + + public boolean add(long blockPosLong) { + + return inner.add(blockPosLong); + } + + @Override + public Iterator iterator() { + + return new Iterator() { + final LongIterator inner = BlockPosSet.this.longIterator(); + + @Override + public boolean hasNext() { + + return inner.hasNext(); + } + + @Override + public BlockPos next() { + + return BlockPos.of(inner.nextLong()); + } + + @Override + public void remove() { + + inner.remove(); + } + + @SuppressWarnings("unused") + public int skip(int n) { + + return inner.skip(n); + } + }; + } + +} diff --git a/src/main/java/ca/teamdman/sfm/common/util/ChunkPosMap.java b/src/main/java/ca/teamdman/sfm/common/util/ChunkPosMap.java index 78cf8ec54..b1ab1bd57 100644 --- a/src/main/java/ca/teamdman/sfm/common/util/ChunkPosMap.java +++ b/src/main/java/ca/teamdman/sfm/common/util/ChunkPosMap.java @@ -1,128 +1,128 @@ -package ca.teamdman.sfm.common.util; - -import it.unimi.dsi.fastutil.longs.Long2ObjectFunction; -import it.unimi.dsi.fastutil.longs.Long2ObjectMap; -import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; -import it.unimi.dsi.fastutil.longs.LongSet; -import it.unimi.dsi.fastutil.objects.ObjectCollection; -import it.unimi.dsi.fastutil.objects.ObjectSet; -import net.minecraft.core.BlockPos; -import net.minecraft.world.level.ChunkPos; -import net.minecraft.world.level.chunk.ChunkAccess; -import org.jetbrains.annotations.Nullable; - -public class ChunkPosMap { - private final Long2ObjectMap inner = new Long2ObjectOpenHashMap<>(); - - public boolean isEmpty() { - - return inner.isEmpty(); - } - - public void clear() { - - inner.clear(); - } - - public @Nullable T put( - long key, - T value - ) { - - return inner.put(key, value); - } - - public @Nullable T get(ChunkAccess chunk) { - - return get(chunk.getPos()); - } - - public @Nullable T get(ChunkPos chunkPos) { - - return get(chunkPos.toLong()); - } - - /// CORRECTNESS: make sure this is not a {@link BlockPos#asLong()} - public @Nullable T get(long chunkPosLong) { - - return inner.get(chunkPosLong); - } - - public @Nullable T get(BlockPos blockPos) { - - return inner.get(ChunkPos.asLong(blockPos)); - } - - - public @Nullable T remove(ChunkAccess chunk) { - - return remove(chunk.getPos()); - } - - public @Nullable T remove(ChunkPos chunkPos) { - - return remove(chunkPos.toLong()); - } - - /// @param chunkPosLong Correctness: MUST come from {@link ChunkPos#asLong}, not to be confused with a {@link BlockPos#asLong()} - public @Nullable T remove(long chunkPosLong) { - - return inner.remove(chunkPosLong); - } - - public LongSet keySet() { - - return inner.keySet(); - } - - public int size() { - - return inner.size(); - } - - public boolean containsKey(long key) { - - return inner.containsKey(key); - } - - /// @param chunkPosLong Correctness: must be from {@link ChunkPos#asLong}, not to be confused with a {@link BlockPos#asLong()} - public T computeIfAbsent( - long chunkPosLong, - Long2ObjectFunction mappingFunction - ) { - - return inner.computeIfAbsent(chunkPosLong, mappingFunction); - } - - public T computeIfAbsent( - BlockPos memberBlockPos, - Long2ObjectFunction mappingFunction - ) { - - return computeIfAbsent(ChunkPos.asLong(memberBlockPos), mappingFunction); - } - - public T computeIfAbsent( - ChunkPos chunkPos, - Long2ObjectFunction mappingFunction - ) { - - return inner.computeIfAbsent(chunkPos.toLong(), mappingFunction); - } - - public @Nullable T remove(BlockPos blockPos) { - - return inner.remove(ChunkPos.asLong(blockPos)); - } - - public ObjectCollection values() { - - return inner.values(); - } - - public ObjectSet> entrySet() { - - return inner.long2ObjectEntrySet(); - } - -} +package ca.teamdman.sfm.common.util; + +import it.unimi.dsi.fastutil.longs.Long2ObjectFunction; +import it.unimi.dsi.fastutil.longs.Long2ObjectMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.longs.LongSet; +import it.unimi.dsi.fastutil.objects.ObjectCollection; +import it.unimi.dsi.fastutil.objects.ObjectSet; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.chunk.ChunkAccess; +import org.jetbrains.annotations.Nullable; + +public class ChunkPosMap { + private final Long2ObjectMap inner = new Long2ObjectOpenHashMap<>(); + + public boolean isEmpty() { + + return inner.isEmpty(); + } + + public void clear() { + + inner.clear(); + } + + public @Nullable T put( + long key, + T value + ) { + + return inner.put(key, value); + } + + public @Nullable T get(ChunkAccess chunk) { + + return get(chunk.getPos()); + } + + public @Nullable T get(ChunkPos chunkPos) { + + return get(chunkPos.toLong()); + } + + /// CORRECTNESS: make sure this is not a {@link BlockPos#asLong()} + public @Nullable T get(long chunkPosLong) { + + return inner.get(chunkPosLong); + } + + public @Nullable T get(BlockPos blockPos) { + + return inner.get(ChunkPos.asLong(blockPos)); + } + + + public @Nullable T remove(ChunkAccess chunk) { + + return remove(chunk.getPos()); + } + + public @Nullable T remove(ChunkPos chunkPos) { + + return remove(chunkPos.toLong()); + } + + /// @param chunkPosLong Correctness: MUST come from {@link ChunkPos#asLong}, not to be confused with a {@link BlockPos#asLong()} + public @Nullable T remove(long chunkPosLong) { + + return inner.remove(chunkPosLong); + } + + public LongSet keySet() { + + return inner.keySet(); + } + + public int size() { + + return inner.size(); + } + + public boolean containsKey(long key) { + + return inner.containsKey(key); + } + + /// @param chunkPosLong Correctness: must be from {@link ChunkPos#asLong}, not to be confused with a {@link BlockPos#asLong()} + public T computeIfAbsent( + long chunkPosLong, + Long2ObjectFunction mappingFunction + ) { + + return inner.computeIfAbsent(chunkPosLong, mappingFunction); + } + + public T computeIfAbsent( + BlockPos memberBlockPos, + Long2ObjectFunction mappingFunction + ) { + + return computeIfAbsent(ChunkPos.asLong(memberBlockPos), mappingFunction); + } + + public T computeIfAbsent( + ChunkPos chunkPos, + Long2ObjectFunction mappingFunction + ) { + + return inner.computeIfAbsent(chunkPos.toLong(), mappingFunction); + } + + public @Nullable T remove(BlockPos blockPos) { + + return inner.remove(ChunkPos.asLong(blockPos)); + } + + public ObjectCollection values() { + + return inner.values(); + } + + public ObjectSet> entrySet() { + + return inner.long2ObjectEntrySet(); + } + +} diff --git a/src/main/java/ca/teamdman/sfm/common/util/SFMBlockPosUtils.java b/src/main/java/ca/teamdman/sfm/common/util/SFMBlockPosUtils.java index 63a2897ab..28ab9f9a2 100644 --- a/src/main/java/ca/teamdman/sfm/common/util/SFMBlockPosUtils.java +++ b/src/main/java/ca/teamdman/sfm/common/util/SFMBlockPosUtils.java @@ -1,32 +1,32 @@ -package ca.teamdman.sfm.common.util; - -import net.minecraft.core.BlockPos; - -import java.util.Arrays; -import java.util.stream.Stream; - -public class SFMBlockPosUtils { - public static Stream get3DNeighboursIncludingKittyCorner(BlockPos pos) { - Stream.Builder builder = Stream.builder(); - for (int x = -1; x <= 1; x++) { - for (int y = -1; y <= 1; y++) { - for (int z = -1; z <= 1; z++) { - if (x == 0 && y == 0 && z == 0) continue; - builder.accept(pos.offset(x, y, z).immutable()); - } - } - } - return builder.build(); - } - - public static Stream get3DNeighbours(BlockPos pos) { - return Arrays.stream(SFMDirections.DIRECTIONS_WITHOUT_NULL).map(d -> pos.offset(d.getNormal())); - } - - - /// @return true iff 1 unit offsets the block positions along a single axis - public static boolean isAdjacent(BlockPos first, BlockPos second) { - return Math.abs(first.getX() - second.getX()) + Math.abs(first.getY() - second.getY()) + Math.abs(first.getZ() - second.getZ()) == 1; - } - -} +package ca.teamdman.sfm.common.util; + +import net.minecraft.core.BlockPos; + +import java.util.Arrays; +import java.util.stream.Stream; + +public class SFMBlockPosUtils { + public static Stream get3DNeighboursIncludingKittyCorner(BlockPos pos) { + Stream.Builder builder = Stream.builder(); + for (int x = -1; x <= 1; x++) { + for (int y = -1; y <= 1; y++) { + for (int z = -1; z <= 1; z++) { + if (x == 0 && y == 0 && z == 0) continue; + builder.accept(pos.offset(x, y, z).immutable()); + } + } + } + return builder.build(); + } + + public static Stream get3DNeighbours(BlockPos pos) { + return Arrays.stream(SFMDirections.DIRECTIONS_WITHOUT_NULL).map(d -> pos.offset(d.getNormal())); + } + + + /// @return true iff 1 unit offsets the block positions along a single axis + public static boolean isAdjacent(BlockPos first, BlockPos second) { + return Math.abs(first.getX() - second.getX()) + Math.abs(first.getY() - second.getY()) + Math.abs(first.getZ() - second.getZ()) == 1; + } + +} diff --git a/src/main/java/ca/teamdman/sfm/common/util/SFMDist.java b/src/main/java/ca/teamdman/sfm/common/util/SFMDist.java index 51c94610e..795759cc9 100644 --- a/src/main/java/ca/teamdman/sfm/common/util/SFMDist.java +++ b/src/main/java/ca/teamdman/sfm/common/util/SFMDist.java @@ -1,31 +1,31 @@ -package ca.teamdman.sfm.common.util; - -import net.minecraftforge.api.distmarker.Dist; -import net.minecraftforge.fml.loading.FMLEnvironment; - -/// This exists because the import for {@link Dist} is {@link MCVersionDependentBehaviour} -public enum SFMDist { - CLIENT(Dist.CLIENT), - DEDICATED_SERVER(Dist.DEDICATED_SERVER); - - public final Dist inner; - - SFMDist(Dist inner) { - this.inner = inner; - } - - public static SFMDist current() { - return SFMDist.from(FMLEnvironment.dist); - } - - public static SFMDist from(Dist dist) { - return switch(dist) { - case CLIENT -> CLIENT; - case DEDICATED_SERVER -> DEDICATED_SERVER; - }; - } - - public boolean isClient() { - return inner == Dist.CLIENT; - } -} +package ca.teamdman.sfm.common.util; + +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.fml.loading.FMLEnvironment; + +/// This exists because the import for {@link Dist} is {@link MCVersionDependentBehaviour} +public enum SFMDist { + CLIENT(Dist.CLIENT), + DEDICATED_SERVER(Dist.DEDICATED_SERVER); + + public final Dist inner; + + SFMDist(Dist inner) { + this.inner = inner; + } + + public static SFMDist current() { + return SFMDist.from(FMLEnvironment.dist); + } + + public static SFMDist from(Dist dist) { + return switch(dist) { + case CLIENT -> CLIENT; + case DEDICATED_SERVER -> DEDICATED_SERVER; + }; + } + + public boolean isClient() { + return inner == Dist.CLIENT; + } +} diff --git a/src/main/java/ca/teamdman/sfml/ast/ASTBuilder.java b/src/main/java/ca/teamdman/sfml/ast/ASTBuilder.java index 7d0c4d10f..5e9917b08 100644 --- a/src/main/java/ca/teamdman/sfml/ast/ASTBuilder.java +++ b/src/main/java/ca/teamdman/sfml/ast/ASTBuilder.java @@ -3,6 +3,8 @@ import ca.teamdman.langs.SFMLBaseVisitor; import ca.teamdman.langs.SFMLParser; import ca.teamdman.sfm.common.config.SFMConfig; +import ca.teamdman.sfml.program_builder.LibraryDefinitions; +import ca.teamdman.sfml.program_builder.LibraryResolver; import com.mojang.datafixers.util.Pair; import org.antlr.v4.runtime.ParserRuleContext; import org.antlr.v4.runtime.tree.ParseTree; @@ -11,6 +13,7 @@ import java.lang.ref.WeakReference; import java.util.*; +import java.util.AbstractMap.SimpleEntry; import java.util.stream.Collectors; public class ASTBuilder extends SFMLBaseVisitor { @@ -23,6 +26,67 @@ public class ASTBuilder extends SFMLBaseVisitor { /// Used for program editor context actions; ctrl+space on a token private final List, ParserRuleContext>> AST_NODE_CONTEXTS = new LinkedList<>(); + /// Struct definitions indexed by name, populated during AST building + private final Map STRUCT_DEFINITIONS = new HashMap<>(); + + /// Struct instances indexed by variable name, populated during AST building + private final Map STRUCT_INSTANCES = new HashMap<>(); + + /// Protocol definitions indexed by name, populated during AST building + private final Map PROTOCOL_DEFINITIONS = new HashMap<>(); + + /// Macro definitions indexed by name, populated during AST building + private final Map MACRO_DEFINITIONS = new HashMap<>(); + + /// Tracks libraries currently being resolved to detect circular dependencies + private final Set librariesBeingResolved = new HashSet<>(); + + /// Library resolver for resolving "use library" statements + private LibraryResolver libraryResolver = LibraryResolver.NONE; + + /** + * Sets the library resolver used to resolve "use library" statements. + */ + public void setLibraryResolver(LibraryResolver resolver) { + this.libraryResolver = resolver != null ? resolver : LibraryResolver.NONE; + } + + /** + * Registers a definition in a map, throwing if a duplicate name is detected. + * + * @param definitions The map to register in + * @param name The name to register + * @param value The value to associate with the name + * @param typeName The type name for error messages (e.g., "protocol", "struct", "macro") + * @param The type of definition + */ + private void registerDefinition(Map definitions, String name, T value, String typeName) { + if (definitions.containsKey(name)) { + throw new IllegalArgumentException("Duplicate " + typeName + " definition: " + name); + } + definitions.put(name, value); + } + + /** + * Builds a ResourceIdSet from a list of resource ID contexts. + * + * @param resourceIds The list of resource ID contexts to process + * @param trackCtx The parser context to track (may be null) + * @return A ResourceIdSet containing the parsed resource identifiers + */ + private ResourceIdSet buildResourceIdSet(List resourceIds, @Nullable ParserRuleContext trackCtx) { + HashSet> ids = resourceIds + .stream() + .map(this::visit) + .map(ResourceIdentifier.class::cast) + .collect(HashSet::new, HashSet::add, HashSet::addAll); + ResourceIdSet resourceIdSet = new ResourceIdSet(ids); + if (trackCtx != null) { + trackNode(resourceIdSet, trackCtx); + } + return resourceIdSet; + } + /// @return hierarchy of nodes; e.g., Program > Trigger > Block > IOStatement > LabelAccess > Label public List> getNodesUnderCursor(int cursorPos) { @@ -50,8 +114,14 @@ public void setLocationFromOtherNode( ASTNode node, ASTNode otherNode ) { - - trackNode(node, AST_NODE_CONTEXTS.get(getIndexForNode(otherNode)).getSecond()); + int index = getIndexForNode(otherNode); + if (index < 0) { + // Node not found in AST_NODE_CONTEXTS - this can happen for macro-expanded statements + // In this case, we track the node without a context + trackNode(node, null); + return; + } + trackNode(node, AST_NODE_CONTEXTS.get(index).getSecond()); } /// Used for client-server collaboration to make context menu actions work. @@ -174,21 +244,719 @@ public Program visitProgram(SFMLParser.ProgramContext ctx) { throw new AssertionError("Program execution is disabled via config"); } var name = visitName(ctx.name()); + + // Process library references + var libraries = ctx + .library() + .stream() + .map(this::visitLibrary) + .collect(Collectors.toList()); + + // Resolve library definitions and import them before processing local definitions + for (LibraryStatement libraryStmt : libraries) { + String libraryName = libraryStmt.blockLabel(); + + // Check for circular dependency + if (librariesBeingResolved.contains(libraryName)) { + throw new IllegalArgumentException("Circular library dependency detected: " + libraryName); + } + + librariesBeingResolved.add(libraryName); + Optional resolved; + try { + resolved = libraryResolver.resolve(libraryName); + } finally { + librariesBeingResolved.remove(libraryName); + } + + if (resolved.isEmpty()) { + throw new IllegalArgumentException("Library '" + libraryName + "' not found in cable network"); + } + LibraryDefinitions libDefs = resolved.get(); + + // Import protocols from library + for (ProtocolDefinition proto : libDefs.protocols()) { + if (PROTOCOL_DEFINITIONS.containsKey(proto.name())) { + throw new IllegalArgumentException("Duplicate protocol definition: " + proto.name() + " (imported from library '" + libraryStmt.blockLabel() + "')"); + } + PROTOCOL_DEFINITIONS.put(proto.name(), proto); + } + + // Import structs from library + for (StructDefinition struct : libDefs.structs()) { + if (STRUCT_DEFINITIONS.containsKey(struct.name())) { + throw new IllegalArgumentException("Duplicate struct definition: " + struct.name() + " (imported from library '" + libraryStmt.blockLabel() + "')"); + } + STRUCT_DEFINITIONS.put(struct.name(), struct); + } + + // Import macros from library + for (MacroDefinition macro : libDefs.macros()) { + if (MACRO_DEFINITIONS.containsKey(macro.name())) { + throw new IllegalArgumentException("Duplicate macro definition: " + macro.name() + " (imported from library '" + libraryStmt.blockLabel() + "')"); + } + MACRO_DEFINITIONS.put(macro.name(), macro); + } + } + + // Process protocol definitions (local definitions can override or extend library ones) + var protocolDefinitions = ctx + .protocolDefinition() + .stream() + .map(this::visitProtocolDefinition) + .collect(Collectors.toList()); + + // Process struct definitions (they can implement protocols) + var structDefinitions = ctx + .structDefinition() + .stream() + .map(this::visitStructDefinition) + .collect(Collectors.toList()); + + // Process macro definitions (they can reference protocols) + var macroDefinitions = ctx + .macroDefinition() + .stream() + .map(this::visitMacroDefinition) + .collect(Collectors.toList()); + + // Process let statements next (they reference struct definitions) + var letStatements = ctx + .letStatement() + .stream() + .map(this::visitLetStatement) + .collect(Collectors.toList()); + + // Process triggers last (they can reference struct instances and macros) var triggers = ctx .trigger() .stream() .map(this::visit) .map(Trigger.class::cast) .collect(Collectors.toList()); + var labels = USED_LABELS .stream() .map(Label::name) .collect(Collectors.toSet()); - Program program = new Program(this, name.value(), triggers, labels, USED_RESOURCES); + Program program = new Program( + this, + name.value(), + libraries, + protocolDefinitions, + structDefinitions, + macroDefinitions, + letStatements, + triggers, + labels, + USED_RESOURCES + ); trackNode(program, ctx); return program; } + // ===== LIBRARIES ===== + + public LibraryStatement visitLibrary(SFMLParser.LibraryContext ctx) { + String blockLabel = visitString(ctx.string()).value(); + LibraryStatement libraryStmt = new LibraryStatement(blockLabel); + trackNode(libraryStmt, ctx); + return libraryStmt; + } + + // ===== PROTOCOL DEFINITIONS ===== + + public ProtocolDefinition visitProtocolDefinition(SFMLParser.ProtocolDefinitionContext ctx) { + String name = ctx.identifier().getText(); + + List fields = new ArrayList<>(); + Set fieldNames = new HashSet<>(); + + for (SFMLParser.ProtocolFieldContext fieldCtx : ctx.protocolBody().protocolField()) { + ProtocolField field = visitProtocolField(fieldCtx); + + // Check for duplicate field names + if (!fieldNames.add(field.name())) { + throw new IllegalArgumentException( + "Duplicate field name '" + field.name() + "' in protocol " + name + ); + } + + fields.add(field); + } + + ProtocolDefinition protocolDef = new ProtocolDefinition(name, fields); + registerDefinition(PROTOCOL_DEFINITIONS, name, protocolDef, "protocol"); + trackNode(protocolDef, ctx); + return protocolDef; + } + + public ProtocolField visitProtocolField(SFMLParser.ProtocolFieldContext ctx) { + String fieldName = ctx.identifier().getText(); + ProtocolFieldType type = visitProtocolFieldType(ctx.protocolFieldType()); + ProtocolField field = new ProtocolField(fieldName, type); + trackNode(field, ctx); + return field; + } + + public ProtocolFieldType visitProtocolFieldType(SFMLParser.ProtocolFieldTypeContext ctx) { + ProtocolFieldType type; + if (ctx instanceof SFMLParser.SideAndSlotTypeContext) { + type = ProtocolFieldType.SIDE_AND_SLOT; + } else if (ctx instanceof SFMLParser.SideTypeContext) { + type = ProtocolFieldType.SIDE_QUALIFIER; + } else if (ctx instanceof SFMLParser.SlotTypeContext) { + type = ProtocolFieldType.SLOT_QUALIFIER; + } else if (ctx instanceof SFMLParser.LabelTypeContext) { + type = ProtocolFieldType.LABEL; + } else if (ctx instanceof SFMLParser.ResourceTypeContext) { + type = ProtocolFieldType.RESOURCE; + } else if (ctx instanceof SFMLParser.NumberTypeContext) { + type = ProtocolFieldType.NUMBER; + } else { + throw new IllegalStateException("Unknown protocol field type"); + } + trackNode(type, ctx); + return type; + } + + // ===== STRUCT DEFINITIONS ===== + + public StructDefinition visitStructDefinition(SFMLParser.StructDefinitionContext ctx) { + String name = ctx.identifier(0).getText(); + + // Collect implemented protocols + List implementedProtocols = new ArrayList<>(); + for (int i = 1; i < ctx.identifier().size(); i++) { + String protocolName = ctx.identifier(i).getText(); + if (!PROTOCOL_DEFINITIONS.containsKey(protocolName)) { + throw new IllegalArgumentException("Unknown protocol: " + protocolName); + } + implementedProtocols.add(protocolName); + } + + List fields = new ArrayList<>(); + Set fieldNames = new HashSet<>(); + + for (SFMLParser.StructFieldContext fieldCtx : ctx.structBody().structField()) { + StructField field = visitStructField(fieldCtx); + + // Check for duplicate field names + if (!fieldNames.add(field.name())) { + throw new IllegalArgumentException( + "Duplicate field name '" + field.name() + "' in struct " + name + ); + } + + fields.add(field); + } + + StructDefinition structDef = new StructDefinition(name, implementedProtocols, fields); + + // Validate protocol conformance + for (String protocolName : implementedProtocols) { + validateProtocolConformance(structDef, PROTOCOL_DEFINITIONS.get(protocolName)); + } + + registerDefinition(STRUCT_DEFINITIONS, name, structDef, "struct"); + trackNode(structDef, ctx); + return structDef; + } + + /** + * Validates that a struct properly implements all fields required by a protocol. + */ + private void validateProtocolConformance(StructDefinition struct, ProtocolDefinition protocol) { + for (ProtocolField protoField : protocol.fields()) { + Optional structField = struct.getField(protoField.name()); + + if (structField.isEmpty()) { + throw new IllegalArgumentException( + "Struct '" + struct.name() + "' is missing required field '" + + protoField.name() + "' from protocol '" + protocol.name() + "'" + ); + } + + if (!protoField.type().matches(structField.get().value())) { + throw new IllegalArgumentException( + "Struct '" + struct.name() + "' field '" + protoField.name() + + "' has wrong type. Expected " + protoField.type() + + " but got " + structField.get().value().getClass().getSimpleName() + ); + } + } + } + + public StructField visitStructField(SFMLParser.StructFieldContext ctx) { + String fieldName = ctx.identifier().getText(); + StructFieldValue value = visitStructFieldValue(ctx.structFieldValue()); + StructField field = new StructField(fieldName, value); + trackNode(field, ctx); + return field; + } + + public StructFieldValue visitStructFieldValue(SFMLParser.StructFieldValueContext ctx) { + // composite: sidequalifier slotqualifier? + if (ctx.sidequalifier() != null) { + SideQualifier sides = (SideQualifier) visit(ctx.sidequalifier()); + NumberRangeSet slots = visitSlotqualifier(ctx.slotqualifier()); + if (!slots.equals(NumberRangeSet.MAX_RANGE)) { + // Has both sides and slots - composite value + CompositeFieldValue composite = new CompositeFieldValue(sides, slots); + trackNode(composite, ctx); + return composite; + } + // Just sides + return sides; + } + + // slotqualifier only + if (ctx.slotqualifier() != null) { + return visitSlotqualifier(ctx.slotqualifier()); + } + + // resourceIdDisjunction + if (ctx.resourceIdDisjunction() != null) { + return visitResourceIdDisjunction(ctx.resourceIdDisjunction()); + } + + // label + if (ctx.label() != null) { + return (Label) visit(ctx.label()); + } + + // number + if (ctx.number() != null) { + return visitNumber(ctx.number()); + } + + throw new IllegalStateException("Unknown struct field value type"); + } + + public LetStatement visitLetStatement(SFMLParser.LetStatementContext ctx) { + String variableName = ctx.identifier().getText(); + + // Check for duplicate variable names + if (STRUCT_INSTANCES.containsKey(variableName)) { + throw new IllegalArgumentException("Duplicate variable name: " + variableName); + } + + StructInstance instance = visitStructInstantiation(ctx.structInstantiation(), variableName); + LetStatement letStatement = new LetStatement(variableName, instance); + + STRUCT_INSTANCES.put(variableName, instance); + trackNode(letStatement, ctx); + return letStatement; + } + + public StructInstance visitStructInstantiation(SFMLParser.StructInstantiationContext ctx, String variableName) { + String structName = ctx.identifier().getText(); + + // Look up the struct definition + StructDefinition definition = STRUCT_DEFINITIONS.get(structName); + if (definition == null) { + throw new IllegalArgumentException("Unknown struct: " + structName); + } + + // Use the variable name as the label automatically + Label label = new Label(variableName); + USED_LABELS.add(label); + + // Create overrides map with the label + Map overrides = new LinkedHashMap<>(); + overrides.put("label", label); + + // Process optional WITH clause field overrides + for (SFMLParser.StructFieldOverrideContext overrideCtx : ctx.structFieldOverride()) { + String fieldName = overrideCtx.identifier().getText(); + + // Validate that the field exists in the struct definition + if (definition.getField(fieldName).isEmpty()) { + throw new IllegalArgumentException( + "Unknown field '" + fieldName + "' in struct " + structName + ); + } + + StructFieldValue value = visitStructFieldValue(overrideCtx.structFieldValue()); + overrides.put(fieldName, value); + } + + StructInstance instance = new StructInstance(variableName, definition, overrides); + trackNode(instance, ctx); + return instance; + } + + // ===== END STRUCT DEFINITIONS ===== + + // ===== MACRO DEFINITIONS ===== + + public MacroDefinition visitMacroDefinition(SFMLParser.MacroDefinitionContext ctx) { + String name = ctx.identifier().getText(); + + // Parse parameters + List parameters = new ArrayList<>(); + if (ctx.macroParamList() != null) { + for (SFMLParser.MacroParamContext paramCtx : ctx.macroParamList().macroParam()) { + MacroParameter param = visitMacroParam(paramCtx); + parameters.add(param); + } + } + + // Parse body + List body = visitMacroBodyStatements(ctx.macroBody()); + + MacroDefinition macroDef = new MacroDefinition(name, parameters, body); + registerDefinition(MACRO_DEFINITIONS, name, macroDef, "macro"); + trackNode(macroDef, ctx); + return macroDef; + } + + public MacroParameter visitMacroParam(SFMLParser.MacroParamContext ctx) { + String name = ctx.identifier(0).getText(); + String protocolConstraint = null; + if (ctx.identifier().size() > 1) { + protocolConstraint = ctx.identifier(1).getText(); + // Validate that the protocol exists + if (!PROTOCOL_DEFINITIONS.containsKey(protocolConstraint)) { + throw new IllegalArgumentException("Unknown protocol constraint: " + protocolConstraint); + } + } + MacroParameter param = new MacroParameter(name, protocolConstraint); + trackNode(param, ctx); + return param; + } + + private List visitMacroBodyStatements(SFMLParser.MacroBodyContext ctx) { + List statements = new ArrayList<>(); + for (SFMLParser.MacroStatementContext stmtCtx : ctx.macroStatement()) { + statements.add(visitMacroStatement(stmtCtx)); + } + return statements; + } + + public MacroStatement visitMacroStatement(SFMLParser.MacroStatementContext ctx) { + if (ctx.macroInputStatement() != null) { + return visitMacroInputStatement(ctx.macroInputStatement()); + } else if (ctx.macroOutputStatement() != null) { + return visitMacroOutputStatement(ctx.macroOutputStatement()); + } else if (ctx.macroIfStatement() != null) { + return visitMacroIfStatement(ctx.macroIfStatement()); + } else if (ctx.macroForgetStatement() != null) { + return visitMacroForgetStatement(ctx.macroForgetStatement()); + } + throw new IllegalStateException("Unknown macro statement type"); + } + + public MacroInputStatement visitMacroInputStatement(SFMLParser.MacroInputStatementContext ctx) { + MacroLabelAccess labelAccess = visitMacroLabelAccess(ctx.macroLabelAccess()); + ResourceLimits resourceLimits = null; + if (ctx.macroResourceLimits() != null) { + resourceLimits = visitResourceLimitList(ctx.macroResourceLimits().resourceLimitList()) + .withDefaultLimit(Limit.MAX_QUANTITY_NO_RETENTION); + } + boolean each = ctx.EACH() != null; + MacroInputStatement stmt = new MacroInputStatement(labelAccess, resourceLimits, each); + trackNode(stmt, ctx); + return stmt; + } + + public MacroOutputStatement visitMacroOutputStatement(SFMLParser.MacroOutputStatementContext ctx) { + MacroLabelAccess labelAccess = visitMacroLabelAccess(ctx.macroLabelAccess()); + ResourceLimits resourceLimits = null; + if (ctx.macroResourceLimits() != null) { + resourceLimits = visitResourceLimitList(ctx.macroResourceLimits().resourceLimitList()) + .withDefaultLimit(Limit.MAX_QUANTITY_MAX_RETENTION); + } + boolean each = ctx.EACH() != null; + MacroOutputStatement stmt = new MacroOutputStatement(labelAccess, resourceLimits, each); + trackNode(stmt, ctx); + return stmt; + } + + public MacroIfStatement visitMacroIfStatement(SFMLParser.MacroIfStatementContext ctx) { + BoolExpr condition = (BoolExpr) visit(ctx.boolexpr()); + List thenBody = visitMacroBodyStatements(ctx.macroBody(0)); + List elseBody = ctx.macroBody().size() > 1 + ? visitMacroBodyStatements(ctx.macroBody(1)) + : List.of(); + MacroIfStatement stmt = new MacroIfStatement(condition, thenBody, elseBody); + trackNode(stmt, ctx); + return stmt; + } + + public MacroForgetStatement visitMacroForgetStatement(SFMLParser.MacroForgetStatementContext ctx) { + MacroForgetStatement stmt = new MacroForgetStatement(); + trackNode(stmt, ctx); + return stmt; + } + + public MacroLabelAccess visitMacroLabelAccess(SFMLParser.MacroLabelAccessContext ctx) { + if (ctx instanceof SFMLParser.MacroParamLabelAccessContext paramCtx) { + String paramName = paramCtx.identifier().getText(); + MacroLabelAccess access = MacroLabelAccess.parameter(paramName); + trackNode(access, ctx); + return access; + } else if (ctx instanceof SFMLParser.MacroStructLabelAccessContext structCtx) { + String paramName = structCtx.identifier(0).getText(); + String fieldName = structCtx.identifier(1).getText(); + SideQualifier sideOverride = structCtx.sidequalifier() != null + ? (SideQualifier) visit(structCtx.sidequalifier()) + : null; + NumberRangeSet slotOverride = structCtx.slotqualifier() != null + ? visitSlotqualifier(structCtx.slotqualifier()) + : null; + MacroLabelAccess access = MacroLabelAccess.structAccess(paramName, fieldName, sideOverride, slotOverride); + trackNode(access, ctx); + return access; + } + throw new IllegalStateException("Unknown macro label access type"); + } + + // ===== EXPAND STATEMENTS ===== + + @Override + public ExpandStatement visitExpandStatement(SFMLParser.ExpandStatementContext ctx) { + String macroName = ctx.identifier().getText(); + + // Look up the macro + MacroDefinition macro = MACRO_DEFINITIONS.get(macroName); + if (macro == null) { + throw new IllegalArgumentException("Unknown macro: " + macroName); + } + + // Parse arguments + List arguments = new ArrayList<>(); + if (ctx.expandArgList() != null) { + for (SFMLParser.ExpandArgContext argCtx : ctx.expandArgList().expandArg()) { + ExpandArgument arg = visitExpandArg(argCtx); + arguments.add(arg); + } + } + + // Validate argument count + if (arguments.size() != macro.parameters().size()) { + throw new IllegalArgumentException( + "Macro '" + macroName + "' expects " + macro.parameters().size() + + " arguments but got " + arguments.size() + ); + } + + // Validate protocol constraints + for (int i = 0; i < arguments.size(); i++) { + MacroParameter param = macro.parameters().get(i); + ExpandArgument arg = arguments.get(i); + + if (param.protocolConstraint() != null) { + // Argument must be a struct variable that implements the protocol + if (arg.isStringLiteral()) { + throw new IllegalArgumentException( + "Macro parameter '" + param.name() + "' requires a struct implementing protocol '" + + param.protocolConstraint() + "', but got a string literal" + ); + } + + StructInstance instance = STRUCT_INSTANCES.get(arg.value()); + if (instance == null) { + throw new IllegalArgumentException( + "Macro parameter '" + param.name() + "' requires a struct implementing protocol '" + + param.protocolConstraint() + "', but '" + arg.value() + "' is not a struct variable" + ); + } + + if (!instance.definition().implementsProtocol(param.protocolConstraint())) { + throw new IllegalArgumentException( + "Struct '" + instance.definition().name() + "' does not implement protocol '" + + param.protocolConstraint() + "' required by macro parameter '" + param.name() + "'" + ); + } + } + } + + // Expand the macro + List expandedStatements = expandMacro(macro, arguments); + + ExpandStatement expandStmt = new ExpandStatement(macroName, arguments, expandedStatements); + trackNode(expandStmt, ctx); + return expandStmt; + } + + public ExpandArgument visitExpandArg(SFMLParser.ExpandArgContext ctx) { + if (ctx.identifier() != null) { + return ExpandArgument.identifier(ctx.identifier().getText()); + } else if (ctx.string() != null) { + return ExpandArgument.stringLiteral(visitString(ctx.string()).value()); + } + throw new IllegalStateException("Unknown expand argument type"); + } + + /** + * Expands a macro with the given arguments, producing a list of statements. + */ + private List expandMacro(MacroDefinition macro, List arguments) { + List result = new ArrayList<>(); + + // Build argument map + Map argMap = new HashMap<>(); + for (int i = 0; i < macro.parameters().size(); i++) { + argMap.put(macro.parameters().get(i).name(), arguments.get(i)); + } + + // Expand each macro statement + for (MacroStatement macroStmt : macro.body()) { + result.addAll(expandMacroStatement(macroStmt, argMap)); + } + + return result; + } + + /** + * Expands a single macro statement into regular statements. + */ + private List expandMacroStatement(MacroStatement macroStmt, Map argMap) { + if (macroStmt instanceof MacroInputStatement input) { + return List.of(expandMacroInput(input, argMap)); + } else if (macroStmt instanceof MacroOutputStatement output) { + return List.of(expandMacroOutput(output, argMap)); + } else if (macroStmt instanceof MacroForgetStatement) { + return List.of(new ForgetStatement(USED_LABELS)); + } else if (macroStmt instanceof MacroIfStatement macroIf) { + List thenStatements = new ArrayList<>(); + for (MacroStatement s : macroIf.thenBody()) { + thenStatements.addAll(expandMacroStatement(s, argMap)); + } + List elseStatements = new ArrayList<>(); + for (MacroStatement s : macroIf.elseBody()) { + elseStatements.addAll(expandMacroStatement(s, argMap)); + } + return List.of(new IfStatement( + macroIf.condition(), + new Block(thenStatements), + new Block(elseStatements) + )); + } + throw new IllegalStateException("Unknown macro statement type: " + macroStmt.getClass()); + } + + /** + * Expands a macro input statement. + */ + private InputStatement expandMacroInput(MacroInputStatement macroInput, Map argMap) { + LabelAccess labelAccess = resolveMacroLabelAccess(macroInput.labelAccess(), argMap); + ResourceLimits limits = macroInput.resourceLimits() != null + ? macroInput.resourceLimits() + : new ResourceLimits(List.of(ResourceLimit.TAKE_ALL_LEAVE_NONE), ResourceIdSet.EMPTY); + return new InputStatement(labelAccess, limits, macroInput.each()); + } + + /** + * Expands a macro output statement. + */ + private OutputStatement expandMacroOutput(MacroOutputStatement macroOutput, Map argMap) { + LabelAccess labelAccess = resolveMacroLabelAccess(macroOutput.labelAccess(), argMap); + ResourceLimits limits = macroOutput.resourceLimits() != null + ? macroOutput.resourceLimits() + : new ResourceLimits(List.of(ResourceLimit.ACCEPT_ALL_WITHOUT_RESTRAINT), ResourceIdSet.EMPTY); + return new OutputStatement(labelAccess, limits, macroOutput.each(), false); + } + + /** + * Resolves a macro label access to a concrete LabelAccess. + */ + private LabelAccess resolveMacroLabelAccess(MacroLabelAccess macroAccess, Map argMap) { + ExpandArgument arg = argMap.get(macroAccess.parameterOrVariable()); + + // Early null check with clear error message + if (arg == null) { + throw new IllegalStateException("Unknown macro parameter: " + macroAccess.parameterOrVariable()); + } + + if (macroAccess.isStructAccess()) { + // This is a struct field access: param using field + if (arg.isStringLiteral()) { + throw new IllegalStateException( + "Macro struct access requires a struct variable, got: " + macroAccess.parameterOrVariable() + ); + } + + StructInstance instance = STRUCT_INSTANCES.get(arg.value()); + if (instance == null) { + throw new IllegalStateException("Unknown struct variable in macro expansion: " + arg.value()); + } + + Label label = instance.getLabel().orElseThrow(() -> + new IllegalStateException("Struct instance " + arg.value() + " has no label") + ); + + // Resolve sides and slots from the field + String fieldName = macroAccess.fieldName(); + Optional fieldValue = instance.resolveField(fieldName); + if (fieldValue.isEmpty()) { + throw new IllegalStateException( + "Unknown field '" + fieldName + "' in struct " + instance.definition().name() + ); + } + + SideQualifier sides = SideQualifier.NULL; + NumberRangeSet slots = NumberRangeSet.MAX_RANGE; + + StructFieldValue resolved = fieldValue.get(); + if (resolved instanceof CompositeFieldValue composite) { + sides = composite.sides(); + slots = composite.slots(); + } else if (resolved instanceof SideQualifier sq) { + sides = sq; + } else if (resolved instanceof NumberRangeSet nrs) { + slots = nrs; + } + + // Apply overrides + if (macroAccess.sideOverride() != null) { + sides = macroAccess.sideOverride(); + } + if (macroAccess.slotOverride() != null) { + slots = macroAccess.slotOverride(); + } + + return new LabelAccess( + List.of(label), + sides, + slots, + RoundRobin.disabled(), + new StructAccess(arg.value(), fieldName) + ); + } else { + // This is a simple parameter reference + Label label; + if (arg.isStringLiteral()) { + // String literal becomes a label directly + label = new Label(arg.value()); + USED_LABELS.add(label); + } else { + // Check if it's a struct variable or a plain label + StructInstance instance = STRUCT_INSTANCES.get(arg.value()); + if (instance != null) { + label = instance.getLabel().orElseThrow(() -> + new IllegalStateException("Struct instance " + arg.value() + " has no label") + ); + } else { + // Treat as a label name + label = new Label(arg.value()); + USED_LABELS.add(label); + } + } + + return new LabelAccess( + List.of(label), + SideQualifier.NULL, + NumberRangeSet.MAX_RANGE, + RoundRobin.disabled(), + null + ); + } + } + + // ===== END MACRO DEFINITIONS ===== + @Override public ASTNode visitTimerTrigger(SFMLParser.TimerTriggerContext ctx) { // create timer trigger @@ -333,9 +1101,13 @@ public OutputStatement visitOutputStatement(SFMLParser.OutputStatementContext ct return outputStatement; } - @Override public LabelAccess visitLabelAccess(SFMLParser.LabelAccessContext ctx) { + // Delegate to the appropriate alternative visitor + return (LabelAccess) visit(ctx); + } + @Override + public LabelAccess visitDirectLabelAccess(SFMLParser.DirectLabelAccessContext ctx) { var directionQualifierCtx = ctx.sidequalifier(); SideQualifier sideQualifier; if (directionQualifierCtx == null) { @@ -347,7 +1119,68 @@ public LabelAccess visitLabelAccess(SFMLParser.LabelAccessContext ctx) { ctx.label().stream().map(this::visit).map(Label.class::cast).collect(Collectors.toList()), sideQualifier, visitSlotqualifier(ctx.slotqualifier()), - visitRoundrobin(ctx.roundrobin()) + visitRoundrobin(ctx.roundrobin()), + null // No struct access for direct label access + ); + trackNode(labelAccess, ctx); + return labelAccess; + } + + @Override + public LabelAccess visitStructLabelAccess(SFMLParser.StructLabelAccessContext ctx) { + String variableName = ctx.identifier(0).getText(); + String fieldName = ctx.identifier(1).getText(); + + // Validate that the variable exists + StructInstance instance = STRUCT_INSTANCES.get(variableName); + if (instance == null) { + throw new IllegalArgumentException("Unknown struct variable: " + variableName); + } + + // Validate that the field exists + Optional fieldValue = instance.resolveField(fieldName); + if (fieldValue.isEmpty()) { + throw new IllegalArgumentException( + "Unknown field '" + fieldName + "' in struct variable " + variableName + ); + } + + // Get the label from the struct instance + Label label = instance.getLabel().orElseThrow(() -> + new IllegalStateException("Struct instance " + variableName + " has no label") + ); + + // Resolve sides and slots from the field value + SideQualifier sides = SideQualifier.NULL; + NumberRangeSet slots = NumberRangeSet.MAX_RANGE; + + StructFieldValue resolvedField = fieldValue.get(); + if (resolvedField instanceof CompositeFieldValue composite) { + sides = composite.sides(); + slots = composite.slots(); + } else if (resolvedField instanceof SideQualifier sq) { + sides = sq; + } else if (resolvedField instanceof NumberRangeSet nrs) { + slots = nrs; + } + + // Allow explicit side/slot overrides in the USING clause + if (ctx.sidequalifier() != null) { + sides = (SideQualifier) visit(ctx.sidequalifier()); + } + if (ctx.slotqualifier() != null) { + slots = visitSlotqualifier(ctx.slotqualifier()); + } + + StructAccess structAccess = new StructAccess(variableName, fieldName); + trackNode(structAccess, ctx); + + LabelAccess labelAccess = new LabelAccess( + List.of(label), + sides, + slots, + RoundRobin.disabled(), + structAccess ); trackNode(labelAccess, ctx); return labelAccess; @@ -535,37 +1368,17 @@ public ResourceIdSet visitResourceExclusion(@Nullable SFMLParser.ResourceExclusi } /// This one uses COMMA instead of OR to separate items - @SuppressWarnings("DuplicatedCode") @Override public ResourceIdSet visitResourceIdList(@Nullable SFMLParser.ResourceIdListContext ctx) { - if (ctx == null) return ResourceIdSet.EMPTY; - HashSet> ids = ctx - .resourceId() - .stream() - .map(this::visit) - .map(ResourceIdentifier.class::cast) - .collect(HashSet::new, HashSet::add, HashSet::addAll); - ResourceIdSet resourceIdSet = new ResourceIdSet(ids); - trackNode(resourceIdSet, ctx); - return resourceIdSet; + return buildResourceIdSet(ctx.resourceId(), ctx); } /// This one uses OR instead of COMMA to separate items - @SuppressWarnings("DuplicatedCode") @Override public ResourceIdSet visitResourceIdDisjunction(@Nullable SFMLParser.ResourceIdDisjunctionContext ctx) { - if (ctx == null) return ResourceIdSet.EMPTY; - HashSet> ids = ctx - .resourceId() - .stream() - .map(this::visit) - .map(ResourceIdentifier.class::cast) - .collect(HashSet::new, HashSet::add, HashSet::addAll); - ResourceIdSet resourceIdSet = new ResourceIdSet(ids); - trackNode(resourceIdSet, ctx); - return resourceIdSet; + return buildResourceIdSet(ctx.resourceId(), ctx); } @Override diff --git a/src/main/java/ca/teamdman/sfml/ast/CompositeFieldValue.java b/src/main/java/ca/teamdman/sfml/ast/CompositeFieldValue.java new file mode 100644 index 000000000..f06e428c0 --- /dev/null +++ b/src/main/java/ca/teamdman/sfml/ast/CompositeFieldValue.java @@ -0,0 +1,27 @@ +package ca.teamdman.sfml.ast; + +/** + * Represents a composite struct field value that combines side and slot qualifiers. + * Example: TOP SIDE SLOTS 0 + */ +public record CompositeFieldValue( + SideQualifier sides, + NumberRangeSet slots +) implements StructFieldValue { + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + if (!sides.equals(SideQualifier.NULL)) { + sb.append(sides.sides().stream() + .map(Side::toString) + .reduce((a, b) -> a + ", " + b) + .orElse("")); + sb.append(" SIDE"); + } + if (!slots.equals(NumberRangeSet.MAX_RANGE)) { + if (!sb.isEmpty()) sb.append(" "); + sb.append("SLOTS ").append(slots); + } + return sb.toString(); + } +} diff --git a/src/main/java/ca/teamdman/sfml/ast/ExpandArgument.java b/src/main/java/ca/teamdman/sfml/ast/ExpandArgument.java new file mode 100644 index 000000000..f25f2202f --- /dev/null +++ b/src/main/java/ca/teamdman/sfml/ast/ExpandArgument.java @@ -0,0 +1,33 @@ +package ca.teamdman.sfml.ast; + +/** + * Represents an argument in an expand statement. + * Can be either an identifier (struct variable or label) or a string literal. + */ +public record ExpandArgument( + String value, + boolean isStringLiteral +) implements ASTNode { + + /** + * Creates an identifier argument. + */ + public static ExpandArgument identifier(String value) { + return new ExpandArgument(value, false); + } + + /** + * Creates a string literal argument. + */ + public static ExpandArgument stringLiteral(String value) { + return new ExpandArgument(value, true); + } + + @Override + public String toString() { + if (isStringLiteral) { + return "\"" + value + "\""; + } + return value; + } +} diff --git a/src/main/java/ca/teamdman/sfml/ast/ExpandStatement.java b/src/main/java/ca/teamdman/sfml/ast/ExpandStatement.java new file mode 100644 index 000000000..f91d9e9fc --- /dev/null +++ b/src/main/java/ca/teamdman/sfml/ast/ExpandStatement.java @@ -0,0 +1,49 @@ +package ca.teamdman.sfml.ast; + +import ca.teamdman.sfm.common.program.ProgramContext; + +import java.util.List; + +/** + * Represents an expand statement that invokes a macro. + * Example: {@code expand smelt(furnace, ore_chest, ingot_chest)} + *

+ * Macros are expanded at compile time (during AST building). The {@code expandedStatements} + * field contains the result of macro expansion - concrete InputStatement, OutputStatement, + * ForgetStatement, and IfStatement instances with parameters substituted. + * + * @param macroName The name of the macro being expanded + * @param arguments The arguments passed to the macro + * @param expandedStatements The statements produced by macro expansion (populated at compile time) + */ +public record ExpandStatement( + String macroName, + List arguments, + List expandedStatements +) implements Statement { + + @Override + public void tick(ProgramContext context) { + // Execute the expanded statements + for (Statement statement : expandedStatements) { + statement.tick(context); + } + } + + @Override + public List getStatements() { + return expandedStatements; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder("expand "); + sb.append(macroName).append("("); + for (int i = 0; i < arguments.size(); i++) { + if (i > 0) sb.append(", "); + sb.append(arguments.get(i)); + } + sb.append(")"); + return sb.toString(); + } +} diff --git a/src/main/java/ca/teamdman/sfml/ast/Label.java b/src/main/java/ca/teamdman/sfml/ast/Label.java index c00b793e2..e5c4f89bd 100644 --- a/src/main/java/ca/teamdman/sfml/ast/Label.java +++ b/src/main/java/ca/teamdman/sfml/ast/Label.java @@ -1,6 +1,6 @@ package ca.teamdman.sfml.ast; -public record Label(String name) implements ASTNode { +public record Label(String name) implements StructFieldValue { @Override public String toString() { return name; diff --git a/src/main/java/ca/teamdman/sfml/ast/LabelAccess.java b/src/main/java/ca/teamdman/sfml/ast/LabelAccess.java index c4632233d..2cefd0b11 100644 --- a/src/main/java/ca/teamdman/sfml/ast/LabelAccess.java +++ b/src/main/java/ca/teamdman/sfml/ast/LabelAccess.java @@ -3,6 +3,7 @@ import ca.teamdman.sfm.common.label.LabelPositionHolder; import com.mojang.datafixers.util.Pair; import net.minecraft.core.BlockPos; +import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; @@ -13,15 +14,43 @@ public record LabelAccess( List