Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ Before a release, `./gradlew releaseChangelog` turns *Unreleased* into the relea

## [Unreleased]

### Added

- NEEPMeat compatibility (Fabric 1.20.1): vascular conduits power turret bases (1 eJ = 1 E); a full turret base takes no power from the network

## [4.0.0] - 2026-11-03

### Added
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ Core gameplay:
Jar-in-jar it (approved).
- **Jade** (optional integration, `compat/jade/` for the server half and `client/compat/jade/` for the tooltips, registered through the `jade` entrypoint on Fabric and `@WailaPlugin` on NeoForge and Forge).
Compile-only (`modCompileOnly`, Modrinth maven), never bundled and never required at runtime.
- **NEEPMeat** (Fabric 1.20.1 only, Modrinth `maven.modrinth:neepmeat`, version in `versions/1.20.1-fabric/gradle.properties`; requested by the user on 2026-11-04): its vascular network charges the turret bases through OMLib (`platform/fabric/FabricNeepMeatPower`, registered by `FabricBlockLookups` when NEEPMeat is loaded; 1 eJ = 1 E, a full buffer takes nothing), so the mod has no code of its own for it.
Never bundled (`suggests` in `fabric.mod.json`); `modCompileOnly` for the game test `gametest/platform/fabric/NeepMeatGameTests` (in `src/gametest/mc1.20.1-1.20.1`; the other nodes drop its line from the test mod's `fabric-gametest` entrypoints), and `modLocalRuntime` with the mods nested in its jar (Loom leaves them out of its remapped copy; OMLib's `nestedModJars`) and **GeckoLib**, which it requires, for the dev runs and the game test server.
- **NeoForge** 21.1.x (1.21.1) and 21.11.x (1.21.11), built with **ModDevGradle** (approved by the user; build-time only).
On NeoForge, the Fabric-only APIs above are replaced by NeoForge's own (capabilities, events, payload registration); see §4 "Loaders".
- **Forge** 47.x (1.20.1 only), built with the legacy variant of ModDevGradle (`net.neoforged.moddev.legacyforge`, same artifact and version as `moddev`; build-time only), requested by the user.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Open Modular Turrets has long been a popular choice in PvP modpacks, and it is n
- **Generator:** burns furnace fuel (9 slots) and powers adjacent turret bases and other machines; an upgrade slot multiplies production and fuel use together
- **Turret API for other mods:** add turrets with their own weapons, ammo, models and custom hit logic; see [docs/turret-api.md](docs/turret-api.md)
- Uses **Team Reborn Energy** on Fabric, so it works with energy from other Fabric tech mods; the NeoForge build uses its energy capability instead
- Optional **NEEPMeat** support (Fabric 1.20.1): vascular conduits power turret bases, 1 eJ per energy unit
- Optional **Jade** support for turret bases and turrets, on both loaders

## Requirements
Expand Down
43 changes: 39 additions & 4 deletions build.fabric.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import java.util.concurrent.Callable

// Fabric nodes (Loom). Shared logic (Java, metadata templating, tests, publishing) is in buildSrc/mod-common.
plugins {
id("mod-common")
Expand Down Expand Up @@ -96,15 +98,48 @@ dependencies {
"modClientRuntimeOnly"("maven.modrinth:sable:${property("sable_version")}")
}

// NEEPMeat (1.20.1 only; the other nodes have no neepmeat_version): OMLib's FabricNeepMeatPower lets its vascular
// network charge the bases. Not bundled, the mod needs nothing of it; NEEPMeat (with the mods nested in its jar,
// which Loom leaves out of its remapped copy: nestedModJars in omlib/build-logic) and GeckoLib (which it requires,
// also with the library nested in its jar) are in the dev runs and on the game test server, where
// gametest/platform/fabric/NeepMeatGameTests checks the bases
findProperty("neepmeat_version")?.let { version ->
val neepmeat = "maven.modrinth:neepmeat:$version"
modCompileOnly(neepmeat)
modLocalRuntime(neepmeat)
// resolved lazily, when Loom resolves modLocalRuntime (earlier, it would use the repository before Loom sets it up)
val geckolib = "maven.modrinth:geckolib:${property("geckolib_version")}"
modLocalRuntime(geckolib)
// GeckoLib nests a library too (mclib; without it the client's resource reload fails)
val nested = layout.buildDirectory.dir("nested-mods")
modLocalRuntime(files(Callable {
listOf(neepmeat, geckolib).flatMap { notation ->
val jar = configurations.detachedConfiguration(dependencies.create(notation)).apply { isTransitive = false }.singleFile
nestedModJars(jar, nested.get().asFile)
}
}))
}

// Architecture rules (src/test/.../architecture): they read the compiled main and client classes
testImplementation("com.tngtech.archunit:archunit-junit5:${property("archunit_version")}")
testRuntimeOnly(sourceSets["client"].output)
}

