diff --git a/CHANGELOG.md b/CHANGELOG.md index 086ddd8..e3770fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Written for Consumers: what a mod building against Pipeworks can use, or will se ## Unreleased +- Every Pipeworks item shows in the suite's shared **Works** creative tab (`libworks:works`) and in no vanilla tab, so the pipe, storage tank, Pump and outlet leave Functional Blocks, and the Pipeworks tab `pipeworks:items` and its lang key `itemGroup.pipeworks.items` are gone. The jar nests `libworks-runtime` 0.1.1 by jar-in-jar, so a player installs nothing more; `neoforge.mods.toml` requires `libworks` from 0.1.1 to the next minor (FactoryWorks ADR-0128, #17). - `pipeworks:pipe` (three from three copper ingots) and `pipeworks:storage_tank` (four iron ingots, four glass and a copper ingot) have crafting recipes from vanilla items, so Pipeworks is craftable with Groundworks alone. The Pump and the creative pipe still have none (5thlayer/factoryworks#610). - The item tag `pipeworks:closes_sides` ships `minecraft:stick`, where it shipped empty: a player with no pack can close a pipe side. A pack that names other items replaces the tag with `"replace": true`; one that adds to it keeps the stick (ADR 0004, #15). - `pipeworks:outlet`, the **Outlet**: a port that sets its segment's fluid down as one source block in the space it faces, with no power and no recipe. It faces away from the block it is placed against and joins pipes on every other face. Once the segment holds 1,000 mB and that space is empty or holds a replaceable block (grass, snow, flowing fluid) and no source block, it takes the bucket and places the block, and the replaced block drops what it would if broken. A pipe, a torch or any other block that is only not solid is left standing. A fluid with no block stays in the pipes, and it never draws fluid from the world (#20). diff --git a/CLAUDE.md b/CLAUDE.md index 26191e3..2b1d99e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ Conventional commits: `(): `, with the summary in ## Testing -`sh ./gradlew build` runs the JUnit tests, on a plain JVM with no Minecraft. `sh ./gradlew runGameTestServer` runs the game tests headless, a real player on a real server with only Groundworks beside the Library, and names each one it ran; it fails if it ran none. JEI, EMI and Jade are on the dev client only. `PlaysAloneTest` fails on another Module's id, namespace or required dependency in the Library's code or resources, or on a tag the code reads that the game tests don't check, and `PlaysAloneTests` on a tag it reads or its data names that only another mod fills (FactoryWorks ADR-0127). A new game test class is registered by a line in `PipeworksGameTests.registerTests`, and its tests stand on the `gametest/platform` structure that the libworks plugin's `sh ./gradlew buildGametestStructures` writes. A test that needs a machine fills `gametest/TestPort`, a block registered only by the Library's own run (`pipeworks.gametest`, set in `build.gradle`), and waits a tick after placing it, since a block entity's `onLoad` runs on the next tick. CI (`.github/workflows/ci.yml`, libworks' `module-ci.yml` at the plugin's tag) runs both on every push and never publishes. Data only the Library's own runs load lives in `src/gametest/resources`, which never ships (libworks ADR 0004): `DevPacks` switches on `gametest_pack/` in the game tests and `dev_pack/` in the dev client when the Library has one, the datapacks that stand in for what a Consumer fills; it has none today. Packs a test switches on itself, such as one per batch, go under `gametest_packs//`. +`sh ./gradlew build` runs the JUnit tests, on a plain JVM with no Minecraft. `sh ./gradlew runGameTestServer` runs the game tests headless, a real player on a real server with only Groundworks and the libworks runtime the Library nests beside it, and names each one it ran; it fails if it ran none. JEI, EMI and Jade are on the dev client only. `PlaysAloneTest` fails on another Module's id, namespace or required dependency in the Library's code or resources, or on a tag the code reads that the game tests don't check, and `PlaysAloneTests` on a tag it reads or its data names that only another mod fills (FactoryWorks ADR-0127). A new game test class is registered by a line in `PipeworksGameTests.registerTests`, and its tests stand on the `gametest/platform` structure that the libworks plugin's `sh ./gradlew buildGametestStructures` writes. A test that needs a machine fills `gametest/TestPort`, a block registered only by the Library's own run (`pipeworks.gametest`, set in `build.gradle`), and waits a tick after placing it, since a block entity's `onLoad` runs on the next tick. CI (`.github/workflows/ci.yml`, libworks' `module-ci.yml` at the plugin's tag) runs both on every push and never publishes. Data only the Library's own runs load lives in `src/gametest/resources`, which never ships (libworks ADR 0004): `DevPacks` switches on `gametest_pack/` in the game tests and `dev_pack/` in the dev client when the Library has one, the datapacks that stand in for what a Consumer fills; it has none today. Packs a test switches on itself, such as one per batch, go under `gametest_packs//`. The `skillworks:quicklaunch` skill opens the dev client into the most recent save in `run/saves`, one client per checkout. diff --git a/GLOSSARY.md b/GLOSSARY.md index 200fa3a..0db19ed 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -65,6 +65,10 @@ _Avoid_: drain, spout, fluid placer The pipes a Groundworks Dismantle takes up as one span: the block tag `pipeworks:dismantle/pipes`, joined only where each pipe draws an arm toward the other, so a closed side ends a span. _Avoid_: dismantle group +**Works tab**: +The creative tab the suite's mods share, `libworks:works`, registered by the libworks runtime the Library nests. Every Pipeworks item shows there; Pipeworks registers no tab of its own (FactoryWorks ADR-0128). +_Avoid_: Pipeworks tab, creative tab (alone) + ### Parties **Pipeworks**: diff --git a/README.md b/README.md index 707318d..83ebf64 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Because I wanted my own fluid logistics for the -Works mods suite. Nothing speci - **One fluid per segment.** A pipe or tank that would join two segments holding different fluids is refused, with a message saying so. - **Breaking splits fairly.** Breaking a pipe, tank or port splits its segment by capacity, and the broken block takes its share of the fluid. Segments are saved with the world and keep their fluid while their chunks are unloaded. - **See the level.** A storage tank shows how full its segment is, in fifteen steps, as fluid of the right colour in its glass. With [Jade](https://www.curseforge.com/minecraft/mc-mods/jade) installed, looking at a pipe, tank or port shows its segment's fluid, amount and capacity. -- **The creative pipe.** `pipeworks:creative_pipe`, in the creative tab, keeps its segment full of the fluid its screen sets. Click its slot with a bucket, or any item holding a fluid, to set it, and with an empty cursor to clear it. With JEI or EMI installed, a fluid can be dragged onto the slot. +- **The creative pipe.** `pipeworks:creative_pipe`, in the Works creative tab, keeps its segment full of the fluid its screen sets. Click its slot with a bucket, or any item holding a fluid, to set it, and with an empty cursor to clear it. With JEI or EMI installed, a fluid can be dragged onto the slot. - **A Pump.** `pipeworks:pump` draws a still source block beside it into its segment. A source in the fluid tag `pipeworks:infinite_sources`, water by default, is left in place and gives 1,200 mB a second; any other is taken whole, as a bucket would, once the segment has room for 1,000 mB. - **An Outlet.** `pipeworks:outlet` sets its segment's fluid down as a source block in the space it faces, a bucket's worth at a time, once that space is empty or replaceable. It never picks fluid up. - **Dismantle and drag-laying.** Pipes are a [Groundworks](https://github.com/5thlayer/groundworks) Dismantle Family and are laid by its Stretch: sneak-click a pipe with a pickaxe, then click another to take up the run between them; drag a line of pipes the way belts are laid. diff --git a/build.gradle b/build.gradle index c43298e..1eb4d4e 100644 --- a/build.gradle +++ b/build.gradle @@ -15,14 +15,20 @@ libworks { tagPrefix = 'v' range = '[0.5.5,0.6)' } + // Nested by jar-in-jar, so players install nothing more; FML loads the newest nested copy in range, + // which runs to the next minor. + runtime = '0.1.1' } repositories { - // Groundworks: its releases are published to the local maven repository only, and nothing else - // resolves from there. + // Groundworks and libworks-runtime: their releases are published to the local maven repository + // only, and nothing else resolves from there. exclusiveContent { forRepository { mavenLocal() } - filter { includeModule 'io.github.5thlayer', 'groundworks' } + filter { + includeModule 'io.github.5thlayer', 'groundworks' + includeModule 'io.github.5thlayer', 'libworks-runtime' + } } exclusiveContent { // EMI's unofficial 26.1.2 port, which the FactoryWorks Pack runs forRepository { maven { url = 'https://cursemaven.com' } } diff --git a/src/main/java/io/github/_5thlayer/pipeworks/PipeworksRegistries.java b/src/main/java/io/github/_5thlayer/pipeworks/PipeworksRegistries.java index b3374cc..a05f5d6 100644 --- a/src/main/java/io/github/_5thlayer/pipeworks/PipeworksRegistries.java +++ b/src/main/java/io/github/_5thlayer/pipeworks/PipeworksRegistries.java @@ -3,6 +3,7 @@ package io.github._5thlayer.pipeworks; +import io.github._5thlayer.libworks.WorksTab; import io.github._5thlayer.pipeworks.block.CreativePipeBlock; import io.github._5thlayer.pipeworks.block.CreativePipeMenu; import io.github._5thlayer.pipeworks.block.FluidPipeBlock; @@ -15,12 +16,8 @@ import java.util.Set; import net.minecraft.core.registries.Registries; -import net.minecraft.network.chat.Component; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.inventory.MenuType; -import net.minecraft.world.item.CreativeModeTab; -import net.minecraft.world.item.CreativeModeTabs; -import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.block.SoundType; import net.minecraft.world.level.block.entity.BlockEntityType; import net.neoforged.bus.api.IEventBus; @@ -33,7 +30,7 @@ import net.neoforged.neoforge.registries.DeferredItem; import net.neoforged.neoforge.registries.DeferredRegister; -/** The pipe, the storage tank, the Pump, the Outlet and the creative pipe, their items, the creative pipe's menu and the creative tab. No fluid is registered here. */ +/** The pipe, the storage tank, the Pump, the Outlet and the creative pipe, their items, the creative pipe's menu, and their place in the creative tabs. No fluid is registered here. */ public final class PipeworksRegistries { private static final DeferredRegister.Blocks BLOCKS = DeferredRegister.createBlocks(Pipeworks.MOD_ID); @@ -41,14 +38,12 @@ public final class PipeworksRegistries { private static final DeferredRegister> BLOCK_ENTITIES = DeferredRegister.create(Registries.BLOCK_ENTITY_TYPE, Pipeworks.MOD_ID); private static final DeferredRegister> MENUS = DeferredRegister.create(Registries.MENU, Pipeworks.MOD_ID); - private static final DeferredRegister CREATIVE_TABS = - DeferredRegister.create(Registries.CREATIVE_MODE_TAB, Pipeworks.MOD_ID); public static final DeferredBlock PIPE = BLOCKS.registerBlock("pipe", FluidPipeBlock::new, props -> props.strength(1.5F, 6.0F).sound(SoundType.COPPER).requiresCorrectToolForDrops().noOcclusion()); public static final DeferredBlock STORAGE_TANK = BLOCKS.registerBlock("storage_tank", StorageTankBlock::new, props -> props.strength(2.0F, 6.0F).sound(SoundType.COPPER).requiresCorrectToolForDrops().noOcclusion()); - /** Has no recipe, which keeps it out of survival: it is in the creative tab and {@code /give}. */ + /** Has no recipe, which keeps it out of survival: it is in the Works tab and {@code /give}. */ public static final DeferredBlock CREATIVE_PIPE = BLOCKS.registerBlock("creative_pipe", CreativePipeBlock::new, props -> props.strength(1.5F, 6.0F).sound(SoundType.COPPER).requiresCorrectToolForDrops().noOcclusion()); /** Has no recipe: a pack adds one. */ @@ -73,19 +68,6 @@ public final class PipeworksRegistries { public static final DeferredHolder, MenuType> CREATIVE_PIPE_MENU = MENUS.register("creative_pipe", () -> IMenuTypeExtension.create(CreativePipeMenu::new)); - public static final DeferredHolder CREATIVE_TAB = - CREATIVE_TABS.register("items", () -> CreativeModeTab.builder() - .title(Component.translatable("itemGroup.pipeworks.items")) - .icon(() -> new ItemStack(PIPE_ITEM.get())) - .displayItems((parameters, output) -> { - output.accept(PIPE_ITEM.get()); - output.accept(STORAGE_TANK_ITEM.get()); - output.accept(PUMP_ITEM.get()); - output.accept(OUTLET_ITEM.get()); - output.accept(CREATIVE_PIPE_ITEM.get()); - }) - .build()); - private PipeworksRegistries() { } @@ -94,7 +76,6 @@ static void register(IEventBus modBus) { ITEMS.register(modBus); BLOCK_ENTITIES.register(modBus); MENUS.register(modBus); - CREATIVE_TABS.register(modBus); modBus.addListener(PipeworksRegistries::registerCapabilities); modBus.addListener(PipeworksRegistries::addToCreativeTabs); } @@ -111,11 +92,12 @@ private static void registerCapabilities(RegisterCapabilitiesEvent event) { } private static void addToCreativeTabs(BuildCreativeModeTabContentsEvent event) { - if (event.getTabKey() == CreativeModeTabs.FUNCTIONAL_BLOCKS) { + if (event.getTabKey() == WorksTab.KEY) { event.accept(PIPE_ITEM.get()); event.accept(STORAGE_TANK_ITEM.get()); event.accept(PUMP_ITEM.get()); event.accept(OUTLET_ITEM.get()); + event.accept(CREATIVE_PIPE_ITEM.get()); } } } diff --git a/src/main/java/io/github/_5thlayer/pipeworks/gametest/CreativeTabTests.java b/src/main/java/io/github/_5thlayer/pipeworks/gametest/CreativeTabTests.java new file mode 100644 index 0000000..9bd5c9f --- /dev/null +++ b/src/main/java/io/github/_5thlayer/pipeworks/gametest/CreativeTabTests.java @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: 2026 5thlayer +// SPDX-License-Identifier: MIT + +package io.github._5thlayer.pipeworks.gametest; + +import java.util.List; + +import io.github._5thlayer.libworks.WorksTab; +import io.github._5thlayer.pipeworks.Pipeworks; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.world.item.CreativeModeTab; +import net.minecraft.world.item.CreativeModeTabs; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; + +/** + * Every item the Library registers shows in the suite's shared Works tab and in no other tab, and the Library keeps no + * tab of its own (FactoryWorks ADR-0128, #17). + */ +final class CreativeTabTests { + + private CreativeTabTests() { + } + + static void register(PipeworksGameTests.Registrar tests) { + tests.test("the_works_tab_holds_every_item_the_library_registers", 1, CreativeTabTests::worksTabHoldsEveryItem); + tests.test("no_other_tab_holds_a_library_item", 1, CreativeTabTests::noOtherTab); + tests.test("the_library_registers_no_creative_tab", 1, CreativeTabTests::noOwnTab); + } + + private static void worksTabHoldsEveryItem(GameTestHelper helper) { + CreativeModeTabs.tryRebuildTabContents(helper.getLevel().enabledFeatures(), true, helper.getLevel().registryAccess()); + List shown = BuiltInRegistries.CREATIVE_MODE_TAB.getValueOrThrow(WorksTab.KEY).getDisplayItems().stream() + .map(ItemStack::getItem) + .toList(); + List missing = BuiltInRegistries.ITEM.stream() + .filter(item -> BuiltInRegistries.ITEM.getKey(item).getNamespace().equals(Pipeworks.MOD_ID)) + .filter(item -> !shown.contains(item)) + .toList(); + if (!missing.isEmpty()) { + helper.fail("the Works tab does not hold " + missing); + return; + } + helper.succeed(); + } + + private static void noOtherTab(GameTestHelper helper) { + CreativeModeTabs.tryRebuildTabContents(helper.getLevel().enabledFeatures(), true, helper.getLevel().registryAccess()); + // Empty tabs would pass the loop below without the Library's items reaching any tab. + if (ownItems(BuiltInRegistries.CREATIVE_MODE_TAB.getValueOrThrow(WorksTab.KEY)).isEmpty()) { + helper.fail("the Works tab shows no Pipeworks item, so no tab was filled"); + return; + } + for (var entry : BuiltInRegistries.CREATIVE_MODE_TAB.entrySet()) { + CreativeModeTab tab = entry.getValue(); + // The search tab lists every other tab's items. + if (entry.getKey() == WorksTab.KEY || tab.getType() == CreativeModeTab.Type.SEARCH) { + continue; + } + List own = ownItems(tab); + if (!own.isEmpty()) { + helper.fail("the tab " + entry.getKey().identifier() + " shows " + own); + return; + } + } + helper.succeed(); + } + + private static List ownItems(CreativeModeTab tab) { + return tab.getDisplayItems().stream() + .map(ItemStack::getItem) + .filter(item -> BuiltInRegistries.ITEM.getKey(item).getNamespace().equals(Pipeworks.MOD_ID)) + .toList(); + } + + private static void noOwnTab(GameTestHelper helper) { + var own = BuiltInRegistries.CREATIVE_MODE_TAB.keySet().stream() + .filter(id -> id.getNamespace().equals(Pipeworks.MOD_ID)) + .toList(); + if (!own.isEmpty()) { + helper.fail("the Library registers its own creative tab: " + own); + return; + } + helper.succeed(); + } +} diff --git a/src/main/java/io/github/_5thlayer/pipeworks/gametest/LoadTests.java b/src/main/java/io/github/_5thlayer/pipeworks/gametest/LoadTests.java index a50d80c..cfeba2c 100644 --- a/src/main/java/io/github/_5thlayer/pipeworks/gametest/LoadTests.java +++ b/src/main/java/io/github/_5thlayer/pipeworks/gametest/LoadTests.java @@ -8,9 +8,9 @@ import net.neoforged.fml.ModList; /** - * The Library loads on a server with nothing else installed, at the version its build gave it. It - * is the harness's own check too: a run that reports this test ran the Library's tests, not - * vanilla's. + * The Library loads on a server with only Groundworks and the libworks runtime it nests, at the + * version its build gave it. It is the harness's own check too: a run that reports this test ran the + * Library's tests, not vanilla's. */ final class LoadTests { diff --git a/src/main/java/io/github/_5thlayer/pipeworks/gametest/PipeworksGameTests.java b/src/main/java/io/github/_5thlayer/pipeworks/gametest/PipeworksGameTests.java index 79c588e..a239037 100644 --- a/src/main/java/io/github/_5thlayer/pipeworks/gametest/PipeworksGameTests.java +++ b/src/main/java/io/github/_5thlayer/pipeworks/gametest/PipeworksGameTests.java @@ -74,6 +74,7 @@ private static void registerTests(RegisterGameTestsEvent event) { PumpTests.register(tests); RecipeTests.register(tests); OutletTests.register(tests); + CreativeTabTests.register(tests); } private static Identifier id(String path) { diff --git a/src/main/java/io/github/_5thlayer/pipeworks/gametest/PlaysAloneTests.java b/src/main/java/io/github/_5thlayer/pipeworks/gametest/PlaysAloneTests.java index 76771e4..9a87f80 100644 --- a/src/main/java/io/github/_5thlayer/pipeworks/gametest/PlaysAloneTests.java +++ b/src/main/java/io/github/_5thlayer/pipeworks/gametest/PlaysAloneTests.java @@ -25,12 +25,13 @@ import net.neoforged.fml.ModList; /** - * The Library plays alone on Groundworks (FactoryWorks ADR-0127): nothing else is loaded beside it, and every tag its code - * reads or its data names holds an entry of Minecraft or Pipeworks. + * The Library plays alone on Groundworks (FactoryWorks ADR-0127): nothing else is loaded beside it but the + * libworks runtime it nests, and every tag its code reads or its data names holds an entry of Minecraft or + * Pipeworks. */ final class PlaysAloneTests { - private static final Set LOADED_ALONE = Set.of("minecraft", "neoforge", "groundworks", Pipeworks.MOD_ID); + private static final Set LOADED_ALONE = Set.of("minecraft", "neoforge", "groundworks", "libworks", Pipeworks.MOD_ID); private static final Set FILLS_ALONE = Set.of("minecraft", Pipeworks.MOD_ID); diff --git a/src/main/resources/META-INF/neoforge.mods.toml b/src/main/resources/META-INF/neoforge.mods.toml index 5a5a0a3..2d31860 100644 --- a/src/main/resources/META-INF/neoforge.mods.toml +++ b/src/main/resources/META-INF/neoforge.mods.toml @@ -35,6 +35,15 @@ versionRange = "${groundworks_range}" ordering = "NONE" side = "BOTH" +# libworks-runtime, nested by jar-in-jar: it registers the Works creative tab. The range is the one +# libworks { runtime } in build.gradle nests, to the next minor. +[[dependencies.${mod_id}]] +modId = "libworks" +type = "required" +versionRange = "${libworks_runtime_range}" +ordering = "NONE" +side = "BOTH" + # Recipe viewers drop a fluid on the creative pipe's screen. Neither is needed to load. [[dependencies.${mod_id}]] modId = "jei" diff --git a/src/main/resources/assets/pipeworks/lang/en_us.json b/src/main/resources/assets/pipeworks/lang/en_us.json index f9eb149..8333bf9 100644 --- a/src/main/resources/assets/pipeworks/lang/en_us.json +++ b/src/main/resources/assets/pipeworks/lang/en_us.json @@ -10,7 +10,6 @@ "item.pipeworks.pipe": "Pipe", "item.pipeworks.pump": "Pump", "item.pipeworks.storage_tank": "Storage Tank", - "itemGroup.pipeworks.items": "Pipeworks", "message.pipeworks.dismantle.not_joined": "That is not joined to the block the dismantle started on.", "message.pipeworks.dismantle.outside_family": "That is not part of the run the dismantle started on.", "message.pipeworks.dismantle.tied": "Two routes of the same length lead there. Pick an end with only one.",