diff --git a/platform/minecraft/src/datagen/java/ca/teamdman/sfm/datagen/SFMBlockStatesAndModelsDatagen.java b/platform/minecraft/src/datagen/java/ca/teamdman/sfm/datagen/SFMBlockStatesAndModelsDatagen.java index e01a91d8e..58b1cf2ad 100644 --- a/platform/minecraft/src/datagen/java/ca/teamdman/sfm/datagen/SFMBlockStatesAndModelsDatagen.java +++ b/platform/minecraft/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/platform/minecraft/src/datagen/java/ca/teamdman/sfm/datagen/SFMItemModelsDatagen.java b/platform/minecraft/src/datagen/java/ca/teamdman/sfm/datagen/SFMItemModelsDatagen.java index 755d59461..462dfe40d 100644 --- a/platform/minecraft/src/datagen/java/ca/teamdman/sfm/datagen/SFMItemModelsDatagen.java +++ b/platform/minecraft/src/datagen/java/ca/teamdman/sfm/datagen/SFMItemModelsDatagen.java @@ -39,6 +39,9 @@ protected void registerModels() { justParent(SFMItems.PRINTING_PRESS, SFMBlocks.PRINTING_PRESS); justParent(SFMItems.WATER_TANK, SFMBlocks.WATER_TANK, "_active"); justParent(SFMItems.BUFFER, SFMBlocks.BUFFER_BLOCK, "_item"); + if (SFMItems.LIBRARY_ITEM != null) { + justParent(SFMItems.LIBRARY_ITEM, SFMBlocks.LIBRARY_BLOCK); + } basicItem(SFMItems.DISK); basicItem(SFMItems.LABEL_GUN); basicItem(SFMItems.EXPERIENCE_GOOP); diff --git a/platform/minecraft/src/datagen/java/ca/teamdman/sfm/datagen/SFMLootTablesDatagen.java b/platform/minecraft/src/datagen/java/ca/teamdman/sfm/datagen/SFMLootTablesDatagen.java index eab71d01e..a478f90ad 100644 --- a/platform/minecraft/src/datagen/java/ca/teamdman/sfm/datagen/SFMLootTablesDatagen.java +++ b/platform/minecraft/src/datagen/java/ca/teamdman/sfm/datagen/SFMLootTablesDatagen.java @@ -40,6 +40,9 @@ protected void populate(BlockLootWriter writer) { writer.dropOther(SFMBlocks.FANCY_CABLE_FACADE, SFMBlocks.FANCY_CABLE); writer.dropSelf(SFMBlocks.PRINTING_PRESS); writer.dropSelf(SFMBlocks.WATER_TANK); + if (SFMBlocks.LIBRARY_BLOCK != null) { + writer.dropSelf(SFMBlocks.LIBRARY_BLOCK); + } } @Override diff --git a/platform/minecraft/src/datagen/java/ca/teamdman/sfm/datagen/SFMRecipesDatagen.java b/platform/minecraft/src/datagen/java/ca/teamdman/sfm/datagen/SFMRecipesDatagen.java index d8e5e55f1..e35fdd4c1 100644 --- a/platform/minecraft/src/datagen/java/ca/teamdman/sfm/datagen/SFMRecipesDatagen.java +++ b/platform/minecraft/src/datagen/java/ca/teamdman/sfm/datagen/SFMRecipesDatagen.java @@ -5,6 +5,7 @@ import ca.teamdman.sfm.common.registry.registration.SFMBlocks; import ca.teamdman.sfm.common.registry.registration.SFMItems; import ca.teamdman.sfm.common.registry.registration.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; @@ -244,6 +245,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.get()) + .define('B', Blocks.BOOKSHELF) + .define('L', Blocks.LECTERN) + .unlockedBy("has_manager", RecipeProvider.has(SFMBlocks.MANAGER.get())) + .pattern("MBM") + .pattern("BLB") + .pattern("MBM") + .save(writer); + } + addPrintingPressRecipe( writer, SFMResourceLocation.fromSFMPath("written_book_copy"), diff --git a/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/SFMGameTestGenerator.java b/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/SFMGameTestGenerator.java index 045814e07..db5f0198a 100644 --- a/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/SFMGameTestGenerator.java +++ b/platform/minecraft/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/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/SFMGameTestGeneratorBase.java b/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/SFMGameTestGeneratorBase.java index 23bb471e9..742b20a1b 100644 --- a/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/SFMGameTestGeneratorBase.java +++ b/platform/minecraft/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/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/SFMGameTestHelper.java b/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/SFMGameTestHelper.java index 21e7bbaaa..155ba55b8 100644 --- a/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/SFMGameTestHelper.java +++ b/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/SFMGameTestHelper.java @@ -310,7 +310,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/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/declarative/SFMDeclarativeTestBuilder.java b/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/declarative/SFMDeclarativeTestBuilder.java index 91eb4a1bf..873cda901 100644 --- a/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/declarative/SFMDeclarativeTestBuilder.java +++ b/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/declarative/SFMDeclarativeTestBuilder.java @@ -105,7 +105,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/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryChainedImportsGameTest.java b/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryChainedImportsGameTest.java new file mode 100644 index 000000000..4dd1f14b5 --- /dev/null +++ b/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryChainedImportsGameTest.java @@ -0,0 +1,216 @@ +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.registration.SFMBlocks; +import ca.teamdman.sfm.common.registry.registration.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; + + +/** + * 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.get()); + helper.setBlock(baseLibPos, SFMBlocks.LIBRARY_BLOCK.get()); + helper.setBlock(structLibPos, SFMBlocks.LIBRARY_BLOCK.get()); + helper.setBlock(managerPos, SFMBlocks.MANAGER.get()); + helper.setBlock(macroLibPos, SFMBlocks.LIBRARY_BLOCK.get()); + helper.setBlock(furnacePos, SFMBlocks.TEST_BARREL.get()); + helper.setBlock(ingotsPos, SFMBlocks.TEST_BARREL.get()); + + // Place cable row behind + helper.setBlock(cable0Pos, SFMBlocks.CABLE.get()); + helper.setBlock(cable1Pos, SFMBlocks.CABLE.get()); + helper.setBlock(cable2Pos, SFMBlocks.CABLE.get()); + helper.setBlock(cable3Pos, SFMBlocks.CABLE.get()); + helper.setBlock(cable4Pos, SFMBlocks.CABLE.get()); + helper.setBlock(cable5Pos, SFMBlocks.CABLE.get()); + helper.setBlock(cable6Pos, SFMBlocks.CABLE.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.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.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.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.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 + helper.assertTrue( + DiskItem.getErrors(baseLib.getItem(0)).isEmpty(), + "base_protocols library should have no errors but had: " + DiskItem.getErrors(baseLib.getItem(0)) + ); + helper.assertTrue( + DiskItem.getErrors(structLib.getItem(0)).isEmpty(), + "struct_lib library should have no errors but had: " + DiskItem.getErrors(structLib.getItem(0)) + ); + helper.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 + helper.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 + helper.assertTrue( + helper.count(oreHandler, Blocks.GOLD_ORE) == 0, + "Ore chest should be empty but has " + helper.count(oreHandler, Blocks.GOLD_ORE) + " gold ore" + ); + helper.assertTrue( + helper.count(furnaceHandler, Blocks.GOLD_ORE) == 32, + "Furnace should have 32 gold ore in input slots but has " + helper.count(furnaceHandler, Blocks.GOLD_ORE) + ); + + // Verify ingots were moved from furnace output to ingot chest + var ingotsHandler = helper.getItemHandler(ingotsPos); + helper.assertTrue( + helper.count(ingotsHandler, Blocks.GOLD_BLOCK) == 4, + "Ingot chest should have 4 gold blocks but has " + helper.count(ingotsHandler, Blocks.GOLD_BLOCK) + ); + helper.assertTrue( + helper.count(furnaceHandler, Blocks.GOLD_BLOCK) == 0, + "Furnace output should be empty but has " + helper.count(furnaceHandler, Blocks.GOLD_BLOCK) + ); + }); + }); + } +} diff --git a/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryProtocolConstraintValidationGameTest.java b/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryProtocolConstraintValidationGameTest.java new file mode 100644 index 000000000..4eeb2f684 --- /dev/null +++ b/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryProtocolConstraintValidationGameTest.java @@ -0,0 +1,147 @@ +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.registration.SFMBlocks; +import ca.teamdman.sfm.common.registry.registration.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; + + +/** + * 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.get()); + + // Place cable row behind + helper.setBlock(cable0Pos, SFMBlocks.CABLE.get()); + helper.setBlock(cable1Pos, SFMBlocks.CABLE.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.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); + helper.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.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, () -> { + helper.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, () -> { + helper.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/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryRecompileOnCableRemovalGameTest.java b/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryRecompileOnCableRemovalGameTest.java new file mode 100644 index 000000000..b9696e5f7 --- /dev/null +++ b/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryRecompileOnCableRemovalGameTest.java @@ -0,0 +1,110 @@ +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.registration.SFMBlocks; +import ca.teamdman.sfm.common.registry.registration.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; + + +/** + * 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.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.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.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); + helper.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); + helper.assertTrue( + !DiskItem.getErrors(diskAfterDisconnect).isEmpty(), + "Consumer disk should have errors after disconnection (library not found)" + ); + + helper.succeed(); + }); + }); + } +} diff --git a/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryWithCombinedIOProtocolGameTest.java b/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryWithCombinedIOProtocolGameTest.java new file mode 100644 index 000000000..3f051d9a3 --- /dev/null +++ b/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryWithCombinedIOProtocolGameTest.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.registration.SFMBlocks; +import ca.teamdman.sfm.common.registry.registration.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; + + +/** + * 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.get()); + helper.setBlock(libraryPos, SFMBlocks.LIBRARY_BLOCK.get()); + helper.setBlock(managerPos, SFMBlocks.MANAGER.get()); + helper.setBlock(machinePos, SFMBlocks.TEST_BARREL.get()); + helper.setBlock(destPos, SFMBlocks.TEST_BARREL.get()); + + // Place cable row behind + helper.setBlock(cable0Pos, SFMBlocks.CABLE.get()); + helper.setBlock(cable1Pos, SFMBlocks.CABLE.get()); + helper.setBlock(cable2Pos, SFMBlocks.CABLE.get()); + helper.setBlock(cable3Pos, SFMBlocks.CABLE.get()); + helper.setBlock(cable4Pos, SFMBlocks.CABLE.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.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.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); + helper.assertTrue( + DiskItem.getErrors(compiledLibraryDisk).isEmpty(), + "Library disk should have no errors but had: " + DiskItem.getErrors(compiledLibraryDisk) + ); + + // Verify manager disk has no errors + helper.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 + helper.assertTrue( + helper.count(sourceHandler, Blocks.COAL_ORE) == 0, + "Source should be empty but has " + helper.count(sourceHandler, Blocks.COAL_ORE) + " coal ore" + ); + helper.assertTrue( + helper.count(machineHandler, Blocks.COAL_ORE) == 32, + "Machine should have 32 coal ore in input slots but has " + helper.count(machineHandler, Blocks.COAL_ORE) + ); + + // Verify items were moved from machine's output slots to dest + var destHandler = helper.getItemHandler(destPos); + helper.assertTrue( + helper.count(destHandler, Blocks.COAL_BLOCK) == 4, + "Dest should have 4 coal blocks but has " + helper.count(destHandler, Blocks.COAL_BLOCK) + ); + helper.assertTrue( + helper.count(machineHandler, Blocks.COAL_BLOCK) == 0, + "Machine output slots should be empty but has " + helper.count(machineHandler, Blocks.COAL_BLOCK) + ); + }); + }); + } +} diff --git a/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryWithStructIOProtocolsGameTest.java b/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryWithStructIOProtocolsGameTest.java new file mode 100644 index 000000000..be9e490d6 --- /dev/null +++ b/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryWithStructIOProtocolsGameTest.java @@ -0,0 +1,174 @@ +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.registration.SFMBlocks; +import ca.teamdman.sfm.common.registry.registration.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; + + +/** + * 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.get()); + helper.setBlock(libraryPos, SFMBlocks.LIBRARY_BLOCK.get()); + helper.setBlock(managerPos, SFMBlocks.MANAGER.get()); + helper.setBlock(devicePos, SFMBlocks.TEST_BARREL.get()); + helper.setBlock(destPos, SFMBlocks.TEST_BARREL.get()); + + // Place cable row behind + helper.setBlock(cable0Pos, SFMBlocks.CABLE.get()); + helper.setBlock(cable1Pos, SFMBlocks.CABLE.get()); + helper.setBlock(cable2Pos, SFMBlocks.CABLE.get()); + helper.setBlock(cable3Pos, SFMBlocks.CABLE.get()); + helper.setBlock(cable4Pos, SFMBlocks.CABLE.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.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.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); + helper.assertTrue( + DiskItem.getErrors(compiledLibraryDisk).isEmpty(), + "Library disk should have no errors but had: " + DiskItem.getErrors(compiledLibraryDisk) + ); + + // Verify manager disk has no errors + helper.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 + helper.assertTrue( + helper.count(sourceHandler, Blocks.DIRT) == 0, + "Source should be empty but has " + helper.count(sourceHandler, Blocks.DIRT) + " dirt" + ); + helper.assertTrue( + helper.count(deviceHandler, Blocks.DIRT) == 32, + "Device should have 32 dirt in input slots but has " + helper.count(deviceHandler, Blocks.DIRT) + ); + + // Verify items were moved from device's output slots to dest + var destHandler = helper.getItemHandler(destPos); + helper.assertTrue( + helper.count(destHandler, Blocks.STONE) == 16, + "Dest should have 16 stone but has " + helper.count(destHandler, Blocks.STONE) + ); + }); + }); + } +} diff --git a/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryWithTransferMacroGameTest.java b/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryWithTransferMacroGameTest.java new file mode 100644 index 000000000..7d56750d4 --- /dev/null +++ b/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/tests/library/LibraryWithTransferMacroGameTest.java @@ -0,0 +1,179 @@ +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.registration.SFMBlocks; +import ca.teamdman.sfm.common.registry.registration.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; + + +/** + * 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.get()); + helper.setBlock(libraryPos, SFMBlocks.LIBRARY_BLOCK.get()); + helper.setBlock(managerPos, SFMBlocks.MANAGER.get()); + helper.setBlock(processorPos, SFMBlocks.TEST_BARREL.get()); + helper.setBlock(outputPos, SFMBlocks.TEST_BARREL.get()); + + // Place cable row behind + helper.setBlock(cable0Pos, SFMBlocks.CABLE.get()); + helper.setBlock(cable1Pos, SFMBlocks.CABLE.get()); + helper.setBlock(cable2Pos, SFMBlocks.CABLE.get()); + helper.setBlock(cable3Pos, SFMBlocks.CABLE.get()); + helper.setBlock(cable4Pos, SFMBlocks.CABLE.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.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.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); + helper.assertTrue( + DiskItem.getErrors(compiledLibraryDisk).isEmpty(), + "Library disk should have no errors but had: " + DiskItem.getErrors(compiledLibraryDisk) + ); + + // Verify manager disk has no errors + helper.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 + helper.assertTrue( + helper.count(inputHandler, Blocks.IRON_ORE) == 0, + "Input should be empty but has " + helper.count(inputHandler, Blocks.IRON_ORE) + " iron ore" + ); + helper.assertTrue( + helper.count(processorHandler, Blocks.IRON_ORE) == 64, + "Processor should have 64 iron ore in input slots but has " + helper.count(processorHandler, Blocks.IRON_ORE) + ); + + // Verify items were moved from processor's output slots to output + var outputHandler = helper.getItemHandler(outputPos); + helper.assertTrue( + helper.count(outputHandler, Blocks.IRON_BLOCK) == 8, + "Output should have 8 iron blocks but has " + helper.count(outputHandler, Blocks.IRON_BLOCK) + ); + helper.assertTrue( + helper.count(processorHandler, Blocks.IRON_BLOCK) == 0, + "Processor output slots should be empty but has " + helper.count(processorHandler, Blocks.IRON_BLOCK) + ); + }); + }); + } +} diff --git a/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/tests/struct/MacroBasicExpansionGameTest.java b/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/tests/struct/MacroBasicExpansionGameTest.java new file mode 100644 index 000000000..9f626695f --- /dev/null +++ b/platform/minecraft/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/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/tests/struct/MacroWithStructAccessGameTest.java b/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/tests/struct/MacroWithStructAccessGameTest.java new file mode 100644 index 000000000..ef026e0cc --- /dev/null +++ b/platform/minecraft/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/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/tests/struct/package-info.java b/platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/tests/struct/package-info.java new file mode 100644 index 000000000..62183791a --- /dev/null +++ b/platform/minecraft/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/platform/minecraft/src/generated/resources/.cache/67cce32b1c3cbbcb1f646605f4914e3f196986c2 b/platform/minecraft/src/generated/resources/.cache/67cce32b1c3cbbcb1f646605f4914e3f196986c2 index 95645b511..09f434055 100644 --- a/platform/minecraft/src/generated/resources/.cache/67cce32b1c3cbbcb1f646605f4914e3f196986c2 +++ b/platform/minecraft/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/platform/minecraft/src/generated/resources/.cache/726a4eaaa226cb50028c5f3f85d691417b67903b b/platform/minecraft/src/generated/resources/.cache/726a4eaaa226cb50028c5f3f85d691417b67903b index d788cf77e..8885ed43c 100644 --- a/platform/minecraft/src/generated/resources/.cache/726a4eaaa226cb50028c5f3f85d691417b67903b +++ b/platform/minecraft/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/platform/minecraft/src/generated/resources/.cache/bf0ffec7956ce36aba0a993095f41e47d655813b b/platform/minecraft/src/generated/resources/.cache/bf0ffec7956ce36aba0a993095f41e47d655813b index 49fa9a0a6..89ebcf5a2 100644 --- a/platform/minecraft/src/generated/resources/.cache/bf0ffec7956ce36aba0a993095f41e47d655813b +++ b/platform/minecraft/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/platform/minecraft/src/generated/resources/assets/sfm/blockstates/library.json b/platform/minecraft/src/generated/resources/assets/sfm/blockstates/library.json new file mode 100644 index 000000000..660d5f632 --- /dev/null +++ b/platform/minecraft/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/platform/minecraft/src/generated/resources/assets/sfm/lang/en_us.json b/platform/minecraft/src/generated/resources/assets/sfm/lang/en_us.json index a353d5dfb..6a9c0e274 100644 --- a/platform/minecraft/src/generated/resources/assets/sfm/lang/en_us.json +++ b/platform/minecraft/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.", @@ -30,6 +31,7 @@ "chat.sfm.config_update_and_sync_result.success": "Successfully updated SFM config.", "chat.sfm.manager.cable_network_rebuild.failed_fallback": "Failed to rebuild cable network at manager %s; purged all cable networks instead", "chat.sfm.manager.cable_network_rebuild.success": "Rebuilt cable network at manager %s", + "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", @@ -257,6 +259,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\"", @@ -280,6 +284,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/platform/minecraft/src/generated/resources/assets/sfm/models/block/library.json b/platform/minecraft/src/generated/resources/assets/sfm/models/block/library.json new file mode 100644 index 000000000..95abadc4a --- /dev/null +++ b/platform/minecraft/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/platform/minecraft/src/generated/resources/assets/sfm/models/item/library.json b/platform/minecraft/src/generated/resources/assets/sfm/models/item/library.json new file mode 100644 index 000000000..ac40f054b --- /dev/null +++ b/platform/minecraft/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/platform/minecraft/src/generated/resources/data/sfm/advancements/recipes/sfm/library.json b/platform/minecraft/src/generated/resources/data/sfm/advancements/recipes/sfm/library.json new file mode 100644 index 000000000..fa8e2c604 --- /dev/null +++ b/platform/minecraft/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/platform/minecraft/src/generated/resources/data/sfm/loot_tables/blocks/library.json b/platform/minecraft/src/generated/resources/data/sfm/loot_tables/blocks/library.json new file mode 100644 index 000000000..9bae64f51 --- /dev/null +++ b/platform/minecraft/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/platform/minecraft/src/generated/resources/data/sfm/recipes/library.json b/platform/minecraft/src/generated/resources/data/sfm/recipes/library.json new file mode 100644 index 000000000..5c3c47ab1 --- /dev/null +++ b/platform/minecraft/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/platform/minecraft/src/main/antlr/sfml/SFML.g4 b/platform/minecraft/src/main/antlr/sfml/SFML.g4 index 3c2e70f78..b6d0854aa 100644 --- a/platform/minecraft/src/main/antlr/sfml/SFML.g4 +++ b/platform/minecraft/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/platform/minecraft/src/main/java/ca/teamdman/sfm/client/registry/SFMBlockEntityRenderers.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/client/registry/SFMBlockEntityRenderers.java index a6ce912ea..3f540fd66 100644 --- a/platform/minecraft/src/main/java/ca/teamdman/sfm/client/registry/SFMBlockEntityRenderers.java +++ b/platform/minecraft/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.registration.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.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.registration.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.get(), + PrintingPressBlockEntityRenderer::new + ); + if (SFMBlockEntities.LIBRARY_BLOCK_ENTITY != null) { + event.registerBlockEntityRenderer( + SFMBlockEntities.LIBRARY_BLOCK_ENTITY.get(), + LibraryBlockEntityRenderer::new + ); + } + } +} diff --git a/platform/minecraft/src/main/java/ca/teamdman/sfm/client/registry/SFMMenuScreens.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/client/registry/SFMMenuScreens.java index 85d108feb..2c2918df4 100644 --- a/platform/minecraft/src/main/java/ca/teamdman/sfm/client/registry/SFMMenuScreens.java +++ b/platform/minecraft/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.registration.SFMMenus; @@ -9,5 +10,6 @@ public class SFMMenuScreens { public static void register() { MenuScreens.register(SFMMenus.MANAGER.get(), ManagerScreen::new); MenuScreens.register(SFMMenus.TEST_BARREL_TANK.get(), TestBarrelTankScreen::new); + MenuScreens.register(SFMMenus.LIBRARY_MENU.get(), LibraryScreen::new); } } diff --git a/platform/minecraft/src/main/java/ca/teamdman/sfm/client/render/LibraryBlockEntityRenderer.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/client/render/LibraryBlockEntityRenderer.java new file mode 100644 index 000000000..e97bad483 --- /dev/null +++ b/platform/minecraft/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/platform/minecraft/src/main/java/ca/teamdman/sfm/client/render/RetexturedBakedQuad.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/client/render/RetexturedBakedQuad.java index ae72efd6a..0c47c8384 100644 --- a/platform/minecraft/src/main/java/ca/teamdman/sfm/client/render/RetexturedBakedQuad.java +++ b/platform/minecraft/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/platform/minecraft/src/main/java/ca/teamdman/sfm/client/screen/LibraryScreen.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/client/screen/LibraryScreen.java new file mode 100644 index 000000000..57c20d262 --- /dev/null +++ b/platform/minecraft/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.registration.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/platform/minecraft/src/main/java/ca/teamdman/sfm/client/text_editor/SFMTextEditScreenLibraryDiskOpenContext.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/client/text_editor/SFMTextEditScreenLibraryDiskOpenContext.java new file mode 100644 index 000000000..925f3251f --- /dev/null +++ b/platform/minecraft/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/platform/minecraft/src/main/java/ca/teamdman/sfm/client/text_styling/ProgramSyntaxHighlightingHelper.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/client/text_styling/ProgramSyntaxHighlightingHelper.java index c5c2f702e..065c528ec 100644 --- a/platform/minecraft/src/main/java/ca/teamdman/sfm/client/text_styling/ProgramSyntaxHighlightingHelper.java +++ b/platform/minecraft/src/main/java/ca/teamdman/sfm/client/text_styling/ProgramSyntaxHighlightingHelper.java @@ -165,6 +165,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/platform/minecraft/src/main/java/ca/teamdman/sfm/common/block/LibraryBlock.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/block/LibraryBlock.java new file mode 100644 index 000000000..7037fc730 --- /dev/null +++ b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/block/LibraryBlock.java @@ -0,0 +1,166 @@ +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.localization.LocalizationEntry; +import ca.teamdman.sfm.common.localization.SFMLocalizationDatagen; +import ca.teamdman.sfm.common.registry.registration.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 { + + @SFMLocalizationDatagen + public static final LocalizationEntry LIBRARY_BLOCK = new LocalizationEntry( + "block.sfm.library", + "SFML Library Block" + ); + + /** + * 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 -> { + for (BlockPos managerPos : network.getOrRebuildAutoLabels() + .getPositions(ManagerBlockEntity.MANAGER_LABEL) + .blockPosIterator()) { + managersToNotify.add(managerPos.immutable()); + } + 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/platform/minecraft/src/main/java/ca/teamdman/sfm/common/block_network/CableNetwork.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/block_network/CableNetwork.java index 7c4ca6f91..92546a24e 100644 --- a/platform/minecraft/src/main/java/ca/teamdman/sfm/common/block_network/CableNetwork.java +++ b/platform/minecraft/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; @@ -15,7 +19,10 @@ import org.jetbrains.annotations.Nullable; import java.util.Comparator; +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. @@ -24,6 +31,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 @@ -161,6 +185,56 @@ public BlockPosIterator getCablePositions() { return members().positions(); } + /** + * 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 : getCablePositions()) { + // Check if the cable itself is a manager + if (level.getBlockEntity(cablePos) instanceof ManagerBlockEntity) { + autoLabelCache.add(ManagerBlockEntity.MANAGER_LABEL, cablePos.immutable()); + } + + // Check if the cable itself is a library (LibraryBlock implements ICableBlock) + if (level.getBlockEntity(cablePos) instanceof LibraryBlockEntity) { + autoLabelCache.add(LibraryBlockEntity.LIBRARY_LABEL, cablePos.immutable()); + } + + // 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; + } + public LongSet getCablePositionsRaw() { return members().keySet(); } @@ -186,9 +260,87 @@ public Stream getManagers() { 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) + BlockPosSet managerPositions = getOrRebuildAutoLabels() + .getPositions(ManagerBlockEntity.MANAGER_LABEL); + BlockPosSet libraryPositions = getOrRebuildAutoLabels() + .getPositions(LibraryBlockEntity.LIBRARY_LABEL); + + Level level = getLevel(); + + // Notify all managers to re-validate their programs + for (BlockPos pos : managerPositions.blockPosIterator()) { + if (level.getBlockEntity(pos) instanceof ManagerBlockEntity manager) { + manager.rebuildProgramAndUpdateDisk(); + } + } + + // Notify all library blocks to recompile their disks + for (BlockPos pos : libraryPositions.blockPosIterator()) { + 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) { @@ -214,6 +366,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 + BlockPosSet 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.blockPosIterator()) { + 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/platform/minecraft/src/main/java/ca/teamdman/sfm/common/block_network/CableNetworkManager.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/block_network/CableNetworkManager.java index 5a9192765..6294b6cc7 100644 --- a/platform/minecraft/src/main/java/ca/teamdman/sfm/common/block_network/CableNetworkManager.java +++ b/platform/minecraft/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; @@ -58,14 +68,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) { @@ -108,6 +172,7 @@ public static List getBadCableCachePositions(Level level) { public static void clear() { NETWORK_MANAGER.clear(); + NETWORKS_WITH_PENDING_NOTIFICATIONS.clear(); } @SFMSubscribeEvent @@ -122,5 +187,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/platform/minecraft/src/main/java/ca/teamdman/sfm/common/blockentity/LibraryBlockEntity.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/blockentity/LibraryBlockEntity.java new file mode 100644 index 000000000..57bbbf7ce --- /dev/null +++ b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/blockentity/LibraryBlockEntity.java @@ -0,0 +1,487 @@ +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.LocalizationEntry; +import ca.teamdman.sfm.common.localization.SFMLocalizationDatagen; +import ca.teamdman.sfm.common.registry.registration.SFMBlockEntities; +import ca.teamdman.sfm.common.registry.registration.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 { + + @SFMLocalizationDatagen + public static final LocalizationEntry LIBRARY_CONTAINER = new LocalizationEntry( + "container.sfm.library", + "SFML Library" + ); + + @SFMLocalizationDatagen + 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." + ); + + @SFMLocalizationDatagen + public static final LocalizationEntry PROGRAM_ERROR_LIBRARY_NOT_FOUND = new LocalizationEntry( + "program.sfm.error.library_not_found", + "Library \"%s\" not found in cable network." + ); + + @SFMLocalizationDatagen + public static final LocalizationEntry PROGRAM_ERROR_LIBRARY_HAS_ERRORS = new LocalizationEntry( + "program.sfm.error.library_has_errors", + "Library \"%s\" has compilation errors." + ); + + /** + * 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.get()); + DiskItem.setProgram(disk, legacySource); + items.set(0, disk); + } + } else { + ContainerHelper.loadAllItems(tag, items); + } + } + + @Override + protected Component getDefaultName() { + return 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/platform/minecraft/src/main/java/ca/teamdman/sfm/common/blockentity/ManagerBlockEntity.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/blockentity/ManagerBlockEntity.java index 91749620b..337a2b189 100644 --- a/platform/minecraft/src/main/java/ca/teamdman/sfm/common/blockentity/ManagerBlockEntity.java +++ b/platform/minecraft/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; @SFMLocalizationDatagen @@ -409,16 +419,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/platform/minecraft/src/main/java/ca/teamdman/sfm/common/containermenu/LibraryContainerMenu.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/containermenu/LibraryContainerMenu.java new file mode 100644 index 000000000..a139891b9 --- /dev/null +++ b/platform/minecraft/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.registration.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/platform/minecraft/src/main/java/ca/teamdman/sfm/common/item/DiskItem.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/item/DiskItem.java index 03f3d1c93..caf869583 100644 --- a/platform/minecraft/src/main/java/ca/teamdman/sfm/common/item/DiskItem.java +++ b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/item/DiskItem.java @@ -17,6 +17,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; @@ -38,9 +39,19 @@ 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+\"([^\"\\\\]*(\\\\.[^\"\\\\]*)*)\""); + @SFMLocalizationDatagen public static final LocalizationEntry DISK_EDIT_IN_HAND_TOOLTIP = new LocalizationEntry( "gui.sfm.disk.tooltip.edit_in_hand", @@ -76,6 +87,31 @@ public DiskItem() { super(new Item.Properties().tab(SFMCreativeTabs.MAIN)); } + /** + * 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 @@ -117,14 +153,24 @@ public static void pruneIfDefault(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(PROGRAM_COMPILE_FROM_DISK_BEGIN.get())); } 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/platform/minecraft/src/main/java/ca/teamdman/sfm/common/net/ServerboundLibraryDiskSetProgramPacket.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/net/ServerboundLibraryDiskSetProgramPacket.java new file mode 100644 index 000000000..40256c06b --- /dev/null +++ b/platform/minecraft/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/platform/minecraft/src/main/java/ca/teamdman/sfm/common/program/linting/StructDefinitionLinter.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/program/linting/StructDefinitionLinter.java new file mode 100644 index 000000000..769be3f45 --- /dev/null +++ b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/program/linting/StructDefinitionLinter.java @@ -0,0 +1,66 @@ +package ca.teamdman.sfm.common.program.linting; + +import ca.teamdman.sfm.common.blockentity.ManagerBlockEntity; +import ca.teamdman.sfm.common.label.LabelPositionHolder; +import ca.teamdman.sfm.common.localization.LocalizationEntry; +import ca.teamdman.sfm.common.localization.SFMLocalizationDatagen; +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; + +/** + * Linter that validates struct definitions are actually used. + */ +public class StructDefinitionLinter implements IProgramLinter { + @SFMLocalizationDatagen + public static final LocalizationEntry PROGRAM_WARNING_UNUSED_STRUCT = new LocalizationEntry( + "program.sfm.warnings.unused_struct", + "Struct \"%s\" is defined but never instantiated." + ); + + @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/platform/minecraft/src/main/java/ca/teamdman/sfm/common/program/linting/UnusedDefinitionLinter.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/program/linting/UnusedDefinitionLinter.java new file mode 100644 index 000000000..fc53bba27 --- /dev/null +++ b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/program/linting/UnusedDefinitionLinter.java @@ -0,0 +1,186 @@ +package ca.teamdman.sfm.common.program.linting; + +import ca.teamdman.sfm.common.blockentity.ManagerBlockEntity; +import ca.teamdman.sfm.common.label.LabelPositionHolder; +import ca.teamdman.sfm.common.localization.LocalizationEntry; +import ca.teamdman.sfm.common.localization.SFMLocalizationDatagen; +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; + +/** + * 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 { + @SFMLocalizationDatagen + 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." + ); + + @SFMLocalizationDatagen + public static final LocalizationEntry PROGRAM_WARNING_UNUSED_PROTOCOL = new LocalizationEntry( + "program.sfm.warnings.unused_protocol", + "Protocol \"%s\" is defined but never used." + ); + + @SFMLocalizationDatagen + public static final LocalizationEntry PROGRAM_WARNING_UNUSED_MACRO = new LocalizationEntry( + "program.sfm.warnings.unused_macro", + "Macro \"%s\" is defined but never expanded." + ); + + @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/platform/minecraft/src/main/java/ca/teamdman/sfm/common/registry/registration/SFMBlockEntities.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/registry/registration/SFMBlockEntities.java index 8f934f6ab..452057fa3 100644 --- a/platform/minecraft/src/main/java/ca/teamdman/sfm/common/registry/registration/SFMBlockEntities.java +++ b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/registry/registration/SFMBlockEntities.java @@ -7,6 +7,7 @@ import ca.teamdman.sfm.common.registry.SFMDeferredRegisterBuilder; import ca.teamdman.sfm.common.registry.SFMRegistryObject; import ca.teamdman.sfm.common.registry.SFMWellKnownRegistries; +import ca.teamdman.sfm.common.util.SFMEnvironmentUtils; import net.minecraft.world.level.block.entity.BlockEntityType; import net.minecraftforge.eventbus.api.IEventBus; @@ -96,6 +97,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 = REGISTERER.register( "tunnelled_cable", diff --git a/platform/minecraft/src/main/java/ca/teamdman/sfm/common/registry/registration/SFMBlocks.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/registry/registration/SFMBlocks.java index ffae7e91e..bc83a8d2a 100644 --- a/platform/minecraft/src/main/java/ca/teamdman/sfm/common/registry/registration/SFMBlocks.java +++ b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/registry/registration/SFMBlocks.java @@ -6,6 +6,7 @@ import ca.teamdman.sfm.common.registry.SFMDeferredRegisterBuilder; import ca.teamdman.sfm.common.registry.SFMRegistryObject; import ca.teamdman.sfm.common.registry.SFMWellKnownRegistries; +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; @@ -102,6 +103,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 = REGISTERER.register( diff --git a/platform/minecraft/src/main/java/ca/teamdman/sfm/common/registry/registration/SFMItems.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/registry/registration/SFMItems.java index 12fe00b87..e22453279 100644 --- a/platform/minecraft/src/main/java/ca/teamdman/sfm/common/registry/registration/SFMItems.java +++ b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/registry/registration/SFMItems.java @@ -103,10 +103,14 @@ public class SFMItems { ); public static SFMRegistryObject BUFFER = null; + public static SFMRegistryObject LIBRARY_ITEM = null; static { if (SFMEnvironmentUtils.isInIDE()) { BUFFER = register("buffer", SFMBlocks.BUFFER_BLOCK); + if (SFMBlocks.LIBRARY_BLOCK != null) { + LIBRARY_ITEM = register("library", SFMBlocks.LIBRARY_BLOCK); + } } } diff --git a/platform/minecraft/src/main/java/ca/teamdman/sfm/common/registry/registration/SFMMenus.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/registry/registration/SFMMenus.java index 7c8863316..17dd3e1f1 100644 --- a/platform/minecraft/src/main/java/ca/teamdman/sfm/common/registry/registration/SFMMenus.java +++ b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/registry/registration/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.registry.SFMDeferredRegister; @@ -107,6 +109,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/platform/minecraft/src/main/java/ca/teamdman/sfm/common/registry/registration/SFMPackets.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/registry/registration/SFMPackets.java index 95fff5038..2fc8ff6d9 100644 --- a/platform/minecraft/src/main/java/ca/teamdman/sfm/common/registry/registration/SFMPackets.java +++ b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/registry/registration/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/platform/minecraft/src/main/java/ca/teamdman/sfm/common/registry/registration/SFMProgramLinters.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/registry/registration/SFMProgramLinters.java index 5b416b3cd..9616b77fc 100644 --- a/platform/minecraft/src/main/java/ca/teamdman/sfm/common/registry/registration/SFMProgramLinters.java +++ b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/registry/registration/SFMProgramLinters.java @@ -73,6 +73,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/platform/minecraft/src/main/java/ca/teamdman/sfm/common/util/BlockPosMap.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/util/BlockPosMap.java index 3fb265294..bc8138aaf 100644 --- a/platform/minecraft/src/main/java/ca/teamdman/sfm/common/util/BlockPosMap.java +++ b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/util/BlockPosMap.java @@ -1,108 +1,108 @@ -package ca.teamdman.sfm.common.util; - -import it.unimi.dsi.fastutil.longs.Long2ObjectMap; -import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; -import net.minecraft.core.BlockPos; -import org.jetbrains.annotations.Nullable; - -import java.util.Map; - -public final class BlockPosMap extends Long2ObjectOpenHashMap { - public BlockPosMap( - int expected, - float f - ) { - - super(expected, f); - } - - public BlockPosMap(int expected) { - - super(expected); - } - - public BlockPosMap() { - - } - - public BlockPosMap( - Map m, - float f - ) { - - super(m, f); - } - - public BlockPosMap(Map m) { - - super(m); - } - - public BlockPosMap( - Long2ObjectMap m, - float f - ) { - - super(m, f); - } - - public BlockPosMap(Long2ObjectMap m) { - - super(m); - } - - public BlockPosMap( - long[] k, - T[] v, - float f - ) { - - super(k, v, f); - } - - public BlockPosMap( - long[] k, - T[] v - ) { - - super(k, v); - } - - @Override - public @Nullable T get(long k) { - - return super.get(k); - } - - public @Nullable T put( - BlockPos blockPos, - T member - ) { - - return put(blockPos.asLong(), member); - } - - public boolean containsKey(BlockPos blockPos) { - - return containsKey(blockPos.asLong()); - } - - public BlockPosIterator positions() { - - return new BlockPosIterator(keySet().longIterator()); - } - - public void removeAllPositions(BlockPosSet positionsInChunk) { - keySet().removeAll(positionsInChunk); - } - - public @Nullable T removePosition(BlockPos blockPos) { - - return remove(blockPos.asLong()); - } - - public @Nullable T getFromPosition(BlockPos blockPos) { - - return get(blockPos.asLong()); - } -} +package ca.teamdman.sfm.common.util; + +import it.unimi.dsi.fastutil.longs.Long2ObjectMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import net.minecraft.core.BlockPos; +import org.jetbrains.annotations.Nullable; + +import java.util.Map; + +public final class BlockPosMap extends Long2ObjectOpenHashMap { + public BlockPosMap( + int expected, + float f + ) { + + super(expected, f); + } + + public BlockPosMap(int expected) { + + super(expected); + } + + public BlockPosMap() { + + } + + public BlockPosMap( + Map m, + float f + ) { + + super(m, f); + } + + public BlockPosMap(Map m) { + + super(m); + } + + public BlockPosMap( + Long2ObjectMap m, + float f + ) { + + super(m, f); + } + + public BlockPosMap(Long2ObjectMap m) { + + super(m); + } + + public BlockPosMap( + long[] k, + T[] v, + float f + ) { + + super(k, v, f); + } + + public BlockPosMap( + long[] k, + T[] v + ) { + + super(k, v); + } + + @Override + public @Nullable T get(long k) { + + return super.get(k); + } + + public @Nullable T put( + BlockPos blockPos, + T member + ) { + + return put(blockPos.asLong(), member); + } + + public boolean containsKey(BlockPos blockPos) { + + return containsKey(blockPos.asLong()); + } + + public BlockPosIterator positions() { + + return new BlockPosIterator(keySet().longIterator()); + } + + public void removeAllPositions(BlockPosSet positionsInChunk) { + keySet().removeAll(positionsInChunk); + } + + public @Nullable T removePosition(BlockPos blockPos) { + + return remove(blockPos.asLong()); + } + + public @Nullable T getFromPosition(BlockPos blockPos) { + + return get(blockPos.asLong()); + } +} diff --git a/platform/minecraft/src/main/java/ca/teamdman/sfm/common/util/BlockPosSet.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/util/BlockPosSet.java index 1351da25e..675716c5d 100644 --- a/platform/minecraft/src/main/java/ca/teamdman/sfm/common/util/BlockPosSet.java +++ b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/util/BlockPosSet.java @@ -1,200 +1,200 @@ -package ca.teamdman.sfm.common.util; - -import it.unimi.dsi.fastutil.longs.LongCollection; -import it.unimi.dsi.fastutil.longs.LongIterator; -import it.unimi.dsi.fastutil.longs.LongOpenHashSet; -import net.minecraft.core.BlockPos; -import net.minecraft.world.level.levelgen.structure.BoundingBox; -import org.jetbrains.annotations.Nullable; - -import java.util.Collection; -import java.util.Iterator; -import java.util.function.Predicate; -import java.util.stream.Collector; - -public final class BlockPosSet extends LongOpenHashSet { - public BlockPosSet( - int expected, - float f - ) { - - super(expected, f); - } - - public BlockPosSet(int expected) { - - super(expected); - } - - public BlockPosSet() { - - } - - public BlockPosSet( - Collection c, - float f - ) { - - super(c, f); - } - - public BlockPosSet( - LongCollection c, - float f - ) { - - super(c, f); - } - - public BlockPosSet(LongCollection c) { - - super(c); - } - - public BlockPosSet( - LongIterator i, - float f - ) { - - super(i, f); - } - - public BlockPosSet(LongIterator i) { - - super(i); - } - - public BlockPosSet( - Iterator i, - float f - ) { - - super(i, f); - } - - public BlockPosSet(Iterator i) { - - super(i); - } - - public BlockPosSet( - long[] a, - int offset, - int length, - float f - ) { - - super(a, offset, length, f); - } - - public BlockPosSet( - long[] a, - int offset, - int length - ) { - - super(a, offset, length); - } - - public BlockPosSet( - long[] a, - float f - ) { - - super(a, f); - } - - public BlockPosSet(long[] a) { - - super(a); - } - - public BlockPosSet(Collection blockPosCollection) { - this(blockPosCollection.size()); - blockPosCollection.forEach(this::add); - } - - /// @return {@code false} if was already an element of the set, {@code true} otherwise - public boolean add(BlockPos pos) { - - return add(pos.asLong()); - - } - - - // can't be called `addAll` because of type erasure - public void addAllPositions(Collection other) { - - for (BlockPos blockPos : other) { - add(blockPos); - } - } - - public boolean remove(BlockPos blockPos) { - - return remove(blockPos.asLong()); - - } - - public boolean contains(BlockPos pos) { - - return contains(pos.asLong()); - } - - public BlockPosIterator blockPosIterator() { - - return new BlockPosIterator(BlockPosSet.this.longIterator()); - } - - @SuppressWarnings("UnusedReturnValue") - public boolean removeIfPosition(Predicate blockPosPredicate) { - - boolean removed = false; - for (Iterator iterator = blockPosIterator().iterator(); iterator.hasNext(); ) { - BlockPos.MutableBlockPos pos = iterator.next(); - if (blockPosPredicate.test(pos)) { - iterator.remove(); - removed = true; - } - } - return removed; - } - - public static Collector collector() { - - return Collector.of( - BlockPosSet::new, - BlockPosSet::add, - (a, b) -> { - a.addAll(b); - return a; - } - ); - } - - public static BlockPosSet of(BlockPos... pos) { - BlockPosSet rtn = new BlockPosSet(); - //noinspection ManualArrayToCollectionCopy // doesn't apply since addAll expects longs - for (BlockPos p : pos) { - rtn.add(p); - } - return rtn; - - } - - public @Nullable BoundingBox boundingBox() { - - BlockPosIterator iterator = blockPosIterator(); - if (!iterator.hasNext()) { - return null; - } else { - BoundingBox box = new BoundingBox(iterator.next()); - while (iterator.hasNext()) { - //noinspection deprecation - box.encapsulate(iterator.next()); - } - return box; - } - } - -} +package ca.teamdman.sfm.common.util; + +import it.unimi.dsi.fastutil.longs.LongCollection; +import it.unimi.dsi.fastutil.longs.LongIterator; +import it.unimi.dsi.fastutil.longs.LongOpenHashSet; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import org.jetbrains.annotations.Nullable; + +import java.util.Collection; +import java.util.Iterator; +import java.util.function.Predicate; +import java.util.stream.Collector; + +public final class BlockPosSet extends LongOpenHashSet { + public BlockPosSet( + int expected, + float f + ) { + + super(expected, f); + } + + public BlockPosSet(int expected) { + + super(expected); + } + + public BlockPosSet() { + + } + + public BlockPosSet( + Collection c, + float f + ) { + + super(c, f); + } + + public BlockPosSet( + LongCollection c, + float f + ) { + + super(c, f); + } + + public BlockPosSet(LongCollection c) { + + super(c); + } + + public BlockPosSet( + LongIterator i, + float f + ) { + + super(i, f); + } + + public BlockPosSet(LongIterator i) { + + super(i); + } + + public BlockPosSet( + Iterator i, + float f + ) { + + super(i, f); + } + + public BlockPosSet(Iterator i) { + + super(i); + } + + public BlockPosSet( + long[] a, + int offset, + int length, + float f + ) { + + super(a, offset, length, f); + } + + public BlockPosSet( + long[] a, + int offset, + int length + ) { + + super(a, offset, length); + } + + public BlockPosSet( + long[] a, + float f + ) { + + super(a, f); + } + + public BlockPosSet(long[] a) { + + super(a); + } + + public BlockPosSet(Collection blockPosCollection) { + this(blockPosCollection.size()); + blockPosCollection.forEach(this::add); + } + + /// @return {@code false} if was already an element of the set, {@code true} otherwise + public boolean add(BlockPos pos) { + + return add(pos.asLong()); + + } + + + // can't be called `addAll` because of type erasure + public void addAllPositions(Collection other) { + + for (BlockPos blockPos : other) { + add(blockPos); + } + } + + public boolean remove(BlockPos blockPos) { + + return remove(blockPos.asLong()); + + } + + public boolean contains(BlockPos pos) { + + return contains(pos.asLong()); + } + + public BlockPosIterator blockPosIterator() { + + return new BlockPosIterator(BlockPosSet.this.longIterator()); + } + + @SuppressWarnings("UnusedReturnValue") + public boolean removeIfPosition(Predicate blockPosPredicate) { + + boolean removed = false; + for (Iterator iterator = blockPosIterator().iterator(); iterator.hasNext(); ) { + BlockPos.MutableBlockPos pos = iterator.next(); + if (blockPosPredicate.test(pos)) { + iterator.remove(); + removed = true; + } + } + return removed; + } + + public static Collector collector() { + + return Collector.of( + BlockPosSet::new, + BlockPosSet::add, + (a, b) -> { + a.addAll(b); + return a; + } + ); + } + + public static BlockPosSet of(BlockPos... pos) { + BlockPosSet rtn = new BlockPosSet(); + //noinspection ManualArrayToCollectionCopy // doesn't apply since addAll expects longs + for (BlockPos p : pos) { + rtn.add(p); + } + return rtn; + + } + + public @Nullable BoundingBox boundingBox() { + + BlockPosIterator iterator = blockPosIterator(); + if (!iterator.hasNext()) { + return null; + } else { + BoundingBox box = new BoundingBox(iterator.next()); + while (iterator.hasNext()) { + //noinspection deprecation + box.encapsulate(iterator.next()); + } + return box; + } + } + +} diff --git a/platform/minecraft/src/main/java/ca/teamdman/sfm/common/util/ChunkPosMap.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/util/ChunkPosMap.java index 78cf8ec54..b1ab1bd57 100644 --- a/platform/minecraft/src/main/java/ca/teamdman/sfm/common/util/ChunkPosMap.java +++ b/platform/minecraft/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/platform/minecraft/src/main/java/ca/teamdman/sfm/common/util/SFMBlockPosUtils.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/util/SFMBlockPosUtils.java index e7359cc3f..c6cb121bf 100644 --- a/platform/minecraft/src/main/java/ca/teamdman/sfm/common/util/SFMBlockPosUtils.java +++ b/platform/minecraft/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)); - } - } - } - 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)); + } + } + } + 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/platform/minecraft/src/main/java/ca/teamdman/sfm/common/util/SFMDist.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/common/util/SFMDist.java index 51c94610e..795759cc9 100644 --- a/platform/minecraft/src/main/java/ca/teamdman/sfm/common/util/SFMDist.java +++ b/platform/minecraft/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/platform/minecraft/src/main/java/ca/teamdman/sfml/ast/ASTBuilder.java b/platform/minecraft/src/main/java/ca/teamdman/sfml/ast/ASTBuilder.java index 7d0c4d10f..5e9917b08 100644 --- a/platform/minecraft/src/main/java/ca/teamdman/sfml/ast/ASTBuilder.java +++ b/platform/minecraft/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/platform/minecraft/src/main/java/ca/teamdman/sfml/ast/CompositeFieldValue.java b/platform/minecraft/src/main/java/ca/teamdman/sfml/ast/CompositeFieldValue.java new file mode 100644 index 000000000..f06e428c0 --- /dev/null +++ b/platform/minecraft/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/platform/minecraft/src/main/java/ca/teamdman/sfml/ast/ExpandArgument.java b/platform/minecraft/src/main/java/ca/teamdman/sfml/ast/ExpandArgument.java new file mode 100644 index 000000000..f25f2202f --- /dev/null +++ b/platform/minecraft/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/platform/minecraft/src/main/java/ca/teamdman/sfml/ast/ExpandStatement.java b/platform/minecraft/src/main/java/ca/teamdman/sfml/ast/ExpandStatement.java new file mode 100644 index 000000000..f91d9e9fc --- /dev/null +++ b/platform/minecraft/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/platform/minecraft/src/main/java/ca/teamdman/sfml/ast/Label.java b/platform/minecraft/src/main/java/ca/teamdman/sfml/ast/Label.java index c00b793e2..e5c4f89bd 100644 --- a/platform/minecraft/src/main/java/ca/teamdman/sfml/ast/Label.java +++ b/platform/minecraft/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/platform/minecraft/src/main/java/ca/teamdman/sfml/ast/LabelAccess.java b/platform/minecraft/src/main/java/ca/teamdman/sfml/ast/LabelAccess.java index c4632233d..2cefd0b11 100644 --- a/platform/minecraft/src/main/java/ca/teamdman/sfml/ast/LabelAccess.java +++ b/platform/minecraft/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