// The game test source set also gets the client's runtime dependencies; Sable (see above) must stay off the test
// server. Loom renames remapped mods to the group remapped.<group>; Jade is compile-only, so nothing else is affected.
configurations.named("gametestRuntimeClasspath") {
exclude(group = "remapped.maven.modrinth")
// The game test source set also gets the client's runtime dependencies; Sable (see above, 1.21.1) must stay off the
// test server. Loom renames remapped mods to the group remapped.<group> (and their modules to <name>-<hash>); Jade is
// compile-only, so nothing else is affected there. 1.20.1 has no Sable: NEEPMeat and GeckoLib stay on its test server.
if (findProperty("sable_companion_version") != null) {
configurations.named("gametestRuntimeClasspath") {
exclude(group = "remapped.maven.modrinth")
}
}

// NeepMeatGameTests exists only where NEEPMeat does (src/gametest/mc1.20.1-1.20.1): the other nodes drop its line from
// the test mod's fabric-gametest entrypoints
if (findProperty("neepmeat_version") == null) {
tasks.named<ProcessResources>("processGametestResources") {
filesMatching("fabric.mod.json") { filter { line -> if ("NeepMeatGameTests" in line) null else line } }
}
}

// ---- Test coverage (JaCoCo): check prints the line coverage of core and api ----
Expand Down
1 change: 1 addition & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ Two directions, so that gameplay code stays loader-free:
Each entrypoint installs its implementations first thing; the `compat` facades (`NetworkCompat`, `MenuCompat`, `ClientNetworkCompat`) forward to them.

Energy and items other blocks may use are described loader-free (`core.energy.EnergyAccess`, `inventory.ItemAccess`, exposed by `getEnergyAccess(side)` / `getItemAccess(side)`) and wrapped by thin adapters per loader (Team Reborn Energy and Transfer API on Fabric, FE and item handler capabilities on NeoForge and Forge).
On Fabric 1.20.1 with NEEPMeat installed, OMLib also makes them sinks of its vascular network (`FabricNeepMeatPower`, 1 eJ = 1 E).

### How core talks to the game

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package omtteam.openmodularturrets.gametest.platform.fabric;

import com.neep.neepmeat.transport.api.pipe.BloodAcceptor;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import omtteam.omlib.core.energy.EnergyStore;
import omtteam.openmodularturrets.blockentity.TurretBaseBlockEntity;
import omtteam.openmodularturrets.gametest.OMTGameTests;
import omtteam.openmodularturrets.registry.ModBlocks;

/**
* NEEPMeat (Fabric 1.20.1 only): its vascular network charges turret bases at 1 eJ = 1 E (OMLib's
* {@code FabricNeepMeatPower}), a base taking only what its energy still accepts. Only this node has NEEPMeat on the
* game test server; the other Fabric nodes leave the class out of the test mod's entrypoints.
*/
@SuppressWarnings("unused")
public class NeepMeatGameTests implements OMTGameTests {
private static final BlockPos EMITTER = new BlockPos(1, 1, 1);
private static final BlockPos CONDUIT = EMITTER.east();
private static final BlockPos BASE = CONDUIT.east();
/** Power of the emitter in the network test, eJ per tick (below the tier three base's 1000 E/t intake). */
private static final int EMITTER_POWER = 400;
/** Tier one base: its energy accepts 50 E per tick and holds 5000 (config defaults). */
private static final long TIER_ONE_INTAKE = 50;

/**
* Power emitter, vascular conduit, tier three base: the conduit finds the base as a sink when placed, and the base
* is charged with the emitter's whole output, in whole ticks of it.
*/
@GameTest(template = EMPTY_STRUCTURE, batch = "neepmeat_network", timeoutTicks = 200)
public void vascularNetworkChargesABase(GameTestHelper helper) {
helper.setBlock(EMITTER, block("power_emitter"));
// its saved power (the block entity's class extends one of a library nested in NEEPMeat's jar, not on the
// compile classpath)
CompoundTag power = new CompoundTag();
power.putInt("power", EMITTER_POWER);
helper.getBlockEntity(EMITTER).load(power);
helper.setBlock(BASE, ModBlocks.TURRET_BASES.get(2));
EnergyStore energy = ((TurretBaseBlockEntity) helper.getBlockEntity(BASE)).getLogic().getEnergy();
placeAsPlayer(helper, CONDUIT, block("vascular_conduit"));
helper.succeedWhen(() -> {
long amount = energy.getAmount();
helper.assertTrue(amount >= EMITTER_POWER * 20L, "The base holds only " + amount + " E");
helper.assertTrue(amount % EMITTER_POWER == 0,
"The base holds " + amount + " E, not whole ticks of " + EMITTER_POWER + " eJ");
clear(helper);
});
}

/**
* The base as a sink: a plain (not active) sink that claims only its energy's intake per tick, charges that much
* each tick, asks the network for a new share when it is full and then claims nothing.
*/
@GameTest(template = EMPTY_STRUCTURE, batch = "neepmeat_sink", timeoutTicks = 300)
public void baseClaimsOnlyWhatItsEnergyAccepts(GameTestHelper helper) {
helper.setBlock(BASE, ModBlocks.TURRET_BASES.get(0));
EnergyStore energy = ((TurretBaseBlockEntity) helper.getBlockEntity(BASE)).getLogic().getEnergy();
BloodAcceptor sink = BloodAcceptor.SIDED.find(helper.getLevel(), helper.absolutePos(BASE), Direction.WEST);
helper.assertTrue(sink != null, "The base is no sink of a vascular network");
helper.assertTrue(sink.getMode() == BloodAcceptor.Mode.SINK, "The base is a " + sink.getMode() + " sink");
int[] updates = {0};
sink.setChangeListener(changed -> updates[0]++);
helper.assertTrue(sink.getDemandHint() == TIER_ONE_INTAKE, "The base asks for " + sink.getDemandHint() + " eJ/t");
// 1 per unit = 1000 eJ/t on offer: the base takes its 50
float taken = sink.updateInflux(1f);
helper.assertTrue(Math.round(taken * 1000) == TIER_ONE_INTAKE, "The base took " + taken * 1000 + " eJ/t of 1000");
long start = helper.getTick();
helper.succeedWhen(() -> {
helper.assertTrue(energy.getFreeSpace() == 0, "The base is not full yet (" + energy.getAmount() + " E)");
long ticks = helper.getTick() - start;
long expected = energy.getCapacity() / TIER_ONE_INTAKE;
helper.assertTrue(Math.abs(ticks - expected) <= 2, "Filling took " + ticks + " ticks, not " + expected);
helper.assertTrue(updates[0] > 0, "The full base did not ask the network for a new share");
helper.assertTrue(sink.getDemandHint() == 0, "The full base still asks for " + sink.getDemandHint() + " eJ/t");
helper.assertTrue(sink.updateInflux(1f) == 0f, "The full base still takes power");
sink.setChangeListener(null);
clear(helper);
});
}

private static Block block(String id) {
return BuiltInRegistries.BLOCK.get(new ResourceLocation("neepmeat", id));
}

/** Places a block like a player does: its state from its neighbours, then its placement callback. */
private static void placeAsPlayer(GameTestHelper helper, BlockPos pos, Block block) {
BlockPos absolute = helper.absolutePos(pos);
BlockState state = Block.updateFromNeighbourShapes(block.defaultBlockState(), helper.getLevel(), absolute);
helper.getLevel().setBlockAndUpdate(absolute, state);
block.setPlacedBy(helper.getLevel(), absolute, state, null, ItemStack.EMPTY);
}

private static void clear(GameTestHelper helper) {
helper.setBlock(CONDUIT, Blocks.AIR);
helper.setBlock(EMITTER, Blocks.AIR);
helper.setBlock(BASE, Blocks.AIR);
}
}
1 change: 1 addition & 0 deletions src/gametest/resources/fabric.mod.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"omtteam.openmodularturrets.gametest.GeneratorGameTests",
"omtteam.openmodularturrets.gametest.BaseGuiGameTests",
"omtteam.openmodularturrets.gametest.ItemTransferGameTests",
"omtteam.openmodularturrets.gametest.platform.fabric.NeepMeatGameTests",
"omtteam.openmodularturrets.gametest.CamoGameTests",
"omtteam.openmodularturrets.gametest.CamoMenuGameTests",
"omtteam.openmodularturrets.gametest.BlockMoveGameTests",
Expand Down
3 changes: 2 additions & 1 deletion src/main/resources/fabric.mod.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
}
],
"suggests": {
"jade": "*"
"jade": "*",
"neepmeat": "*"
},
"depends": {
"fabricloader": ">=${loader_version}",
Expand Down
4 changes: 4 additions & 0 deletions versions/1.20.1-fabric/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@ minecraft_dependency=1.20.1
fabric_api_version=0.92.12+1.20.1
java_version=17
jade_version=11.13.3+fabric
# NEEPMeat (Fabric, 1.20.1 only; Modrinth): its vascular network charges the bases (OMLib); in the dev runs and on the
# game test server with GeckoLib, which it requires (4.8.4 Fabric)
neepmeat_version=0.34.0-beta+1.20.1
geckolib_version=SD2lOScS