From b3b77d19ead6309bf0feb59c011437ed17216a5c Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Sat, 7 Nov 2026 08:01:38 +0200 Subject: [PATCH 1/3] Add provider-based statistics and configurable generator panels --- docs/gui-screens.md | 76 +++++++ docs/machine-companion.md | 16 +- .../moddev/mc/companion/CompanionMod.java | 9 +- .../mc/companion/CreativePowerEntity.java | 74 ++++++- .../zone/moddev/mc/companion/DemoBlock.java | 29 +++ .../zone/moddev/mc/companion/DemoEntity.java | 8 + .../moddev/mc/companion/ProcessorEntity.java | 21 +- .../blockstates/creative_power.json | 14 +- .../mmdlib_companion/blockstates/crusher.json | 14 +- .../blockstates/dual_crusher.json | 14 +- .../blockstates/logistics.json | 14 +- .../blockstates/transmuter.json | 14 +- .../assets/mmdlib_companion/lang/en_us.json | 3 +- .../mmdlib/screen/creative_power.xml | 3 +- .../companion/test/CompanionClientSmoke.java | 48 +++- .../mc/companion/test/CompanionGameTests.java | 191 ++++++++++++++++ .../test/CreativePowerClientSmoke.java | 110 +++++++++ .../mc/companion/test/StatsClientSmoke.java | 127 +++++++++++ .../mc/lib/api/capabilities/EnergyBuffer.java | 59 ++++- .../mc/lib/api/capabilities/FluidStorage.java | 8 +- .../mc/lib/api/capabilities/ItemStorage.java | 9 +- .../moddev/mc/lib/api/stats/Statistics.java | 115 ++++++++++ .../mc/lib/api/stats/StatsProvider.java | 7 + .../mc/lib/gui/client/AutomationDrawer.java | 8 +- .../mc/lib/gui/client/DrawerMemory.java | 12 + .../mc/lib/gui/client/MachineScreen.java | 44 +++- .../mc/lib/gui/client/RedstoneDrawer.java | 12 +- .../moddev/mc/lib/gui/client/StatsDrawer.java | 208 ++++++++++++++++++ .../mc/lib/gui/runtime/AutomationMenu.java | 14 +- .../mc/lib/gui/runtime/CounterRate.java | 33 +++ .../mc/lib/gui/runtime/MachineMenu.java | 11 + .../moddev/mc/lib/gui/runtime/ScreenHost.java | 10 + .../mc/lib/gui/runtime/ScreenStats.java | 109 +++++++++ .../mc/lib/gui/runtime/StatsNumbers.java | 23 ++ .../lib/machine/feature/RecipeProcessor.java | 60 ++++- .../mc/lib/machine/inventory/EnergyGroup.java | 10 + .../lib/machine/neoforge/StorageGroups.java | 25 ++- .../resources/assets/mmdlib/lang/en_us.json | 23 +- .../moddev/mc/lib/gui/EnergyRateTest.java | 114 ++++++++++ .../mc/lib/gui/runtime/CycleRateTest.java | 56 +++++ .../mc/lib/gui/runtime/ScreenStatsTest.java | 154 +++++++++++++ 41 files changed, 1847 insertions(+), 62 deletions(-) create mode 100644 src/companionTest/java/zone/moddev/mc/companion/test/CreativePowerClientSmoke.java create mode 100644 src/companionTest/java/zone/moddev/mc/companion/test/StatsClientSmoke.java create mode 100644 src/main/java/zone/moddev/mc/lib/api/stats/Statistics.java create mode 100644 src/main/java/zone/moddev/mc/lib/api/stats/StatsProvider.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/StatsDrawer.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/CounterRate.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenStats.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/StatsNumbers.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/EnergyRateTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/runtime/CycleRateTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/runtime/ScreenStatsTest.java diff --git a/docs/gui-screens.md b/docs/gui-screens.md index 3309a933..0d705e62 100644 --- a/docs/gui-screens.md +++ b/docs/gui-screens.md @@ -149,6 +149,82 @@ under `build/gui-smoke*`, checks server actions and full codecs, opens the actua synced values, and saves `screenshots/gui-smoke.png`. Run a normal build afterward to remove injected smoke classes from main output; release verification rejects any leaked smoke classes. +For a side-configuration-only drawer, return `false` from +`ScreenHost.screenAutomationAddons()`. The menu registers no addon/filter slots and the drawer +ends below its face controls. Combine this with `inventory="none"` to omit player inventory and +equipment drawers, as in the companion Creative Power Source. + +## Statistics drawer + +The left Statistics drawer shows sections supplied by machine parts through +`api.stats.StatsProvider`. The menu knows only this interface, not concrete storage or processor +classes. Return the contributing parts from `ScreenHost.screenStats()` in display order: + +```java +@Override public List screenStats() { + var parts = new ArrayList(); + parts.add(energy); + parts.add(items); + parts.add(fluids); + parts.addAll(lanes); + return List.copyOf(parts); +} +``` + +Return stable part instances so the menu can retain counter history. Repeated references to the +same part are sampled once. Providers run only on the server, once per 20 game ticks per menu; +unit changes are local and use the existing synchronized values channel. + +A provider returns `Statistics`: a translated section title, rows with stable IDs, and optional +energy-demand contributions. Rows describe a cumulative `Counter`, an exact `Rate`, explanatory +`Text`, or aggregate `MaximumUse`. Each row declares its label, unit, precision and whether it +scales with time. `row.whenUsed(resource)` hides it unless a provider reports positive +energy demand for that exact resource. The drawer handles sampling and formatting. Custom parts can implement this +interface without changing the menu or renderer. Each section supports at most 64 distinct rows; +the combined wire snapshot is limited to 4,096 lines. Empty sections add no title or rows. + +Built-in providers: + +- `ItemStorage` and `FluidStorage` return `Statistics.EMPTY`. +- `RecipeProcessor` uses its display name as header, followed by measured cycles and maximum + cycles per selected timescale. The maximum is based on the active recipe's duration; without an + active plan it says No recipe. A manually interrupted processor has released its plan, so its + maximum is unavailable until it reserves again. The just-finished plan bridges the one-tick + gap before the next recipe. Idle/reset processors do not retain stale recipe limits. +- `EnergyBuffer` shows RF in and its maximum transfer rate when it supports input, and RF out and + its maximum transfer rate when it supports output. RF used and RF max use appear only while + registered processors report positive current-recipe demand for this buffer. Generators and idle + buffers omit both usage rows. Beneath the Energy header there are two rows per supported + direction, plus two usage rows when applicable. + +Input/output maxima follow the selected timescale: the configured transfer limit is the RF/t +baseline, multiplied by 20 for RF/s or 1,200 for RF/min. This uses one transfer per tick; +the capability still enforces the limit per call, so multiple callers can exceed this nominal +rate. Measured input/output continues to report actual total flow. +Maximum processing use is the sum of each registered processor's active recipe peak +`ceil(recipe energy / recipe duration)` for this buffer, scaled to the selected time unit. +`EnergyGroup.statisticsResource()` identifies the backing storage; `StorageGroups.energy` returns +the exact buffer instance. This attributes demand correctly when lanes share buffers or a machine +has multiple independent buffers. Register all contributing processors, not just the buffer. + +`EnergyBuffer.insert` counts incoming energy, `extract` counts outgoing energy, and `consume` +counts processing use separately. Simulations, direct assignments, loading and resizing do not +count. Recipe processors call `EnergyGroup.consume`; its default delegates to `extract` for custom +implementations, while the built-in adapter records use separately. Transactions restore all +three counters along with stored energy on rollback. Creative Power refills through `insert`. +Counters are transient; saved NBT remains unchanged. + +Measured RF uses the most recent 20 ticks. Completed cycles use up to 1,200 ticks (60 game seconds), +warming up from menu opening. Stalls, interruptions and unsuccessful output commits do not count +as completions. Rates use game time at 20 ticks per second, not wall-clock time. A first sample +shows a dash until enough time has elapsed. RF values are grouped integers; cycles have exactly +two decimal places. For example: 24,000 RF/min and 12.00 cycles/min. + +The compact header selector cycles Per tick / Per second / Per minute; right-click reverses. +Info, Statistics and equipment drawers replace each other on the left. Right-side automation +and redstone drawers remain independent. Statistics scrolls when needed, reserves its bounds +from JEI, closes on Escape, and remembers its open state for the connection. + ## Registering custom widget types A consuming mod can add a leaf widget without replacing MMDLib's screen or menu. Register its diff --git a/docs/machine-companion.md b/docs/machine-companion.md index 732a431b..5d23dbe7 100644 --- a/docs/machine-companion.md +++ b/docs/machine-companion.md @@ -14,7 +14,19 @@ apply to `runClient` and `runCompanionClient` only and stay outside the reposito Minecraft uses its default window size. Automated smoke runs retain their own fixed dimensions. Place a **Creative Power Source** next to a machine, then right-click the machine to open its screen. -The source supplies all six adjacent faces. JEI is included in development runs; add `-PwithoutJei` + +Open the Creative Power Source and use its right-hand side configuration tab to configure each +output face. Its screen has only side configuration, Info and Statistics panels, with no player +inventory, equipment drawers or addon/filter slots. +Machines face the player when placed. Left/right in the face grid are viewed from that front, +so neighbor icons and side controls follow the same orientation. Rotation and mirroring update +that facing. Blocks placed before facing was recorded keep the north default; replace them to +choose their front from placement. Walking around a machine does not change its configured front. + +Faces cycle between Output and Disabled. Disabled faces block both automatic delivery and +external extraction; settings survive reloads. Existing sources start with all six faces enabled. + +JEI is included in development runs; add `-PwithoutJei` to run without it. | Block ID (prefix `mmdlib_companion:`) | What it does | @@ -22,7 +34,7 @@ to run without it. | `crusher` | One input slot, two output slots, one processor, and a water/lava preview tank | | `transmuter` | One slot limited to one item; converts an iron ingot into a gold ingot | | `dual_crusher` | Two independently pausable processors share the same input and output slots | -| `creative_power` | Refills its buffer and supplies up to 1,000 FE per adjacent face per tick | +| `creative_power` | Refills its buffer and supplies up to 1,000 FE per enabled adjacent face per tick | The crusher recipes take 100 ticks and 2,000 FE per cycle: cobblestone becomes gravel, gravel becomes sand, and copper/iron/gold ore becomes two of the corresponding raw material. The converter takes diff --git a/src/companion/java/zone/moddev/mc/companion/CompanionMod.java b/src/companion/java/zone/moddev/mc/companion/CompanionMod.java index b2194f6b..187adaeb 100644 --- a/src/companion/java/zone/moddev/mc/companion/CompanionMod.java +++ b/src/companion/java/zone/moddev/mc/companion/CompanionMod.java @@ -27,7 +27,6 @@ import zone.moddev.mc.lib.api.MaterialEventTypes; import zone.moddev.mc.lib.api.MaterialExtensions; -import zone.moddev.mc.lib.api.capabilities.IoMode; import zone.moddev.mc.lib.api.capabilities.MachineCapabilities; import zone.moddev.mc.lib.gui.runtime.ScreenClick; import zone.moddev.mc.lib.machine.neoforge.MachineRecipeTypes; @@ -138,7 +137,13 @@ private static void capabilities(RegisterCapabilitiesEvent event) { for (var machine : MACHINES) { if (machine == CREATIVE_POWER) { MachineCapabilities.energy( - event, machine.type.get(), entity -> entity.energy, (entity, side) -> IoMode.OUTPUT); + event, + machine.type.get(), + entity -> entity.energy, + (entity, side) -> + ((CreativePowerEntity) entity) + .automation.mode( + zone.moddev.mc.lib.api.automation.ResourceType.ENERGY, 0, side)); continue; } event.registerBlockEntity( diff --git a/src/companion/java/zone/moddev/mc/companion/CreativePowerEntity.java b/src/companion/java/zone/moddev/mc/companion/CreativePowerEntity.java index 9e58af35..c2b68d86 100644 --- a/src/companion/java/zone/moddev/mc/companion/CreativePowerEntity.java +++ b/src/companion/java/zone/moddev/mc/companion/CreativePowerEntity.java @@ -2,31 +2,58 @@ import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.world.Clearable; +import net.minecraft.world.level.Level; import net.minecraft.world.level.block.entity.BlockEntityType; import net.minecraft.world.level.block.state.BlockState; import net.neoforged.neoforge.capabilities.Capabilities; +import zone.moddev.mc.lib.api.automation.AutomationInventory; +import zone.moddev.mc.lib.api.automation.ResourceGroup; +import zone.moddev.mc.lib.api.automation.ResourceType; +import zone.moddev.mc.lib.api.capabilities.IoMode; import zone.moddev.mc.lib.api.capabilities.ItemStorage; +import zone.moddev.mc.lib.api.capabilities.SideConfig; +import zone.moddev.mc.lib.common.runtime.DropsContents; import zone.moddev.mc.lib.gui.runtime.ScreenHost; /** Refills each tick and supplies up to 1,000 FE per adjacent face per tick. */ -public final class CreativePowerEntity extends DemoEntity implements ScreenHost { +public final class CreativePowerEntity extends DemoEntity + implements ScreenHost, DropsContents, Clearable { private final ItemStorage empty = new ItemStorage(0); + public final AutomationInventory automation; public CreativePowerEntity(BlockEntityType type, BlockPos pos, BlockState state) { super(type, pos, state); energy.setRates(0, 1_000); energy.setStored(energy.capacity()); + var group = + new ResourceGroup<>( + "energy", + Component.translatable("screen.mmdlib_companion.group.energy"), + 0xd85b5b, + ResourceType.ENERGY, + IoMode.OUTPUT, + java.util.List.of(0), + java.util.List.of("energy"), + new SideConfig(IoMode.OUTPUT)); + group.bind(this::facing, side -> energy.view(() -> group.mode(side))); + automation = new AutomationInventory(this, java.util.List.of(group)); } @Override public void serverTick() { - energy.setStored(energy.capacity()); + energy.insert(energy.space(), false); for (var side : Direction.values()) { + if (!automation.mode(ResourceType.ENERGY, 0, side).canExtract()) continue; var target = worldPosition.relative(side); if (!level.hasChunkAt(target)) continue; - var receiver = level.getCapability(Capabilities.EnergyStorage.BLOCK, target, side.getOpposite()); + var receiver = + level.getCapability(Capabilities.EnergyStorage.BLOCK, target, side.getOpposite()); if (receiver != null && receiver.canReceive()) energy.extract(receiver.receiveEnergy(1_000, false), false); } @@ -36,4 +63,45 @@ public void serverTick() { public ItemStorage screenItems() { return empty; } + + @Override + public AutomationInventory screenAutomation() { + return automation; + } + + @Override + public boolean screenAutomationAddons() { + return false; + } + + @Override + protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) { + super.saveAdditional(tag, registries); + tag.put("automation", automation.save(registries)); + } + + @Override + protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) { + super.loadAdditional(tag, registries); + if (tag.contains("automation")) automation.load(tag.getCompound("automation"), registries); + } + + @Override + public void dropContents(Level level, BlockPos pos) { + automation.dropAll( + payload -> + net.minecraft.world.Containers.dropItemStack( + level, + pos.getX() + .5, + pos.getY() + .5, + pos.getZ() + .5, + RecoveryCapsuleItem.create(payload))); + } + + @Override + public void clearContent() { + automation.clear(); + energy.setStored(0); + setChanged(); + } } diff --git a/src/companion/java/zone/moddev/mc/companion/DemoBlock.java b/src/companion/java/zone/moddev/mc/companion/DemoBlock.java index fbf2c8fa..93bf4fa2 100644 --- a/src/companion/java/zone/moddev/mc/companion/DemoBlock.java +++ b/src/companion/java/zone/moddev/mc/companion/DemoBlock.java @@ -3,19 +3,26 @@ import java.util.function.Supplier; import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; import net.minecraft.network.chat.Component; import net.minecraft.world.InteractionHand; import net.minecraft.world.InteractionResult; import net.minecraft.world.ItemInteractionResult; import net.minecraft.world.entity.player.Player; import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.context.BlockPlaceContext; import net.minecraft.world.level.Level; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.EntityBlock; +import net.minecraft.world.level.block.Mirror; +import net.minecraft.world.level.block.Rotation; import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.entity.BlockEntityTicker; import net.minecraft.world.level.block.entity.BlockEntityType; import net.minecraft.world.level.block.state.BlockState; +import 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.phys.BlockHitResult; import net.neoforged.neoforge.fluids.FluidUtil; @@ -25,11 +32,33 @@ /** Explicit block hooks for a code-registered machine. */ public final class DemoBlock extends Block implements EntityBlock { + public static final DirectionProperty FACING = BlockStateProperties.HORIZONTAL_FACING; private final Supplier> type; public DemoBlock(Properties properties, Supplier> type) { super(properties); this.type = type; + registerDefaultState(stateDefinition.any().setValue(FACING, 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 + public BlockState rotate(BlockState state, Rotation rotation) { + return state.setValue(FACING, rotation.rotate(state.getValue(FACING))); + } + + @Override + public BlockState mirror(BlockState state, Mirror mirror) { + return rotate(state, mirror.getRotation(state.getValue(FACING))); } @Override diff --git a/src/companion/java/zone/moddev/mc/companion/DemoEntity.java b/src/companion/java/zone/moddev/mc/companion/DemoEntity.java index dbd9b37f..23f28794 100644 --- a/src/companion/java/zone/moddev/mc/companion/DemoEntity.java +++ b/src/companion/java/zone/moddev/mc/companion/DemoEntity.java @@ -18,6 +18,14 @@ protected DemoEntity(BlockEntityType type, BlockPos pos, BlockState state) { public abstract void serverTick(); + public net.minecraft.core.Direction facing() { + return zone.moddev.mc.lib.api.capabilities.RelativeFace.facing(getBlockState()); + } + + public java.util.List screenStats() { + return java.util.List.of(energy); + } + public long getEnergy() { return energy.stored(); } diff --git a/src/companion/java/zone/moddev/mc/companion/ProcessorEntity.java b/src/companion/java/zone/moddev/mc/companion/ProcessorEntity.java index 9ee43cfb..d80cce9b 100644 --- a/src/companion/java/zone/moddev/mc/companion/ProcessorEntity.java +++ b/src/companion/java/zone/moddev/mc/companion/ProcessorEntity.java @@ -4,7 +4,6 @@ import java.util.List; import net.minecraft.core.BlockPos; -import net.minecraft.core.Direction; import net.minecraft.core.HolderLookup; import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.NbtOps; @@ -23,12 +22,12 @@ import net.neoforged.neoforge.fluids.FluidStack; import zone.moddev.mc.lib.api.automation.*; -import zone.moddev.mc.lib.api.redstone.RedstoneOutput; -import zone.moddev.mc.lib.api.redstone.RedstoneControl; import zone.moddev.mc.lib.api.capabilities.*; import zone.moddev.mc.lib.api.capabilities.FluidStorage; import zone.moddev.mc.lib.api.capabilities.ItemStorage; import zone.moddev.mc.lib.api.capabilities.Role; +import zone.moddev.mc.lib.api.redstone.RedstoneControl; +import zone.moddev.mc.lib.api.redstone.RedstoneOutput; import zone.moddev.mc.lib.common.runtime.DropsContents; import zone.moddev.mc.lib.gui.runtime.ScreenHost; import zone.moddev.mc.lib.machine.feature.FeatureHost; @@ -144,7 +143,7 @@ public ProcessorEntity(BlockEntityType type, BlockPos pos, BlockState state, new SideConfig( kind == Kind.CRUSHER ? IoMode.BOTH : (i < 2 ? IoMode.INPUT : IoMode.OUTPUT))); group.bind( - () -> Direction.NORTH, + this::facing, side -> fluids.view(index -> index == tank ? group.mode(side) : IoMode.DISABLED)); groups.add(group); } @@ -159,7 +158,7 @@ public ProcessorEntity(BlockEntityType type, BlockPos pos, BlockState state, List.of("energy"), new SideConfig(kind == Kind.LOGISTICS ? IoMode.BOTH : IoMode.INPUT)); if (kind == Kind.LOGISTICS) energy.setRates(1000, 1000); - energyGroup.bind(() -> Direction.NORTH, side -> energy.view(() -> energyGroup.mode(side))); + energyGroup.bind(this::facing, side -> energy.view(() -> energyGroup.mode(side))); groups.add(energyGroup); automation = new AutomationInventory(this, groups); var outputParts = new ArrayList(); @@ -208,7 +207,7 @@ private ResourceGroup itemGroup( .toList(), new SideConfig(mode)); return group.bind( - () -> Direction.NORTH, + this::facing, side -> items.view(index -> group.contains(index) ? group.mode(side) : IoMode.DISABLED)); } @@ -263,6 +262,16 @@ public int getProgress() { return lanes.getFirst().progress(); } + @Override + public List screenStats() { + var parts = new ArrayList(); + parts.add(energy); + parts.add(items); + parts.add(fluids); + parts.addAll(lanes); + return List.copyOf(parts); + } + public boolean hasTank() { return fluids.getTanks() > 0; } diff --git a/src/companion/resources/assets/mmdlib_companion/blockstates/creative_power.json b/src/companion/resources/assets/mmdlib_companion/blockstates/creative_power.json index 81d0b5b9..6fa6782c 100644 --- a/src/companion/resources/assets/mmdlib_companion/blockstates/creative_power.json +++ b/src/companion/resources/assets/mmdlib_companion/blockstates/creative_power.json @@ -1,7 +1,19 @@ { "variants": { - "": { + "facing=north": { "model": "mmdlib_companion:block/creative_power" + }, + "facing=east": { + "model": "mmdlib_companion:block/creative_power", + "y": 90 + }, + "facing=south": { + "model": "mmdlib_companion:block/creative_power", + "y": 180 + }, + "facing=west": { + "model": "mmdlib_companion:block/creative_power", + "y": 270 } } } diff --git a/src/companion/resources/assets/mmdlib_companion/blockstates/crusher.json b/src/companion/resources/assets/mmdlib_companion/blockstates/crusher.json index 460c7c36..665e73e6 100644 --- a/src/companion/resources/assets/mmdlib_companion/blockstates/crusher.json +++ b/src/companion/resources/assets/mmdlib_companion/blockstates/crusher.json @@ -1,7 +1,19 @@ { "variants": { - "": { + "facing=north": { "model": "mmdlib_companion:block/crusher" + }, + "facing=east": { + "model": "mmdlib_companion:block/crusher", + "y": 90 + }, + "facing=south": { + "model": "mmdlib_companion:block/crusher", + "y": 180 + }, + "facing=west": { + "model": "mmdlib_companion:block/crusher", + "y": 270 } } } diff --git a/src/companion/resources/assets/mmdlib_companion/blockstates/dual_crusher.json b/src/companion/resources/assets/mmdlib_companion/blockstates/dual_crusher.json index d2d04c54..d8e88838 100644 --- a/src/companion/resources/assets/mmdlib_companion/blockstates/dual_crusher.json +++ b/src/companion/resources/assets/mmdlib_companion/blockstates/dual_crusher.json @@ -1,7 +1,19 @@ { "variants": { - "": { + "facing=north": { "model": "mmdlib_companion:block/dual_crusher" + }, + "facing=east": { + "model": "mmdlib_companion:block/dual_crusher", + "y": 90 + }, + "facing=south": { + "model": "mmdlib_companion:block/dual_crusher", + "y": 180 + }, + "facing=west": { + "model": "mmdlib_companion:block/dual_crusher", + "y": 270 } } } diff --git a/src/companion/resources/assets/mmdlib_companion/blockstates/logistics.json b/src/companion/resources/assets/mmdlib_companion/blockstates/logistics.json index 5d17bc0f..5af58503 100644 --- a/src/companion/resources/assets/mmdlib_companion/blockstates/logistics.json +++ b/src/companion/resources/assets/mmdlib_companion/blockstates/logistics.json @@ -1,7 +1,19 @@ { "variants": { - "": { + "facing=north": { "model": "mmdlib_companion:block/logistics" + }, + "facing=east": { + "model": "mmdlib_companion:block/logistics", + "y": 90 + }, + "facing=south": { + "model": "mmdlib_companion:block/logistics", + "y": 180 + }, + "facing=west": { + "model": "mmdlib_companion:block/logistics", + "y": 270 } } } diff --git a/src/companion/resources/assets/mmdlib_companion/blockstates/transmuter.json b/src/companion/resources/assets/mmdlib_companion/blockstates/transmuter.json index 8bd9bed5..f4631d85 100644 --- a/src/companion/resources/assets/mmdlib_companion/blockstates/transmuter.json +++ b/src/companion/resources/assets/mmdlib_companion/blockstates/transmuter.json @@ -1,7 +1,19 @@ { "variants": { - "": { + "facing=north": { "model": "mmdlib_companion:block/transmuter" + }, + "facing=east": { + "model": "mmdlib_companion:block/transmuter", + "y": 90 + }, + "facing=south": { + "model": "mmdlib_companion:block/transmuter", + "y": 180 + }, + "facing=west": { + "model": "mmdlib_companion:block/transmuter", + "y": 270 } } } diff --git a/src/companion/resources/assets/mmdlib_companion/lang/en_us.json b/src/companion/resources/assets/mmdlib_companion/lang/en_us.json index 82157d80..c97be778 100644 --- a/src/companion/resources/assets/mmdlib_companion/lang/en_us.json +++ b/src/companion/resources/assets/mmdlib_companion/lang/en_us.json @@ -19,7 +19,8 @@ "screen.mmdlib_companion.lane_a": "Lane A", "screen.mmdlib_companion.lane_b": "Lane B", "screen.mmdlib_companion.single": "One iron in, one gold out", - "screen.mmdlib_companion.power": "Powers all six adjacent blocks", + "screen.mmdlib_companion.power": "Powers adjacent blocks on enabled sides", + "screen.mmdlib_companion.info.creative_power": "*Creative Power Source*\n\nSupplies up to 1,000 FE per enabled face each tick.\n\nUse the side configuration panel to enable or disable outputs. Statistics shows the measured output rate.", "screen.mmdlib_companion.rate": "1,000 FE per face per tick", "status.mmdlib_companion.stalled": "Needs power", "status.mmdlib_companion.blocked": "Blocked", diff --git a/src/companion/resources/mmdlib/screen/creative_power.xml b/src/companion/resources/mmdlib/screen/creative_power.xml index 4fc869d7..889ef8e6 100644 --- a/src/companion/resources/mmdlib/screen/creative_power.xml +++ b/src/companion/resources/mmdlib/screen/creative_power.xml @@ -4,7 +4,7 @@ - @@ -12,4 +12,5 @@ + diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/CompanionClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/CompanionClientSmoke.java index 5bccfa5d..c65a95bd 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/CompanionClientSmoke.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/CompanionClientSmoke.java @@ -39,6 +39,7 @@ public final class CompanionClientSmoke { private static boolean started, opening, menuRequested, finished; private static int stage, frames, waitingTicks, drawerStage, fluidStage, controlStage; private static boolean controlFixtureRequested; + private static boolean redstoneSmokeDone; private static volatile boolean controlFixtureReady; @SubscribeEvent @@ -156,6 +157,29 @@ public static void tick(ClientTickEvent.Post event) { 3); level.setBlock( POS, CompanionMod.MACHINES.get(current).block.get().defaultBlockState(), 3); + if (current == 3) { + // Player stands south of both machines: the western neighbour is on the left. + level.setBlock( + POS, + CompanionMod.CREATIVE_POWER + .block + .get() + .defaultBlockState() + .setValue( + zone.moddev.mc.companion.DemoBlock.FACING, + net.minecraft.core.Direction.SOUTH), + 3); + level.setBlock( + POS.west(), + CompanionMod.CRUSHER + .block + .get() + .defaultBlockState() + .setValue( + zone.moddev.mc.companion.DemoBlock.FACING, + net.minecraft.core.Direction.SOUTH), + 3); + } var entity = level.getBlockEntity(POS); if (entity instanceof ProcessorEntity machine) { machine.energy.setStored( @@ -227,7 +251,7 @@ public static void frame(RenderFrameEvent.Post event) { waitingTicks = 0; return; } - if (menu.automation != null) { + if (menu.automation != null && menu.bound.screen().playerInventory()) { var row = screen.layout.inventoryColors().orElseThrow(); var inventory = screen.layout.inventory().orElseThrow(); if (row.x() != inventory.x()) @@ -255,7 +279,19 @@ public static void frame(RenderFrameEvent.Post event) { throw new AssertionError("Energy must sit against the right panel margin"); if (!(menu.value(energy, false) instanceof Number)) throw new AssertionError("Energy was not synced"); - if (stage == 0 && !RedstoneClientSmoke.run(screen)) { + if (stage == 0 + && !redstoneSmokeDone + && !(redstoneSmokeDone = RedstoneClientSmoke.run(screen))) { + frames = 0; + waitingTicks = 0; + return; + } + if ((stage == 0 || stage == 2) && !StatsClientSmoke.run(screen)) { + frames = 0; + waitingTicks = 0; + return; + } + if (stage == 3 && !CreativePowerClientSmoke.run(screen)) { frames = 0; waitingTicks = 0; return; @@ -369,7 +405,7 @@ public static void frame(RenderFrameEvent.Post event) { screen.mouseClicked(tab.x() + 10, tab.y() + 10, 0); var exclusions = screen.exclusions(); if (exclusions.size() - != 3 + != 4 + (menu.redstone == null ? 0 : 1) + (menu.curiosDrawer.state().icons().isEmpty() ? 0 : 1) || exclusions.get(1).right() != tab.x()) @@ -388,7 +424,7 @@ public static void frame(RenderFrameEvent.Post event) { mc.gameDirectory, name + "-drawer.png", mc.getMainRenderTarget(), message -> {}); // The compact panel puts equipment tabs at the bottom of the same rail. // Exercise empty rail below the info tab, not another drawer's tab. - int railY = tab.bottom() + 4; + int railY = tab.bottom() + 1; // Gap before the Statistics tab. if (!screen.mouseClicked(tab.x() + 10, railY, 0)) throw new AssertionError("Clicks on the drawer rail must not pass through the screen"); // Vanilla returns true even for unused buttons. Observe its recorded press to verify @@ -408,7 +444,7 @@ public static void frame(RenderFrameEvent.Post event) { if (lastButton.getInt(screen) != button) throw new AssertionError("Drawer swallowed mouse button " + button); if (screen.exclusions().size() - != 3 + != 4 + (menu.redstone == null ? 0 : 1) + (menu.curiosDrawer.state().icons().isEmpty() ? 0 : 1)) throw new AssertionError("Extra mouse button changed the drawer state"); @@ -416,7 +452,7 @@ public static void frame(RenderFrameEvent.Post event) { screen.mouseScrolled(tab.x() - 10, tab.y() + 10, 0, -1); screen.mouseClicked(tab.x() + 10, tab.y() + 10, 0); if (screen.exclusions().size() - != 2 + != 3 + (menu.redstone == null ? 0 : 1) + (menu.curiosDrawer.state().icons().isEmpty() ? 0 : 1)) throw new AssertionError("The icon tab did not close its drawer"); diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/CompanionGameTests.java b/src/companionTest/java/zone/moddev/mc/companion/test/CompanionGameTests.java index 1be3f8e3..611d66d0 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/CompanionGameTests.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/CompanionGameTests.java @@ -38,6 +38,52 @@ public final class CompanionGameTests { private static final BlockPos CENTER = new BlockPos(2, 2, 2); + @GameTest(template = "empty") + public static void statisticsProvidersDescribeCurrentRecipes(GameTestHelper helper) { + var machine = place(helper, CompanionMod.DUAL_CRUSHER); + var first = machine.lanes.getFirst(); + require( + machine.items.statistics().rows().isEmpty() && machine.fluids.statistics().rows().isEmpty(), + "Item and fluid inventories contribute no statistics"); + require( + first.statistics().rows().get(1).value() + instanceof zone.moddev.mc.lib.api.stats.Statistics.Text, + "Idle processor maximum explicitly has no recipe"); + machine.items.setStackInSlot(0, new ItemStack(Items.COPPER_ORE, 16)); + machine.energy.setStored(100000); + machine.serverTick(); + long peak = 0; + for (var lane : machine.lanes) { + var section = lane.statistics(); + require( + section.title().equals(lane.displayName()) && section.rows().size() == 2, + "Processor provides a named section with exactly two rows"); + var maximum = (zone.moddev.mc.lib.api.stats.Statistics.Rate) section.rows().get(1).value(); + require( + maximum.amount().equals(java.math.BigInteger.ONE) && maximum.ticks() == 100, + "Maximum throughput uses the current copper recipe's duration"); + require( + section.maximumUses().size() == 1 + && section.maximumUses().getFirst().resource() == machine.energy, + "Demand is attributed to the backing buffer, not the group's wrapper"); + peak += section.maximumUses().getFirst().perTick().longValueExact(); + } + require(peak == 40, "Two current processors contribute 20 RF/t each"); + machine.serverTick(); + require( + machine.energy.used().equals(java.math.BigInteger.valueOf(40)) + && machine.energy.extracted().signum() == 0, + "Recipe consumption is used RF, not output RF"); + first.reset(); + require(first.statistics().maximumUses().isEmpty(), "Reset removes stale recipe demand"); + require( + first.statistics().rows().get(1).value() + instanceof zone.moddev.mc.lib.api.stats.Statistics.Text, + "Reset removes stale maximum cycle rate"); + helper.getLevel().setBlock(machine.getBlockPos(), Blocks.AIR.defaultBlockState(), 3); + helper.succeed(); + } + private static ScreenNetwork.Widget widgetPacket( MachineMenu menu, String element, @@ -497,6 +543,137 @@ public static void partialShiftClickExtractionMarksMachineForSave(GameTestHelper helper.succeed(); } + @GameTest(template = "empty") + public static void placementFacingKeepsNeighboursAndPortsOnTheSameSide(GameTestHelper helper) { + var level = helper.getLevel(); + var pos = helper.absolutePos(CENTER); + var player = helper.makeMockServerPlayerInLevel(); + var leftFace = zone.moddev.mc.lib.api.capabilities.RelativeFace.LEFT; + var disabled = zone.moddev.mc.lib.api.capabilities.IoMode.DISABLED; + var output = zone.moddev.mc.lib.api.capabilities.IoMode.OUTPUT; + for (var look : Direction.Plane.HORIZONTAL) { + player.setYRot(look.toYRot()); + var left = look.getCounterClockWise(); + for (var machine : CompanionMod.MACHINES) { + var context = + new net.minecraft.world.item.context.BlockPlaceContext( + player, + InteractionHand.MAIN_HAND, + new ItemStack(machine.block.get()), + new BlockHitResult(Vec3.atCenterOf(pos.below()), Direction.UP, pos.below(), false)); + var state = machine.block.get().getStateForPlacement(context); + require( + state.getValue(zone.moddev.mc.companion.DemoBlock.FACING) == look.getOpposite(), + "Placed machine faces its player: " + look); + level.setBlock(pos, state, 3); + var entity = (zone.moddev.mc.companion.DemoEntity) level.getBlockEntity(pos); + var host = (zone.moddev.mc.lib.gui.runtime.ScreenHost) entity; + for (var group : host.screenAutomation().groups()) + require( + group.world(leftFace) == left, "Every resource group uses placement-relative left"); + if (entity instanceof ProcessorEntity processor) + require( + processor.redstone.world(leftFace.ordinal()) == left, + "Redstone and resource drawers agree on left"); + var rotated = state.rotate(net.minecraft.world.level.block.Rotation.CLOCKWISE_90); + require( + rotated.getValue(zone.moddev.mc.companion.DemoBlock.FACING) + == look.getOpposite().getClockWise(), + "Rotation changes the stored facing"); + var mirrored = state.mirror(net.minecraft.world.level.block.Mirror.LEFT_RIGHT); + var expectedMirror = look.getAxis() == Direction.Axis.Z ? look : look.getOpposite(); + require( + mirrored.getValue(zone.moddev.mc.companion.DemoBlock.FACING) == expectedMirror, + "Mirror changes the stored facing"); + if (entity instanceof CreativePowerEntity power) { + level.setBlock( + pos.relative(left), + CompanionMod.CRUSHER.block.get().getStateForPlacement(context), + 3); + var receiver = (ProcessorEntity) level.getBlockEntity(pos.relative(left)); + require( + receiver.facing() == power.facing(), + "Both placements from one position share a facing"); + var group = power.automation.group(0); + for (var face : zone.moddev.mc.lib.api.capabilities.RelativeFace.values()) + group.sides().set(face, disabled); + group.sides().set(leftFace, output); + var port = level.getCapability(Capabilities.EnergyStorage.BLOCK, pos, left); + require(port.canExtract(), "Left capability is enabled"); + require( + !level + .getCapability(Capabilities.EnergyStorage.BLOCK, pos, left.getOpposite()) + .canExtract(), + "Right capability stays disabled"); + power.serverTick(); + require(receiver.energy.stored() == 1000, "Left output powers the left neighbour"); + group.sides().set(leftFace, disabled); + power.serverTick(); + require( + receiver.energy.stored() == 1000 && !port.canExtract(), + "Disabling left blocks the neighbour and the cached capability"); + group.sides().set(leftFace, output); + level.setBlock(pos, rotated, 3); + require( + level.getBlockEntity(pos) == power && group.world(leftFace) == left.getClockWise(), + "Existing group follows block rotation"); + require(!port.canExtract(), "Cached world-side capability follows rotation"); + level.setBlock(pos.relative(left), Blocks.AIR.defaultBlockState(), 3); + } + level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3); + } + } + helper.succeed(); + } + + @GameTest(template = "empty") + public static void creativePowerSidesGatePushAndPullAndPersist(GameTestHelper helper) { + var level = helper.getLevel(); + var pos = helper.absolutePos(CENTER); + level.setBlock(pos, CompanionMod.CREATIVE_POWER.block.get().defaultBlockState(), 3); + level.setBlock(pos.above(), CompanionMod.CRUSHER.block.get().defaultBlockState(), 3); + var power = (CreativePowerEntity) level.getBlockEntity(pos); + var receiver = (ProcessorEntity) level.getBlockEntity(pos.above()); + var port = level.getCapability(Capabilities.EnergyStorage.BLOCK, pos, Direction.UP); + var group = power.automation.group(0); + var top = zone.moddev.mc.lib.api.capabilities.RelativeFace.TOP; + var output = zone.moddev.mc.lib.api.capabilities.IoMode.OUTPUT; + var disabled = zone.moddev.mc.lib.api.capabilities.IoMode.DISABLED; + require(power.screenAutomation() == power.automation, "Generator exposes side drawer"); + var menu = new MachineMenu(46, helper.makeMockServerPlayerInLevel().getInventory(), power); + require( + menu.slots.isEmpty() + && menu.automation.slotCount() == 0 + && !menu.automation.addonsEnabled(), + "Generator side configuration exposes no inventory slots"); + require(menu.bound.screen().find("info").isPresent(), "Generator has an Info panel"); + require(group.cycle(top, output, false), "Output side can be disabled"); + require( + !port.canExtract() && port.extractEnergy(1000, false) == 0, + "Existing capability follows disabled side"); + power.serverTick(); + require(receiver.energy.stored() == 0, "Disabled side does not push energy"); + var saved = power.saveWithFullMetadata(level.registryAccess()); + var copy = + (CreativePowerEntity) + BlockEntity.loadStatic(pos, power.getBlockState(), saved, level.registryAccess()); + require(copy.automation.group(0).sides().get(top) == disabled, "Disabled side survives reload"); + saved.remove("automation"); + var legacy = + (CreativePowerEntity) + BlockEntity.loadStatic(pos, power.getBlockState(), saved, level.registryAccess()); + require( + legacy.automation.group(0).sides().get(top) == output, + "Older generators retain all outputs"); + require(group.cycle(top, disabled, false), "Disabled side cycles back to output"); + require(port.canExtract() && !port.canReceive(), "Generator remains output-only"); + power.serverTick(); + require(receiver.energy.stored() == 1000, "Re-enabled side pushes energy"); + level.setBlock(pos.above(), Blocks.AIR.defaultBlockState(), 3); + level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3); + helper.succeed(); + } + @GameTest(template = "empty") public static void creativePowerFeedsAllFaces(GameTestHelper helper) { var level = helper.getLevel(); @@ -674,6 +851,9 @@ public static void oneSlotInterruptAndResume(GameTestHelper helper) { machine.energy.setStored(0); machine.serverTick(); require(machine.lanes.getFirst().status() == Status.STALLED, "Stalls without power"); + require( + machine.lanes.getFirst().completedCycles().signum() == 0, + "Progress and energy stalls are not completed cycles"); var player = helper.makeMockServerPlayerInLevel(); player.setPos(pos.getX() + 0.5, pos.getY(), pos.getZ() + 0.5); @@ -686,12 +866,18 @@ public static void oneSlotInterruptAndResume(GameTestHelper helper) { machine.getPaused() && !machine.items.hasPlan(0), "The screen frees reservations without power"); require(automation.extractItem(0, 1, true).getCount() == 1, "Paused iron can be taken"); + require( + machine.lanes.getFirst().completedCycles().signum() == 0, + "Interrupts do not count as completed cycles"); menu.clickMenuButton(player, action); machine.energy.setStored(100_000); tick(machine, 150); require( machine.items.getStackInSlot(0).is(Items.GOLD_INGOT), "Resume finishes from saved progress"); + require( + machine.lanes.getFirst().completedCycles().equals(java.math.BigInteger.ONE), + "A resumed recipe counts exactly once when it finishes"); require(automation.extractItem(0, 1, false).is(Items.GOLD_INGOT), "Gold can be extracted"); level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3); helper.succeed(); @@ -722,6 +908,11 @@ public static void sharedLanesSurviveSaveAndInterrupt(GameTestHelper helper) { tick(copy, 70); require(copy.items.getStackInSlot(0).isEmpty(), "Both lanes consumed their own ore"); require(copy.items.getStackInSlot(1).getCount() == 4, "Both lanes produced exactly two copper"); + require( + copy.screenStats().containsAll(copy.lanes) + && copy.lanes.stream() + .allMatch(lane -> lane.completedCycles().equals(java.math.BigInteger.ONE)), + "Each processor exposes its own completed-cycle count"); machine.clearContent(); level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3); helper.succeed(); diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/CreativePowerClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/CreativePowerClientSmoke.java new file mode 100644 index 00000000..daec29b2 --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/CreativePowerClientSmoke.java @@ -0,0 +1,110 @@ +package zone.moddev.mc.companion.test; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; + +import zone.moddev.mc.lib.api.capabilities.IoMode; +import zone.moddev.mc.lib.api.capabilities.RelativeFace; +import zone.moddev.mc.lib.gui.client.MachineScreen; +import zone.moddev.mc.lib.gui.layout.AutomationLayout; + +/** Generator side settings round trip through the actual drawer and server. */ +final class CreativePowerClientSmoke { + private static int step; + + static boolean run(MachineScreen screen) { + var menu = screen.getMenu(); + var automation = menu.automation; + if (automation == null) throw new AssertionError("Generator must expose its energy drawer"); + if (automation.pending()) return false; + int top = RelativeFace.TOP.ordinal(); + switch (step) { + case 0 -> { + var left = automation.inventory.group(0).world(RelativeFace.LEFT); + if (left != net.minecraft.core.Direction.WEST + || !menu.blockEntity + .getLevel() + .getBlockState(menu.blockEntity.getBlockPos().relative(left)) + .is(zone.moddev.mc.companion.CompanionMod.CRUSHER.block.get())) + throw new AssertionError( + "Generator drawer places the western crusher on its left when facing south"); + if (!menu.slots.isEmpty() + || automation.addonsEnabled() + || screen.layout.inventory().isPresent() + || screen.layout.inventoryColors().isPresent() + || !menu.containerDrawer.state().icons().isEmpty() + || !menu.curiosDrawer.state().icons().isEmpty()) + throw new AssertionError("Generator has no inventory, addon slots or equipment drawers"); + if (menu.stats(0).size() != 3 + || menu.stats(0).stream().anyMatch(c -> c.getString().contains("RF used"))) + throw new AssertionError("Generator statistics only show output and output limit"); + if (menu.stats(2).stream().noneMatch(c -> c.getString().contains("1,200,000 RF/min"))) + throw new AssertionError("Generator maximum output follows the minute timescale"); + var panel = screen.panelBounds(); + click(screen, panel.right() + 5, panel.y() + AutomationLayout.TOP + 5); + } + case 1 -> { + if (!automation.open()) throw new AssertionError("Generator side tab opens drawer"); + var drawer = + screen.exclusions().stream() + .filter(r -> r.x() >= screen.panelBounds().right() && r.width() > 22) + .findFirst() + .orElseThrow(); + if (drawer.height() + != AutomationLayout.face(RelativeFace.BOTTOM.ordinal()).bottom() + + AutomationLayout.PADDING) + throw new AssertionError("Side-only drawer ends below the faces without addon space"); + var face = AutomationLayout.face(top).offset(drawer.x(), drawer.y()); + click(screen, face.x() + 8, face.y() + 8); + } + case 2 -> { + if (automation.mode(0, top) != IoMode.DISABLED) + throw new AssertionError("Generator top face disabled through client/server action"); + var info = screen.bounds(menu.bound.screen().find("info").orElseThrow()); + click(screen, info.x() + 5, info.bottom() + 7); + } + case 3 -> { + var panel = screen.panelBounds(); + int x = panel.x() - AutomationLayout.RAIL - 35; + int y = panel.y() + AutomationLayout.TOP + 10; + click(screen, x, y); + click(screen, x, y); + } + case 4 -> + Screenshot.grab( + Minecraft.getInstance().gameDirectory, + "creative-power-sides-stats.png", + Minecraft.getInstance().getMainRenderTarget(), + m -> {}); + case 5 -> automation.send("face", 0, -1, IoMode.DISABLED.name(), top, ""); + case 6 -> { + if (automation.mode(0, top) != IoMode.OUTPUT) + throw new AssertionError("Generator top face re-enabled"); + screen.keyPressed(256, 0, 0); + var info = screen.bounds(menu.bound.screen().find("info").orElseThrow()); + click(screen, info.x() + 5, info.y() + 5); + } + case 7 -> + Screenshot.grab( + Minecraft.getInstance().gameDirectory, + "creative-power-info.png", + Minecraft.getInstance().getMainRenderTarget(), + m -> {}); + case 8 -> { + var info = screen.bounds(menu.bound.screen().find("info").orElseThrow()); + click(screen, info.x() + 5, info.y() + 5); + automation.send("close", 0, -1, "", 0, ""); + } + default -> { + return true; + } + } + step++; + return false; + } + + private static void click(MachineScreen screen, int x, int y) { + screen.mouseClicked(x, y, 0); + screen.mouseReleased(x, y, 0); + } +} diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/StatsClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/StatsClientSmoke.java new file mode 100644 index 00000000..49c298f1 --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/StatsClientSmoke.java @@ -0,0 +1,127 @@ +package zone.moddev.mc.companion.test; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; + +import zone.moddev.mc.lib.gui.client.MachineScreen; +import zone.moddev.mc.lib.gui.layout.AutomationLayout; +import zone.moddev.mc.lib.gui.layout.Rect; + +/** Real synced throughput rows, left drawer switching and the cycling timescale control. */ +final class StatsClientSmoke { + private static final java.util.Map progress = new java.util.WeakHashMap<>(); + + static boolean run(MachineScreen screen) { + var state = progress.computeIfAbsent(screen, ignored -> new int[2]); + int step = state[0]; + if (step > 11) return true; + if (++state[1] < 2) return false; + var menu = screen.getMenu(); + var panel = screen.panelBounds(); + var info = screen.bounds(menu.bound.screen().find("info").orElseThrow()); + var tab = + new Rect( + panel.x() - AutomationLayout.RAIL, + info.bottom() + 2, + AutomationLayout.RAIL, + AutomationLayout.TAB_HEIGHT); + int x = tab.x() - Math.min(160, tab.x() - 4), top = panel.y() + AutomationLayout.TOP; + String name = menu.bound.screen().id().getPath(); + var mc = Minecraft.getInstance(); + switch (step) { + case 0 -> { + if (menu.stats(0).isEmpty() + || menu.containerDrawer.pending() + || menu.curiosDrawer.pending()) return false; + int processors = name.contains("dual") ? 2 : 1; + boolean consuming = + menu.stats(0).stream().anyMatch(c -> c.getString().startsWith("RF used:")); + if (menu.stats(0).size() != (consuming ? 5 : 3) + 4 * processors) + throw new AssertionError( + "Statistics should contain directional energy rows, conditional usage and two rows" + + " under each processor header"); + if (menu.stats(0).stream() + .filter( + c -> + c.getString().contains("cycles/t") + || c.getString().contains("Max cycles: No recipe")) + .count() + != 2 * processors) + throw new AssertionError("Each processor must have an independent cycle rate"); + click(screen, info.x() + 5, info.y() + 5, 0); + } + case 1 -> { + // Opening Info can close a restored equipment drawer through a server round trip. + if (menu.containerDrawer.pending() || menu.curiosDrawer.pending()) return false; + click(screen, tab.x() + 5, tab.y() + 5, 0); + } + case 2 -> { + requireUnit(screen, 0); + if (screen.exclusions().stream().noneMatch(r -> r.right() == tab.x() && r.width() == 160)) + throw new AssertionError( + "Statistics must expand to the left, under Info: " + + name + + " " + + screen.exclusions()); + Screenshot.grab( + mc.gameDirectory, name + "-stats-tick.png", mc.getMainRenderTarget(), m -> {}); + } + case 3 -> { + click(screen, tab.x() - 35, top + 10, 0); + requireUnit(screen, 1); + } + case 4 -> + Screenshot.grab( + mc.gameDirectory, name + "-stats-second.png", mc.getMainRenderTarget(), m -> {}); + case 5 -> { + click(screen, tab.x() - 35, top + 10, 0); + requireUnit(screen, 2); + } + case 6 -> + Screenshot.grab( + mc.gameDirectory, name + "-stats-minute.png", mc.getMainRenderTarget(), m -> {}); + case 7 -> { + click(screen, tab.x() - 35, top + 10, 0); + requireUnit(screen, 0); + click(screen, tab.x() - 35, top + 10, 1); + requireUnit(screen, 2); + } + case 8 -> + Screenshot.grab( + mc.gameDirectory, name + "-stats-reverse.png", mc.getMainRenderTarget(), m -> {}); + case 9 -> click(screen, info.x() + 5, info.y() + 5, 0); + case 10 -> { + if (screen.exclusions().stream().anyMatch(r -> r.right() == tab.x() && r.width() == 160)) + throw new AssertionError("Info replaces Statistics on the left"); + click(screen, tab.x() + 5, tab.y() + 5, 0); + } + case 11 -> { + screen.keyPressed(256, 0, 0); + if (mc.screen != screen) + throw new AssertionError("Escape closes Statistics before the machine"); + } + } + state[0]++; + state[1] = 0; + return false; + } + + private static void requireUnit(MachineScreen screen, int expected) { + try { + var field = MachineScreen.class.getDeclaredField("stats"); + field.setAccessible(true); + var drawer = field.get(screen); + var unit = drawer.getClass().getDeclaredField("unit"); + unit.setAccessible(true); + if (unit.getInt(drawer) != expected) + throw new AssertionError("Timescale did not cycle to " + expected); + } catch (ReflectiveOperationException error) { + throw new AssertionError(error); + } + } + + private static void click(MachineScreen screen, int x, int y, int button) { + screen.mouseClicked(x, y, button); + screen.mouseReleased(x, y, button); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/api/capabilities/EnergyBuffer.java b/src/main/java/zone/moddev/mc/lib/api/capabilities/EnergyBuffer.java index 5c685288..f1278858 100644 --- a/src/main/java/zone/moddev/mc/lib/api/capabilities/EnergyBuffer.java +++ b/src/main/java/zone/moddev/mc/lib/api/capabilities/EnergyBuffer.java @@ -10,13 +10,59 @@ import net.neoforged.neoforge.common.util.INBTSerializable; import net.neoforged.neoforge.energy.IEnergyStorage; +import zone.moddev.mc.lib.api.stats.Statistics; +import zone.moddev.mc.lib.api.stats.StatsProvider; + /** * A machine's energy, counted as a {@code long} so large buffers do not overflow. Automation sees * it through {@link IEnergyStorage}, whose {@code int} values are clamped, and is limited to the * transfer rates. The owning block entity uses {@link #insert} and {@link #extract}, which ignore * the rates. */ -public class EnergyBuffer implements IEnergyStorage, INBTSerializable { +public class EnergyBuffer implements IEnergyStorage, INBTSerializable, StatsProvider { + private java.math.BigInteger inserted = java.math.BigInteger.ZERO; + private java.math.BigInteger extracted = java.math.BigInteger.ZERO; + private java.math.BigInteger used = java.math.BigInteger.ZERO; + + public java.math.BigInteger used() { + return used; + } + + @Override + public Statistics statistics() { + var rows = new java.util.ArrayList(); + if (canReceive()) rows.add(Statistics.rf("in", new Statistics.Counter(inserted, 20))); + if (canExtract()) rows.add(Statistics.rf("out", new Statistics.Counter(extracted, 20))); + if (canReceive()) rows.add(Statistics.transfer("in_max", maxInsert)); + if (canExtract()) rows.add(Statistics.transfer("out_max", maxExtract)); + rows.add(Statistics.rf("used", new Statistics.Counter(used, 20)).whenUsed(this)); + rows.add(Statistics.rf("used_max", new Statistics.MaximumUse(this)).whenUsed(this)); + return new Statistics(Statistics.label("energy"), rows); + } + + /** Runtime transfer counters. Loading, resizing and direct assignments are not transfers. */ + public java.math.BigInteger inserted() { + return inserted; + } + + public java.math.BigInteger extracted() { + return extracted; + } + + /** Captures stored energy and transfer counters for a synchronous transaction rollback. */ + public Runnable checkpoint() { + long before = stored; + var beforeIn = inserted; + var beforeOut = extracted; + var beforeUsed = used; + return () -> { + inserted = beforeIn; + extracted = beforeOut; + used = beforeUsed; + setStored(before); + }; + } + private long stored; private long capacity; private long maxInsert; @@ -89,20 +135,27 @@ public void setRates(long maxInsert, long maxExtract) { /** Adds up to {@code amount}, ignoring the transfer rate. Returns the amount added. */ public long insert(long amount, boolean simulate) { long accepted = Math.clamp(amount, 0, space()); - if (!simulate && accepted > 0) setStored(stored + accepted); + if (!simulate && accepted > 0) { + inserted = inserted.add(java.math.BigInteger.valueOf(accepted)); + setStored(stored + accepted); + } return accepted; } /** Removes up to {@code amount}, ignoring the transfer rate. Returns the amount removed. */ public long extract(long amount, boolean simulate) { long removed = Math.clamp(amount, 0, stored); - if (!simulate && removed > 0) setStored(stored - removed); + if (!simulate && removed > 0) { + extracted = extracted.add(java.math.BigInteger.valueOf(removed)); + setStored(stored - removed); + } return removed; } /** Removes exactly {@code amount} if that much is stored, e.g. to run one tick of work. */ public boolean consume(long amount) { if (amount < 0 || amount > stored) return false; + used = used.add(java.math.BigInteger.valueOf(amount)); setStored(stored - amount); return true; } diff --git a/src/main/java/zone/moddev/mc/lib/api/capabilities/FluidStorage.java b/src/main/java/zone/moddev/mc/lib/api/capabilities/FluidStorage.java index fb6e5df6..b120622c 100644 --- a/src/main/java/zone/moddev/mc/lib/api/capabilities/FluidStorage.java +++ b/src/main/java/zone/moddev/mc/lib/api/capabilities/FluidStorage.java @@ -17,6 +17,7 @@ import net.neoforged.neoforge.fluids.capability.IFluidHandler; import net.neoforged.neoforge.fluids.capability.templates.FluidTank; +import zone.moddev.mc.lib.api.stats.StatsProvider; import zone.moddev.mc.lib.machine.inventory.ReservationPlans; import zone.moddev.mc.lib.machine.inventory.TankPlan; import zone.moddev.mc.lib.machine.neoforge.FluidStacks; @@ -30,7 +31,12 @@ * accepts it. Draining picks the first tank holding the requested fluid, or for an amount, the * first tank that is not empty. */ -public class FluidStorage implements IFluidHandler, INBTSerializable { +public class FluidStorage implements IFluidHandler, INBTSerializable, StatsProvider { + @Override + public zone.moddev.mc.lib.api.stats.Statistics statistics() { + return zone.moddev.mc.lib.api.stats.Statistics.EMPTY; + } + private final List tanks = new ArrayList<>(); private final List roles = new ArrayList<>(); private final ReservationPlans plans = diff --git a/src/main/java/zone/moddev/mc/lib/api/capabilities/ItemStorage.java b/src/main/java/zone/moddev/mc/lib/api/capabilities/ItemStorage.java index 85ade1f6..853c39d3 100644 --- a/src/main/java/zone/moddev/mc/lib/api/capabilities/ItemStorage.java +++ b/src/main/java/zone/moddev/mc/lib/api/capabilities/ItemStorage.java @@ -14,6 +14,8 @@ import net.neoforged.neoforge.items.IItemHandler; import net.neoforged.neoforge.items.ItemStackHandler; +import zone.moddev.mc.lib.api.stats.StatsProvider; + /** * A machine inventory: an {@link ItemStackHandler} with a {@link Role}, filter and limit per slot, * and a change callback. The owning block entity and its screen use it directly; automation gets a @@ -23,7 +25,12 @@ * refuse placement, so players cannot put items into outputs. The machine fills outputs with {@link * #insertResult}. */ -public class ItemStorage extends PlannedItemHandler { +public class ItemStorage extends PlannedItemHandler implements StatsProvider { + @Override + public zone.moddev.mc.lib.api.stats.Statistics statistics() { + return zone.moddev.mc.lib.api.stats.Statistics.EMPTY; + } + private final Role[] roles; private final int[] limits; diff --git a/src/main/java/zone/moddev/mc/lib/api/stats/Statistics.java b/src/main/java/zone/moddev/mc/lib/api/stats/Statistics.java new file mode 100644 index 00000000..19e11d27 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/api/stats/Statistics.java @@ -0,0 +1,115 @@ +package zone.moddev.mc.lib.api.stats; + +import java.math.BigInteger; +import java.util.List; +import java.util.Objects; + +import net.minecraft.network.chat.Component; + +/** + * Immutable descriptions of a part's statistics; sampling, formatting and sync belong to the UI. + */ +public record Statistics(Component title, List rows, List maximumUses) { + public static final Statistics EMPTY = new Statistics(Component.empty(), List.of(), List.of()); + + public Statistics { + title = Objects.requireNonNull(title).copy(); + rows = List.copyOf(rows); + maximumUses = List.copyOf(maximumUses); + if (rows.size() > 64 || rows.stream().map(Row::id).distinct().count() != rows.size()) + throw new IllegalArgumentException("Statistics need at most 64 rows with unique IDs"); + } + + public Statistics(Component title, List rows) { + this(title, rows, List.of()); + } + + /** Stable IDs keep each row's counter history independent of order and optional rows. */ + public record Row( + String id, + Component label, + Value value, + Component unit, + int decimals, + boolean perTimescale, + Object demandResource) { + public Row( + String id, + Component label, + Value value, + Component unit, + int decimals, + boolean perTimescale) { + this(id, label, value, unit, decimals, perTimescale, null); + } + + /** Show this row only while a provider declares positive demand for this resource. */ + public Row whenUsed(Object resource) { + return new Row( + id, label, value, unit, decimals, perTimescale, Objects.requireNonNull(resource)); + } + + public Row { + Objects.requireNonNull(id); + label = Objects.requireNonNull(label).copy(); + Objects.requireNonNull(value); + unit = Objects.requireNonNull(unit).copy(); + if (decimals < 0 || decimals > 8) throw new IllegalArgumentException("Decimal precision"); + } + } + + public sealed interface Value permits Counter, Rate, Text, MaximumUse {} + + /** A lifetime counter, measured over the most recent window (20..1200 game ticks). */ + public record Counter(BigInteger total, int windowTicks) implements Value { + public Counter { + if (total.signum() < 0 || windowTicks < 20 || windowTicks > 1200) + throw new IllegalArgumentException("Invalid statistics counter"); + } + } + + /** Exact amount per number of game ticks; a non-timescale row uses this as a fixed quantity. */ + public record Rate(BigInteger amount, long ticks) implements Value { + public Rate { + if (amount.signum() < 0 || ticks <= 0) + throw new IllegalArgumentException("Invalid statistics rate"); + } + } + + public record Text(Component text) implements Value { + public Text { + text = Objects.requireNonNull(text).copy(); + } + } + + /** Resolves the combined demands of all registered providers against this exact resource. */ + public record MaximumUse(Object resource) implements Value { + public MaximumUse { + Objects.requireNonNull(resource); + } + } + + /** Maximum recipe demand per working tick. Resources are matched by identity, not equality. */ + public record EnergyUse(Object resource, BigInteger perTick) { + public EnergyUse { + Objects.requireNonNull(resource); + if (perTick.signum() < 0) throw new IllegalArgumentException("Negative energy demand"); + } + } + + public static Component label(String key) { + return Component.translatable("screen.mmdlib.stats." + key); + } + + public static Row rf(String id, Value value) { + return new Row(id, label(id), value, Component.literal("RF"), 0, true); + } + + public static Row transfer(String id, long maximum) { + return rf(id, new Rate(BigInteger.valueOf(maximum), 1)); + } + + public static Row cycles(String id, Value value) { + return new Row(id, label(id), value, label("cycles_unit"), 2, true); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/api/stats/StatsProvider.java b/src/main/java/zone/moddev/mc/lib/api/stats/StatsProvider.java new file mode 100644 index 00000000..9d957b7e --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/api/stats/StatsProvider.java @@ -0,0 +1,7 @@ +package zone.moddev.mc.lib.api.stats; + +/** A machine part's server-side statistics. Returning an empty section adds no drawer rows. */ +@FunctionalInterface +public interface StatsProvider { + Statistics statistics(); +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/AutomationDrawer.java b/src/main/java/zone/moddev/mc/lib/gui/client/AutomationDrawer.java index 946e3faf..2a5ef8a2 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/AutomationDrawer.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/AutomationDrawer.java @@ -94,7 +94,11 @@ public void layout() { hasFilterSlot |= model.supportsFilter(model.selected(), c); hasFilter |= !model.filter(model.selected(), c).isEmpty(); } - int contentHeight = AutomationLayout.contentHeight(hasFilterSlot, hasFilter); + int contentHeight = + model.addonsEnabled() + ? AutomationLayout.contentHeight(hasFilterSlot, hasFilter) + : AutomationLayout.face(RelativeFace.BOTTOM.ordinal()).bottom() + + AutomationLayout.PADDING; int baseHeight = Math.max( contentHeight, @@ -332,7 +336,7 @@ public void drawBackground(GuiGraphics g, int mx, int my) { arrow(g, new Rect(selector.x() + 3, selector.bottom() + 1, 8, 6), true, true); } for (int f = 0; f < 6; f++) drawFace(g, f, mx, my); - for (int c = 0; c < 4; c++) { + for (int c = 0; model.addonsEnabled() && c < 4; c++) { sprite(g, "slot", module(c)); if (module(c).contains(mx, my) && model.addon(model.selected(), c).isEmpty()) tooltip = diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/DrawerMemory.java b/src/main/java/zone/moddev/mc/lib/gui/client/DrawerMemory.java index 6929263c..efb8f7c1 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/DrawerMemory.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/DrawerMemory.java @@ -2,8 +2,11 @@ import java.util.EnumMap; import java.util.Map; + import net.minecraft.client.Minecraft; + import net.neoforged.neoforge.network.PacketDistributor; + import zone.moddev.mc.lib.gui.model.Element; import zone.moddev.mc.lib.gui.runtime.ContainerDrawerMenu; @@ -35,6 +38,11 @@ void userInput() { restoring.clear(); } + void statsIntent(boolean open) { + if (open) remembered.put(Element.Edge.LEFT, new Selection("stats", "", 0)); + else remembered.remove(Element.Edge.LEFT); + } + void restore(MachineScreen screen, java.util.Set open) { if (++ticks > 100) { restoring.clear(); @@ -76,6 +84,9 @@ void restore(MachineScreen screen, java.util.Set open) { restoring.remove(edge); screen.closeLeftDrawers(); } + } else if (selection.kind().equals("stats")) { + if (!screen.stats.show()) continue; + restoring.remove(edge); } else if (selection.kind().equals("redstone")) { var redstone = menu.redstone; if (redstone == null) { @@ -176,6 +187,7 @@ void capture(MachineScreen screen, java.util.Set open) { state.keys().get(state.selected()), state.offset()); } + if (screen.stats.open) selection = new Selection("stats", "", 0); } else if (menu.redstone != null && menu.redstone.open()) { selection = new Selection("redstone", "", menu.redstone.selected()); } else if (menu.automation != null && menu.automation.open()) { diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/MachineScreen.java b/src/main/java/zone/moddev/mc/lib/gui/client/MachineScreen.java index cafa9bad..ba607b55 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/MachineScreen.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/MachineScreen.java @@ -25,6 +25,7 @@ public class MachineScreen extends AbstractContainerScreen { public ScreenLayout layout; private final AutomationDrawer automation; private final RedstoneDrawer redstone; + final StatsDrawer stats = new StatsDrawer(this); private final ReservationOverlay reservations = new ReservationOverlay(); private final ContainerDrawer containerDrawer; private final ContainerDrawer curiosDrawer; @@ -227,16 +228,39 @@ void rememberRedstoneIntent(int kind, int face) { drawerMemory.redstoneIntent(menu.redstone.open(), kind, face); } + void rememberStatsIntent() { + drawerMemory.statsIntent(stats.open); + } + void closeRightPopouts() { open.removeIf(p -> p.edge() == Element.Edge.RIGHT); } void closeLeftDrawers() { + stats.open = false; open.removeIf(p -> p.edge() == Element.Edge.LEFT); } - boolean onDrawerTab(double x, double y) { + void openStatsDrawer() { + closeLeftDrawers(); + containerDrawer.close(); + curiosDrawer.close(); + } + + boolean onOtherLeftDrawerTab(double x, double y) { return containerDrawer.tabContains(x, y) + || curiosDrawer.tabContains(x, y) + || menu.bound.screen().tabs().stream() + .anyMatch( + e -> + e instanceof Element.Popout p + && p.edge() == Element.Edge.LEFT + && bounds(e).contains(x, y)); + } + + boolean onDrawerTab(double x, double y) { + return stats.tabContains(x, y) + || containerDrawer.tabContains(x, y) || curiosDrawer.tabContains(x, y) || menu.bound.screen().tabs().stream().anyMatch(e -> bounds(e).contains(x, y)); } @@ -321,11 +345,13 @@ protected void renderBg(GuiGraphics g, float partial, int mx, int my) { }); if (automation != null) automation.drawBackground(g, mx, my); if (redstone != null) redstone.render(g, mx, my); - if (automation != null) automation.renderTab(g); containerDrawer.render(g, mx, my); curiosDrawer.render(g, mx, my); + stats.render(g, mx, my); containerDrawer.renderTab(g); curiosDrawer.renderTab(g); + if (automation != null) automation.renderTab(g); + if (redstone != null) redstone.renderTab(g); } private void draw(GuiGraphics g, Element e, Rect r, int mx, int my, float partial) { @@ -581,6 +607,7 @@ public List exclusions() { if (redstone != null) result.addAll(redstone.exclusions()); result.addAll(containerDrawer.exclusions()); result.addAll(curiosDrawer.exclusions()); + result.addAll(stats.exclusions()); return result; } @@ -673,6 +700,7 @@ public void render(GuiGraphics g, int mx, int my, float partial) { if (redstone != null) redstone.tooltip(g, mx, my); containerDrawer.tooltip(g, mx, my); curiosDrawer.tooltip(g, mx, my); + stats.tooltip(g, mx, my); } private void hover(Element e, boolean enter) { @@ -773,6 +801,10 @@ private void send(Element e, int kind, int button, int option) { @Override public boolean mouseClicked(double mx, double my, int button) { drawerMemory.userInput(); + if (stats.click(mx, my, button)) { + widgetInput.clearFocus(); + return true; + } if (containerDrawer.click(mx, my, button) || curiosDrawer.click(mx, my, button)) { widgetInput.clearFocus(); return true; @@ -809,6 +841,7 @@ public boolean mouseClicked(double mx, double my, int button) { if (button > 1) continue; if (e instanceof Element.Popout p) { if (p.edge() == Element.Edge.LEFT) { + stats.open = false; containerDrawer.close(); curiosDrawer.close(); } @@ -895,6 +928,7 @@ private void back(Element.Context c) { @Override public boolean keyPressed(int key, int scan, int modifiers) { drawerMemory.userInput(); + if (stats.key(key)) return true; // A lost drawer acknowledgment must never trap the player in the screen. if ((menu.containerDrawer.pending() || menu.curiosDrawer.pending() @@ -940,6 +974,7 @@ public void removed() { @Override public boolean mouseScrolled(double mx, double my, double dx, double dy) { + if (stats.scroll(mx, my, dy)) return true; if (curiosDrawer.scroll(mx, my, dy) || containerDrawer.scroll(mx, my, dy)) return true; if (redstone != null && redstone.scroll(mx, my, dy)) return true; if (automation != null && automation.scroll(mx, my, dy)) return true; @@ -979,6 +1014,7 @@ public void mouseMoved(double x, double y) { @Override public boolean mouseDragged(double x, double y, int button, double dx, double dy) { + if (stats.drag()) return true; if (containerDrawer.drag(x, y, button) || curiosDrawer.drag(x, y, button)) return true; if (redstone != null && redstone.drag(x, y, button)) return true; if (automation != null && automation.drag(x, y, button, dx, dy)) return true; @@ -988,6 +1024,7 @@ public boolean mouseDragged(double x, double y, int button, double dx, double dy @Override public boolean mouseReleased(double x, double y, int button) { + if (stats.release()) return true; boolean curiosReleased = curiosDrawer.release(x, y, button); if (containerDrawer.release(x, y, button) || curiosReleased) return true; if (redstone != null && redstone.release(x, y, button)) return true; @@ -1011,7 +1048,8 @@ public boolean keyReleased(int key, int scan, int modifiers) { @Override protected boolean hasClickedOutside(double x, double y, int left, int top, int button) { - return (redstone == null || !redstone.contains(x, y)) + return !stats.contains(x, y) + && (redstone == null || !redstone.contains(x, y)) && (automation == null || !automation.contains(x, y)) && !containerDrawer.contains(x, y) && !curiosDrawer.contains(x, y) diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/RedstoneDrawer.java b/src/main/java/zone/moddev/mc/lib/gui/client/RedstoneDrawer.java index 0f5c745d..720977c4 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/RedstoneDrawer.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/RedstoneDrawer.java @@ -1,16 +1,18 @@ package zone.moddev.mc.lib.gui.client; import java.util.*; + import net.minecraft.ChatFormatting; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.Font; -import net.minecraft.locale.Language; -import net.minecraft.util.FormattedCharSequence; import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.locale.Language; import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.FormattedCharSequence; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; + import zone.moddev.mc.lib.api.capabilities.RelativeFace; import zone.moddev.mc.lib.api.redstone.*; import zone.moddev.mc.lib.gui.layout.*; @@ -387,6 +389,12 @@ void tooltip(GuiGraphics g, int x, int y) { if (!tooltip.isEmpty()) g.renderComponentTooltip(Minecraft.getInstance().font, tooltip, x, y); } + void renderTab(GuiGraphics g) { + layout(); + SideDrawerRenderer.tab(g, tab, 0xc78181); + g.renderItem(tabIcon, tab.x() + 3, tab.y() + 4); + } + List exclusions() { layout(); return model.open() ? List.of(SideDrawerLayout.rail(tab, body), body) : List.of(tab); diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/StatsDrawer.java b/src/main/java/zone/moddev/mc/lib/gui/client/StatsDrawer.java new file mode 100644 index 00000000..1155756b --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/StatsDrawer.java @@ -0,0 +1,208 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.*; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.network.chat.Component; +import net.minecraft.util.FormattedCharSequence; + +import zone.moddev.mc.lib.gui.layout.*; +import zone.moddev.mc.lib.gui.model.Element; + +/** Read-only statistics; unit selection and scrolling are entirely local. */ +final class StatsDrawer { + private final MachineScreen screen; + boolean open; + private int unit, scroll; + private boolean handled; + private Rect tab, body; + private List lines = List.of(); + private List cachedText = List.of(); + private int cachedWidth = -1; + private net.minecraft.client.gui.Font cachedFont; + private net.minecraft.locale.Language cachedLanguage; + private static final int COLOUR = 0x85b9c7; + private static final float SCALE = .75f; + + StatsDrawer(MachineScreen screen) { + this.screen = screen; + } + + private Component text(String key) { + return Component.translatable("screen.mmdlib.stats." + key); + } + + void layout() { + var panel = screen.panelBounds(); + int y = panel.y() + AutomationLayout.TOP; + for (var e : screen.getMenu().bound.screen().tabs()) + if (e instanceof Element.Popout p && p.edge() == Element.Edge.LEFT) + y = Math.max(y, screen.bounds(e).bottom() + 2); + tab = + new Rect( + panel.x() - AutomationLayout.RAIL, + y, + AutomationLayout.RAIL, + AutomationLayout.TAB_HEIGHT); + if (!open) return; + int width = Math.max(0, Math.min(160, tab.x() - 4)); + var font = Minecraft.getInstance().font; + var text = screen.getMenu().stats(unit); + var language = net.minecraft.locale.Language.getInstance(); + if (!text.equals(cachedText) + || width != cachedWidth + || font != cachedFont + || language != cachedLanguage) { + cachedText = text; + cachedWidth = width; + cachedFont = font; + cachedLanguage = language; + lines = + text.stream() + .flatMap(c -> font.split(c, Math.max(1, (int) ((width - 12) / SCALE))).stream()) + .toList(); + } + body = + SideDrawerLayout.body( + panel, + screen.drawerBottom(), + tab.x() - width, + panel.y() + AutomationLayout.TOP, + width, + 28 + lines.size() * 9, + SideDrawerLayout.HeightLimit.SCREEN); + scroll = Math.clamp(scroll, 0, Math.max(0, lines.size() * 9 - Math.max(0, body.height() - 28))); + } + + boolean show() { + var menu = screen.getMenu(); + if (menu.containerDrawer.pending() || menu.curiosDrawer.pending()) return false; + screen.openStatsDrawer(); + open = true; + return true; + } + + void render(GuiGraphics g, int mx, int my) { + layout(); + if (open) SideDrawerRenderer.open(g, tab, body, Element.Edge.LEFT, COLOUR); + else SideDrawerRenderer.tab(g, tab, COLOUR); + // Small bar-chart glyph, drawn at native GUI resolution. + for (int i = 0; i < 3; i++) + g.fill( + tab.x() + 5 + i * 5, tab.y() + 14 - i * 4, tab.x() + 8 + i * 5, tab.y() + 19, 0xff30545e); + if (!open || body.width() < 12 || body.height() < 12) return; + var font = Minecraft.getInstance().font; + g.enableScissor(body.x() + 5, body.y() + 4, body.right() - 5, body.bottom() - 4); + var r = unitBounds(); + g.enableScissor(body.x() + 5, body.y() + 4, Math.max(body.x() + 5, r.x() - 4), body.y() + 19); + g.drawString(font, text("title"), body.x() + 6, body.y() + 6, 0xff263e45, false); + g.disableScissor(); + boolean hover = r.contains(mx, my); + g.fill(r.x(), r.y(), r.right(), r.bottom(), 0xff486e78); + g.fill(r.x() + 1, r.y() + 1, r.right() - 1, r.bottom() - 1, hover ? 0xffc2e3e9 : 0xffa7cdd6); + g.fill(r.x() + 1, r.y() + 1, r.right() - 1, r.y() + 2, 0xffd6edf1); + var label = text(new String[] {"per_tick", "per_second", "per_minute"}[unit]); + g.pose().pushPose(); + g.pose() + .translate( + r.x() + Math.max(3, (r.width() - 10 - font.width(label) * SCALE) / 2), r.y() + 4, 0); + g.pose().scale(SCALE, SCALE, 1); + g.drawString(font, label, 0, 0, 0xff263e45, false); + g.pose().popPose(); + int arrowX = r.right() - 6, arrowY = r.y() + 6; + g.fill(arrowX - 1, arrowY - 2, arrowX, arrowY + 3, 0xff41616b); + g.fill(arrowX, arrowY - 1, arrowX + 1, arrowY + 2, 0xff41616b); + g.fill(arrowX + 1, arrowY, arrowX + 2, arrowY + 1, 0xff41616b); + g.enableScissor( + body.x() + 5, + Math.min(body.bottom() - 4, body.y() + 24), + body.right() - 5, + body.bottom() - 4); + g.pose().pushPose(); + g.pose().translate(body.x() + 6, body.y() + 24 - scroll, 0); + g.pose().scale(SCALE, SCALE, 1); + for (int i = 0; i < lines.size(); i++) + g.drawString(font, lines.get(i), 0, i * 12, 0xff263e45, false); + g.pose().popPose(); + g.disableScissor(); + g.disableScissor(); + } + + private Rect unitBounds() { + int width = Math.min(64, Math.max(0, body.width() - 12)); + return new Rect(body.right() - 6 - width, body.y() + 4, width, 14); + } + + boolean tabContains(double x, double y) { + layout(); + return tab.contains(x, y); + } + + void tooltip(GuiGraphics g, int x, int y) { + if (tab.contains(x, y) || open && unitBounds().contains(x, y)) + g.renderComponentTooltip( + Minecraft.getInstance().font, + List.of(text("title"), text("cycle_tip"), text("tip")), + x, + y); + } + + boolean contains(double x, double y) { + layout(); + return tab.contains(x, y) + || open + && (body.contains(x, y) + || SideDrawerLayout.rail(tab, body).contains(x, y) && !otherTab(x, y)); + } + + private boolean otherTab(double x, double y) { + return screen.onOtherLeftDrawerTab(x, y); + } + + boolean click(double x, double y, int button) { + if (!contains(x, y)) { + if ((button == 0 || button == 1) && otherTab(x, y)) open = false; + return false; + } + handled = true; + if (button != 0 && button != 1) return true; + if (tab.contains(x, y)) { + if (open) open = false; + else show(); + screen.rememberStatsIntent(); + } else if (open && unitBounds().contains(x, y)) { + unit = Math.floorMod(unit + (button == 0 ? 1 : -1), 3); + scroll = 0; + } + return true; + } + + boolean release() { + boolean result = handled; + handled = false; + return result; + } + + boolean drag() { + return handled; + } + + boolean key(int key) { + if (key != 256 || !open) return false; + open = false; + screen.rememberStatsIntent(); + return true; + } + + boolean scroll(double x, double y, double amount) { + if (!open || !contains(x, y)) return false; + scroll += amount < 0 ? 18 : -18; + return true; + } + + List exclusions() { + layout(); + return open ? List.of(tab, body, SideDrawerLayout.rail(tab, body)) : List.of(tab); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/AutomationMenu.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/AutomationMenu.java index 048b9ef9..9921e276 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/runtime/AutomationMenu.java +++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/AutomationMenu.java @@ -23,6 +23,7 @@ public final class AutomationMenu { private final MachineMenu menu; private final Player owner; private final boolean client; + private final boolean addons; private final ItemStackHandler items; private final List slots = new ArrayList<>(); private int selected, editor = -1, start, lastRequest, nextRequest, pendingTicks; @@ -42,6 +43,7 @@ public AutomationMenu( this.inventory = inventory; this.owner = owner; this.client = client; + addons = !(menu.blockEntity instanceof ScreenHost host) || host.screenAutomationAddons(); modes = new int[inventory.groups().size() * 6]; generations = new long[inventory.groups().size() * 4]; revisions = new long[generations.length]; @@ -69,7 +71,11 @@ public boolean isItemValid(int slot, ItemStack stack) { } public int slotCount() { - return items.getSlots(); + return addons ? items.getSlots() : 0; + } + + public boolean addonsEnabled() { + return addons; } public int selected() { @@ -98,11 +104,11 @@ public int slotStart() { } public ItemStack addon(int g, int c) { - return items.getStackInSlot(g * 8 + c); + return addons ? items.getStackInSlot(g * 8 + c) : ItemStack.EMPTY; } public ItemStack filter(int g, int c) { - return items.getStackInSlot(g * 8 + 4 + c); + return addons ? items.getStackInSlot(g * 8 + 4 + c) : ItemStack.EMPTY; } public long generation(int g, int c) { @@ -129,7 +135,7 @@ public boolean supportsFilter(int g, int c) { public void addSlots(Consumer add, int start, int bodyX) { this.start = start; - for (int i = 0; i < items.getSlots(); i++) { + for (int i = 0; i < slotCount(); i++) { final int index = i; var bounds = AutomationLayout.item(i % 8).offset(bodyX, AutomationLayout.TOP); var slot = diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/CounterRate.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/CounterRate.java new file mode 100644 index 00000000..23c95a18 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/CounterRate.java @@ -0,0 +1,33 @@ +package zone.moddev.mc.lib.gui.runtime; + +import java.math.BigInteger; +import java.util.ArrayDeque; + +/** Per-viewer rolling sample of a provider counter, using game time. */ +public final class CounterRate { + private record Sample(long tick, BigInteger count) {} + + private final int window; + + public CounterRate(int window) { + this.window = window; + } + + private final ArrayDeque samples = new ArrayDeque<>(); + + public void sample(BigInteger count, long tick) { + if (!samples.isEmpty() + && (tick <= samples.getLast().tick() || count.compareTo(samples.getLast().count()) < 0)) + samples.clear(); + samples.addLast(new Sample(tick, count)); + while (samples.size() > 1 && samples.getFirst().tick() < tick - window) samples.removeFirst(); + } + + public String format(int ticksPerUnit, int decimals) { + if (samples.size() < 2) return "—"; + var first = samples.getFirst(); + var last = samples.getLast(); + return StatsNumbers.rate( + last.count().subtract(first.count()), last.tick() - first.tick(), ticksPerUnit, decimals); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/MachineMenu.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/MachineMenu.java index a34af0dd..227fd540 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/runtime/MachineMenu.java +++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/MachineMenu.java @@ -6,6 +6,7 @@ import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.chat.Component; import net.minecraft.world.entity.player.*; import net.minecraft.world.inventory.*; import net.minecraft.world.item.ItemStack; @@ -35,6 +36,15 @@ public class MachineMenu extends AbstractContainerMenu { private final Map visibility = new IdentityHashMap<>(); private final Map elementIndexes = new IdentityHashMap<>(); private final Map values = new HashMap<>(); + private final ScreenStats stats = new ScreenStats(); + + /** Server-synchronized stats for tick, second or minute rate display. */ + public List stats(int unit) { + Object value = values.get(ScreenStats.KEY + Math.clamp(unit, 0, 2)); + if (!(value instanceof List list)) return List.of(); + return list.stream().filter(Component.class::isInstance).map(Component.class::cast).toList(); + } + private final ScreenHost host; private final int machineSlots; private final IItemHandler items; @@ -630,6 +640,7 @@ public void broadcastChanges() { snapshot.put(Integer.MIN_VALUE + i, java.math.BigInteger.valueOf(selected)); } snapshot.putAll(reservationSnapshot()); + snapshot.putAll(stats.read(host, blockEntity.getLevel().getGameTime())); var delta = ScreenValues.changed(values, snapshot, initial); for (int key : widgetRefreshKeys) delta.put(key, snapshot.get(key)); widgetRefreshKeys.clear(); diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenHost.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenHost.java index 701acc73..c95707f6 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenHost.java +++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenHost.java @@ -20,6 +20,11 @@ public interface ScreenHost { */ IItemHandlerModifiable screenItems(); + /** Parts contributing statistics, in display order. Providers are only sampled on the server. */ + default java.util.List screenStats() { + return java.util.List.of(); + } + /** Opt in to the independent redstone face drawer. */ default zone.moddev.mc.lib.api.redstone.RedstoneControl screenRedstone() { return null; @@ -30,6 +35,11 @@ default zone.moddev.mc.lib.api.automation.AutomationInventory screenAutomation() return null; } + /** False exposes only resource selection and side modes, with no addon/filter slots. */ + default boolean screenAutomationAddons() { + return true; + } + /** Ordered destinations for a player inventory stack; storage still decides what it accepts. */ default int[] screenDestinations(ItemStack stack) { return java.util.stream.IntStream.range(0, screenItems().getSlots()).toArray(); diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenStats.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenStats.java new file mode 100644 index 00000000..0cbc040c --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenStats.java @@ -0,0 +1,109 @@ +package zone.moddev.mc.lib.gui.runtime; + +import java.math.BigInteger; +import java.util.*; + +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; + +import zone.moddev.mc.lib.api.stats.Statistics; +import zone.moddev.mc.lib.api.stats.StatsProvider; + +/** Server snapshots from the public provider contract; no concrete storage/processor knowledge. */ +final class ScreenStats { + static final int KEY = 1 << 21; + + private record Sampled(int window, CounterRate rate) {} + + private record Part(StatsProvider provider, Statistics section) {} + + private final Map> counters = new IdentityHashMap<>(); + private Map snapshot = Map.of(); + private long lastTick = Long.MIN_VALUE; + + Map read(ScreenHost host, long tick) { + if (lastTick != Long.MIN_VALUE && tick >= lastTick && tick - lastTick < 20) return snapshot; + return readParts(host == null ? List.of() : host.screenStats(), tick); + } + + Map readParts(List providers, long tick) { + lastTick = tick; + var seen = Collections.newSetFromMap(new IdentityHashMap()); + var parts = new ArrayList(); + var demands = new IdentityHashMap(); + for (var provider : providers) { + if (!seen.add(provider)) continue; + var section = provider.statistics(); + parts.add(new Part(provider, section)); + for (var use : section.maximumUses()) + demands.merge(use.resource(), use.perTick(), BigInteger::add); + var samples = counters.computeIfAbsent(provider, ignored -> new HashMap<>()); + var currentIds = new HashSet(); + for (var row : section.rows()) + if (row.value() instanceof Statistics.Counter counter) { + currentIds.add(row.id()); + var sampled = samples.get(row.id()); + if (sampled == null || sampled.window() != counter.windowTicks()) { + sampled = new Sampled(counter.windowTicks(), new CounterRate(counter.windowTicks())); + samples.put(row.id(), sampled); + } + sampled.rate().sample(counter.total(), tick); + } + samples.keySet().retainAll(currentIds); + } + counters.keySet().retainAll(seen); + var result = new HashMap(); + int[] units = {1, 20, 1200}; + String[] names = {"tick", "second", "minute"}; + for (int unit = 0; unit < units.length; unit++) { + var lines = new ArrayList(); + for (var part : parts) { + var section = part.section(); + var visible = + section.rows().stream() + .filter( + row -> + row.demandResource() == null + || demands.getOrDefault(row.demandResource(), BigInteger.ZERO).signum() + > 0) + .toList(); + if (visible.isEmpty()) continue; + if (!lines.isEmpty()) lines.add(Component.empty()); + if (!section.title().equals(Component.empty())) + lines.add(section.title().copy().withStyle(ChatFormatting.BOLD)); + for (var row : visible) { + var line = row.label().copy().append(": "); + if (row.value() instanceof Statistics.Text text) line.append(text.text().copy()); + else { + int multiplier = row.perTimescale() ? units[unit] : 1; + String number = + switch (row.value()) { + case Statistics.Counter ignored -> + counters + .get(part.provider()) + .get(row.id()) + .rate() + .format(multiplier, row.decimals()); + case Statistics.Rate rate -> + StatsNumbers.rate(rate.amount(), rate.ticks(), multiplier, row.decimals()); + case Statistics.MaximumUse use -> + StatsNumbers.rate( + demands.getOrDefault(use.resource(), BigInteger.ZERO), + 1, + multiplier, + row.decimals()); + default -> throw new IllegalStateException("Unsupported statistics value"); + }; + line.append(number).append(" ").append(row.unit().copy()); + if (row.perTimescale()) line.append("/").append(Statistics.label(names[unit])); + } + lines.add(line); + } + } + if (lines.isEmpty()) lines.add(Statistics.label("empty")); + if (lines.size() > 4096) throw new IllegalArgumentException("Too many statistics rows"); + result.put(KEY + unit, List.copyOf(lines)); + } + return snapshot = Map.copyOf(result); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/StatsNumbers.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/StatsNumbers.java new file mode 100644 index 00000000..3b912ef9 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/StatsNumbers.java @@ -0,0 +1,23 @@ +package zone.moddev.mc.lib.gui.runtime; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.math.RoundingMode; +import java.text.NumberFormat; +import java.util.Locale; + +/** Exact rate conversion with fixed precision and comma grouping, independent of server locale. */ +final class StatsNumbers { + private StatsNumbers() {} + + static String rate(BigInteger amount, long elapsed, int ticksPerUnit, int decimals) { + var value = + new BigDecimal(amount) + .multiply(BigDecimal.valueOf(ticksPerUnit)) + .divide(BigDecimal.valueOf(elapsed), decimals, RoundingMode.HALF_UP); + var format = NumberFormat.getNumberInstance(Locale.US); + format.setMinimumFractionDigits(decimals); + format.setMaximumFractionDigits(decimals); + return format.format(value); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java b/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java index 9bd291a2..883fbde1 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java +++ b/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java @@ -1,22 +1,24 @@ package zone.moddev.mc.lib.machine.feature; -import java.util.Optional; import java.util.List; +import java.util.Optional; import java.util.function.IntSupplier; -import net.minecraft.network.chat.Component; -import zone.moddev.mc.lib.api.redstone.IRedstoneOutput; -import zone.moddev.mc.lib.api.redstone.RedstoneOutput; -import zone.moddev.mc.lib.api.redstone.RedstoneControl; - -import org.jetbrains.annotations.Nullable; import com.mojang.serialization.Codec; import com.mojang.serialization.DataResult; import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; import net.minecraft.world.level.Level; +import org.jetbrains.annotations.Nullable; + +import zone.moddev.mc.lib.api.redstone.IRedstoneOutput; +import zone.moddev.mc.lib.api.redstone.RedstoneControl; +import zone.moddev.mc.lib.api.redstone.RedstoneOutput; +import zone.moddev.mc.lib.api.stats.Statistics; +import zone.moddev.mc.lib.api.stats.StatsProvider; import zone.moddev.mc.lib.machine.inventory.EnergyGroup; import zone.moddev.mc.lib.machine.inventory.MachineInventory; import zone.moddev.mc.lib.machine.recipe.CyclePlan; @@ -33,7 +35,7 @@ *

Several processors may share an inventory; a processor sees what the others reserved. */ public final class RecipeProcessor - implements MachineFeature, IRedstoneOutput { + implements MachineFeature, IRedstoneOutput, StatsProvider { private static final int SEARCH_RETRY_INTERVAL = 20; /** What the processor is doing. */ @@ -116,6 +118,18 @@ public record State( private boolean paused; private boolean changed; private Status status = Status.IDLE; + private java.math.BigInteger completedCycles = java.math.BigInteger.ZERO; + private final Component displayName; + + /** Successful recipe completions during this processor instance's lifetime; not saved. */ + public java.math.BigInteger completedCycles() { + return completedCycles; + } + + public Component displayName() { + return displayName.copy(); + } + private Status marked = Status.IDLE; private int markedTime; @Nullable private CyclePlan justFinished; @@ -134,6 +148,7 @@ public RecipeProcessor( String key, MachineRecipeType type, MachineInventory inventory, Component displayName) { this.key = key; outputPrefix = outputPrefix(key); + this.displayName = displayName.copy(); this.inventory = inventory; this.type = type; lookup = type.lookup(); @@ -321,6 +336,32 @@ public void reset() { changed = true; } + @Override + public Statistics statistics() { + var plan = cycle != null ? cycle : justFinished; + var maximum = + plan == null + ? new Statistics.Text(Statistics.label("no_recipe")) + : (Statistics.Value) new Statistics.Rate(java.math.BigInteger.ONE, plan.time()); + var uses = new java.util.ArrayList(); + if (plan != null) + for (var need : plan.energy().entrySet()) { + if (inventory.group(need.getKey()).orElse(null) instanceof EnergyGroup group) { + long total = need.getValue(); + long peak = total / plan.time() + (total % plan.time() == 0 ? 0 : 1); + uses.add( + new Statistics.EnergyUse( + group.statisticsResource(), java.math.BigInteger.valueOf(peak))); + } + } + return new Statistics( + displayName, + java.util.List.of( + Statistics.cycles("cycles", new Statistics.Counter(completedCycles, 1200)), + Statistics.cycles("cycles_max", maximum)), + uses); + } + /** What the processor is doing. */ public Status status() { return status; @@ -401,6 +442,7 @@ private void run(FeatureHost host) { } if (progress >= cycle.time()) { if (cycle.finish(inventory, level.getRandom())) { + completedCycles = completedCycles.add(java.math.BigInteger.ONE); justFinished = cycle; cycle = null; progress = 0; @@ -473,7 +515,7 @@ private boolean draw() { for (var need : cycle.energy().entrySet()) { long share = EnergySchedule.share(need.getValue(), cycle.time(), progress); if (!(inventory.group(need.getKey()).orElse(null) instanceof EnergyGroup group) - || group.extract(share, tx) < share) return false; + || group.consume(share, tx) < share) return false; } tx.commit(); return true; diff --git a/src/main/java/zone/moddev/mc/lib/machine/inventory/EnergyGroup.java b/src/main/java/zone/moddev/mc/lib/machine/inventory/EnergyGroup.java index 518b378f..3f41251d 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/inventory/EnergyGroup.java +++ b/src/main/java/zone/moddev/mc/lib/machine/inventory/EnergyGroup.java @@ -11,6 +11,16 @@ public non-sealed interface EnergyGroup extends SlotGroup { /** Takes up to {@code amount}; returns how much was taken. */ long extract(long amount, MachineTx tx); + /** Processing use, distinct from exporting energy where the storage supports telemetry. */ + default long consume(long amount, MachineTx tx) { + return extract(amount, tx); + } + + /** Shared identity used to attribute processor maximum demand to its backing storage. */ + default Object statisticsResource() { + return this; + } + /** Stores up to {@code amount}; returns how much was stored. */ long insert(long amount, MachineTx tx); } diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java index d713d077..9c1e719c 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java @@ -3,12 +3,12 @@ import java.util.ArrayList; import java.util.List; -import org.jetbrains.annotations.Nullable; - import net.minecraft.world.item.ItemStack; import net.neoforged.neoforge.fluids.capability.IFluidHandler.FluidAction; +import org.jetbrains.annotations.Nullable; + import zone.moddev.mc.lib.api.capabilities.EnergyBuffer; import zone.moddev.mc.lib.api.capabilities.FluidStorage; import zone.moddev.mc.lib.api.capabilities.ItemStorage; @@ -171,7 +171,6 @@ private void track(int index, MachineTx tx) { return () -> storage.setStackInSlot(index, before); }); } - } private record FluidTanks(FluidStorage storage, int[] indexes) implements FluidGroup { @@ -297,10 +296,21 @@ private void track(int index, MachineTx tx) { return () -> storage.tank(index).setFluid(before); }); } - } private record EnergyStore(EnergyBuffer buffer) implements EnergyGroup { + @Override + public Object statisticsResource() { + return buffer; + } + + @Override + public long consume(long amount, MachineTx tx) { + if (amount <= 0 || amount > buffer.stored()) return 0; + track(tx); + return buffer.consume(amount) ? amount : 0; + } + @Override public long stored() { return buffer.stored(); @@ -326,12 +336,7 @@ public long insert(long amount, MachineTx tx) { } private void track(MachineTx tx) { - tx.track( - buffer, - () -> { - long before = buffer.stored(); - return () -> buffer.setStored(before); - }); + tx.track(buffer, buffer::checkpoint); } } } diff --git a/src/main/resources/assets/mmdlib/lang/en_us.json b/src/main/resources/assets/mmdlib/lang/en_us.json index fe54ecaf..86b46b20 100644 --- a/src/main/resources/assets/mmdlib/lang/en_us.json +++ b/src/main/resources/assets/mmdlib/lang/en_us.json @@ -342,5 +342,26 @@ "screen.mmdlib.redstone.info.always": "This face pauses processing for any signal level.", "screen.mmdlib.redstone.info.never": "No signal level makes this face pause processing.", "screen.mmdlib.redstone.info.between": "This face pauses processing for signals from %s to %s, inclusive.", - "screen.mmdlib.redstone.info.outside": "This face pauses processing for signals below %s or above %s." + "screen.mmdlib.redstone.info.outside": "This face pauses processing for signals below %s or above %s.", + "screen.mmdlib.stats.in_max": "RF in max rate", + "screen.mmdlib.stats.out_max": "RF out max rate", + "screen.mmdlib.stats.used": "RF used", + "screen.mmdlib.stats.used_max": "RF max use", + "screen.mmdlib.stats.energy": "Energy", + "screen.mmdlib.stats.cycles_max": "Max cycles", + "screen.mmdlib.stats.cycles_unit": "cycles", + "screen.mmdlib.stats.no_recipe": "No recipe", + "screen.mmdlib.stats.title": "Statistics", + "screen.mmdlib.stats.tick": "t", + "screen.mmdlib.stats.second": "s", + "screen.mmdlib.stats.minute": "min", + "screen.mmdlib.stats.in": "RF in", + "screen.mmdlib.stats.out": "RF out", + "screen.mmdlib.stats.per_tick": "Per tick", + "screen.mmdlib.stats.per_second": "Per second", + "screen.mmdlib.stats.per_minute": "Per minute", + "screen.mmdlib.stats.cycles": "Cycles", + "screen.mmdlib.stats.cycle_tip": "Click to cycle tick / second / minute. Right-click to cycle backwards.", + "screen.mmdlib.stats.empty": "No statistics available.", + "screen.mmdlib.stats.tip": "Measured RF: latest 20 ticks. Cycles: up to 60 game seconds. Max cycles uses the active recipe duration; max use is peak recipe demand. Max transfer rates use one transfer per tick." } diff --git a/src/test/java/zone/moddev/mc/lib/gui/EnergyRateTest.java b/src/test/java/zone/moddev/mc/lib/gui/EnergyRateTest.java new file mode 100644 index 00000000..85fde82c --- /dev/null +++ b/src/test/java/zone/moddev/mc/lib/gui/EnergyRateTest.java @@ -0,0 +1,114 @@ +package zone.moddev.mc.lib.gui; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import zone.moddev.mc.lib.api.capabilities.EnergyBuffer; +import zone.moddev.mc.lib.gui.runtime.CounterRate; + +class EnergyRateTest { + private static final class EnergyRate { + final CounterRate in = new CounterRate(20), + out = new CounterRate(20), + used = new CounterRate(20); + + void sample(EnergyBuffer buffer, long tick) { + in.sample(buffer.inserted(), tick); + out.sample(buffer.extracted(), tick); + used.sample(buffer.used(), tick); + } + + String format(boolean input, int unit) { + return (input ? in : out).format(unit, 0); + } + } + + @Test + void roundsEnergyToGroupedWholeNumbers() { + var energy = new EnergyBuffer(100000, 100000); + var rate = new EnergyRate(); + rate.sample(energy, 0); + energy.insert(19990, false); + rate.sample(energy, 20); + assertEquals("1,000", rate.format(true, 1)); + assertEquals("19,990", rate.format(true, 20)); + assertEquals("1,199,400", rate.format(true, 1200)); + } + + @Test + void rollbackRestoresTransferCounters() { + var energy = new EnergyBuffer(1000, 1000); + energy.setStored(100); + var group = zone.moddev.mc.lib.machine.neoforge.StorageGroups.energy(energy); + var inventory = + zone.moddev.mc.lib.machine.inventory.MachineInventory.builder() + .group("energy", group) + .build(); + try (var tx = inventory.begin()) { + group.insert(500, tx); + group.extract(300, tx); + group.consume(50, tx); + } + assertEquals(100, energy.stored()); + assertEquals(java.math.BigInteger.ZERO, energy.inserted()); + assertEquals(java.math.BigInteger.ZERO, energy.extracted()); + assertEquals(java.math.BigInteger.ZERO, energy.used()); + } + + @Test + void measuresBalancedFlowAndUnitConversions() { + var energy = new EnergyBuffer(1000, 1000); + energy.setStored(500); + var rate = new EnergyRate(); + rate.sample(energy, 100); + assertEquals("—", rate.format(true, 1)); + energy.insert(400, false); + energy.consume(400); + assertEquals(500, energy.stored()); + rate.sample(energy, 120); + assertEquals("20", rate.format(true, 1)); + assertEquals("400", rate.used.format(20, 0)); + assertEquals("0", rate.format(false, 20)); + assertEquals("24,000", rate.used.format(1200, 0)); + rate.sample(energy, 140); + assertEquals("0", rate.format(true, 1)); + assertEquals("0", rate.format(false, 1)); + } + + @Test + void ignoresSimulationsAssignmentsAndRejectedConsumption() { + var energy = new EnergyBuffer(1000, 100); + var rate = new EnergyRate(); + rate.sample(energy, 0); + energy.insert(100, true); + energy.setStored(500); + energy.extract(400, true); + assertFalse(energy.consume(600)); + energy.setCapacity(200); + rate.sample(energy, 20); + assertEquals("0", rate.format(true, 1)); + assertEquals("0", rate.format(false, 1)); + } + + @Test + void countsAcceptedSidedTransfersAndHandlesLargeTotals() { + var energy = new EnergyBuffer(Long.MAX_VALUE, Long.MAX_VALUE); + var rate = new EnergyRate(); + rate.sample(energy, 0); + energy.insert(Long.MAX_VALUE, false); + energy.extract(Long.MAX_VALUE, false); + energy.insert(Long.MAX_VALUE, false); + rate.sample(energy, 20); + assertEquals("18,446,744,073,709,551,614", rate.format(true, 20)); + assertEquals("1,106,804,644,422,573,096,840", rate.format(true, 1200)); + energy.setStored(0); + rate.sample(energy, 20); + var view = energy.view(zone.moddev.mc.lib.api.capabilities.IoMode.BOTH); + assertEquals(7, view.receiveEnergy(7, false)); + assertEquals(7, view.extractEnergy(10, false)); + rate.sample(energy, 40); + assertEquals("0", rate.format(true, 1)); + assertEquals("0", rate.format(false, 1)); + } +} diff --git a/src/test/java/zone/moddev/mc/lib/gui/runtime/CycleRateTest.java b/src/test/java/zone/moddev/mc/lib/gui/runtime/CycleRateTest.java new file mode 100644 index 00000000..69eb9b0f --- /dev/null +++ b/src/test/java/zone/moddev/mc/lib/gui/runtime/CycleRateTest.java @@ -0,0 +1,56 @@ +package zone.moddev.mc.lib.gui.runtime; + +import static org.junit.jupiter.api.Assertions.*; + +import java.math.BigInteger; + +import org.junit.jupiter.api.Test; + +class CycleRateTest { + @Test + void roundsToTwoDecimalsBeforeGrouping() { + var rate = new CounterRate(1200); + rate.sample(BigInteger.ZERO, 0); + rate.sample(BigInteger.valueOf(199999), 200); + assertEquals("1,000.00", rate.format(1, 2)); + assertEquals("19,999.90", rate.format(20, 2)); + assertEquals("1,199,994.00", rate.format(1200, 2)); + } + + @Test + void averagesCompletionsAndConvertsUnits() { + var rate = new CounterRate(1200); + rate.sample(BigInteger.valueOf(50), 0); + assertEquals("—", rate.format(20, 2)); + rate.sample(BigInteger.valueOf(51), 100); + assertEquals("0.01", rate.format(1, 2)); + assertEquals("0.20", rate.format(20, 2)); + assertEquals("12.00", rate.format(1200, 2)); + rate.sample(BigInteger.valueOf(51), 200); + assertEquals("6.00", rate.format(1200, 2)); + } + + @Test + void expiresActivityAndKeepsProcessorsIndependent() { + var first = new CounterRate(1200); + var second = new CounterRate(1200); + for (int tick = 0; tick <= 1200; tick += 20) { + first.sample(BigInteger.valueOf(tick / 100), tick); + second.sample(BigInteger.valueOf(tick / 200), tick); + } + assertEquals("12.00", first.format(1200, 2)); + assertEquals("6.00", second.format(1200, 2)); + for (int tick = 1220; tick <= 2400; tick += 20) first.sample(BigInteger.valueOf(12), tick); + assertEquals("0.00", first.format(1200, 2)); + } + + @Test + void resetsBaselineWhenClockOrCounterRestarts() { + var rate = new CounterRate(1200); + rate.sample(BigInteger.TEN, 100); + rate.sample(BigInteger.ZERO, 120); + assertEquals("—", rate.format(1, 2)); + rate.sample(BigInteger.ONE, 20); + assertEquals("—", rate.format(1, 2)); + } +} diff --git a/src/test/java/zone/moddev/mc/lib/gui/runtime/ScreenStatsTest.java b/src/test/java/zone/moddev/mc/lib/gui/runtime/ScreenStatsTest.java new file mode 100644 index 00000000..88ef873d --- /dev/null +++ b/src/test/java/zone/moddev/mc/lib/gui/runtime/ScreenStatsTest.java @@ -0,0 +1,154 @@ +package zone.moddev.mc.lib.gui.runtime; + +import static org.junit.jupiter.api.Assertions.*; + +import java.math.BigInteger; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import net.minecraft.network.chat.Component; + +import org.junit.jupiter.api.Test; + +import zone.moddev.mc.lib.api.capabilities.EnergyBuffer; +import zone.moddev.mc.lib.api.stats.Statistics; +import zone.moddev.mc.lib.api.stats.StatsProvider; + +class ScreenStatsTest { + @Test + void transferMaximaFollowTheSelectedTimescale() { + var energy = new EnergyBuffer(10000, 1000, 500); + var values = new ScreenStats().readParts(List.of(energy), 0); + String[] input = {": 1,000 RF/", ": 20,000 RF/", ": 1,200,000 RF/"}; + String[] output = {": 500 RF/", ": 10,000 RF/", ": 600,000 RF/"}; + for (int unit = 0; unit < 3; unit++) { + var rows = lines(values, unit); + assertTrue(rows.get(3).contains(input[unit])); + assertTrue(rows.get(4).contains(output[unit])); + assertFalse(rows.get(3).contains("transfer")); + } + } + + @Test + void readsCustomProvidersOnceAndTracksRowsWithoutConcreteTypes() { + var count = new AtomicReference<>(BigInteger.ZERO); + var calls = new AtomicInteger(); + StatsProvider part = + () -> { + calls.incrementAndGet(); + return new Statistics( + Component.literal("Custom part"), + List.of( + new Statistics.Row( + "work", + Component.literal("Work"), + new Statistics.Counter(count.get(), 20), + Component.literal("units"), + 2, + true))); + }; + var stats = new ScreenStats(); + stats.readParts(List.of(part), 0); + count.set(BigInteger.valueOf(1500)); + var values = stats.readParts(List.of(part), 20); + assertEquals(2, calls.get()); + assertEquals("Custom part", lines(values, 1).getFirst()); + assertTrue(lines(values, 1).get(1).contains("1,500.00 units/")); + } + + @Test + void separatesInputOutputAndProcessingUseAndRestoresAllOnRollback() { + var energy = new EnergyBuffer(10000, 1000, 500); + var parts = List.of(energy, demand(energy, 20)); + var stats = new ScreenStats(); + stats.readParts(parts, 0); + energy.insert(1000, false); + energy.extract(100, false); + assertTrue(energy.consume(400)); + var undo = energy.checkpoint(); + energy.insert(30, false); + energy.extract(40, false); + energy.consume(50); + undo.run(); + var rows = lines(stats.readParts(parts, 20), 1); + assertEquals(7, rows.size()); + assertTrue(rows.get(1).contains(": 1,000 RF/")); + assertTrue(rows.get(2).contains(": 100 RF/")); + assertTrue(rows.get(5).contains(": 400 RF/")); + } + + @Test + void usageRowsRequireCurrentDemandForThatBuffer() { + var energy = new EnergyBuffer(10000, 0, 1000); + var other = new EnergyBuffer(10000, 1000, 0); + var stats = new ScreenStats(); + assertEquals(3, lines(stats.readParts(List.of(energy), 0), 0).size()); + assertEquals(3, lines(stats.readParts(List.of(energy, demand(other, 20)), 20), 0).size()); + assertEquals(3, lines(stats.readParts(List.of(energy, demand(energy, 0)), 40), 0).size()); + assertEquals(5, lines(stats.readParts(List.of(energy, demand(energy, 20)), 60), 0).size()); + energy.setStored(100); + energy.consume(20); + assertEquals(3, lines(stats.readParts(List.of(energy), 80), 0).size()); + } + + private static StatsProvider demand(Object resource, long perTick) { + return () -> + new Statistics( + Component.empty(), + List.of(), + List.of(new Statistics.EnergyUse(resource, BigInteger.valueOf(perTick)))); + } + + @Test + void aggregatesOnlyMatchingProcessorResourcesRegardlessOfOrder() { + var first = new EnergyBuffer(10000, 1000, 0); + var second = new EnergyBuffer(10000, 0, 1000); + StatsProvider a = + () -> + new Statistics( + Component.empty(), + List.of(), + List.of(new Statistics.EnergyUse(first, BigInteger.valueOf(20)))); + StatsProvider b = + () -> + new Statistics( + Component.empty(), + List.of(), + List.of( + new Statistics.EnergyUse(first, BigInteger.valueOf(7)), + new Statistics.EnergyUse(second, BigInteger.valueOf(3)))); + var stats = new ScreenStats(); + var rows = lines(stats.readParts(List.of(first, a, second, b, a), 0), 2); + assertEquals(11, rows.size()); // two four-row sections, their headers and one separator + assertTrue(rows.get(4).contains(": 32,400 RF/")); + assertTrue(rows.get(10).contains(": 3,600 RF/")); + assertEquals( + List.of("in", "in_max", "used", "used_max"), + first.statistics().rows().stream().map(Statistics.Row::id).toList()); + assertEquals( + List.of("out", "out_max", "used", "used_max"), + second.statistics().rows().stream().map(Statistics.Row::id).toList()); + } + + @Test + void emptyPartsAddNothingAndMissingRecipesHaveNoInventedMaximum() { + StatsProvider empty = () -> Statistics.EMPTY; + StatsProvider processor = + () -> + new Statistics( + Component.literal("Processor A"), + List.of( + Statistics.cycles( + "cycles_max", new Statistics.Text(Component.literal("No recipe"))))); + var rows = lines(new ScreenStats().readParts(List.of(empty, processor), 0), 2); + assertEquals(2, rows.size()); + assertTrue(rows.get(1).endsWith(": No recipe")); + assertFalse(rows.get(1).contains("cycles/")); + } + + private static List lines(java.util.Map values, int unit) { + return ((List) values.get(ScreenStats.KEY + unit)) + .stream().map(Component.class::cast).map(Component::getString).toList(); + } +} From 90a7da2ad6ccb2fa2d9e3f130f359a8c9cdc792c Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Sat, 7 Nov 2026 08:57:32 +0200 Subject: [PATCH 2/3] Lighten statistics drawer background for better contrast --- src/main/java/zone/moddev/mc/lib/gui/client/StatsDrawer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/StatsDrawer.java b/src/main/java/zone/moddev/mc/lib/gui/client/StatsDrawer.java index 1155756b..ed6a6343 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/StatsDrawer.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/StatsDrawer.java @@ -22,7 +22,7 @@ final class StatsDrawer { private int cachedWidth = -1; private net.minecraft.client.gui.Font cachedFont; private net.minecraft.locale.Language cachedLanguage; - private static final int COLOUR = 0x85b9c7; + private static final int COLOUR = 0xa7cdd6; private static final float SCALE = .75f; StatsDrawer(MachineScreen screen) { From cab118547bd56c2151339292b8144ada4de69bee Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Sat, 7 Nov 2026 11:50:36 +0200 Subject: [PATCH 3/3] Fix statistics accounting, held processor status, and automation recovery --- .../moddev/mc/companion/ProcessorEntity.java | 6 +- .../assets/mmdlib_companion/lang/en_us.json | 1 + .../companion/test/AutomationGameTests.java | 57 ++++++++++++++++- .../test/AutomationRecoveryTests.java | 3 + .../mc/companion/test/CompanionGameTests.java | 55 ++++++++++++++++- .../api/automation/AutomationInventory.java | 14 ++--- .../api/automation/AutomationTransfers.java | 9 ++- .../mc/lib/api/capabilities/EnergyBuffer.java | 14 +++++ .../mc/lib/api/capabilities/FluidStorage.java | 8 +-- .../mc/lib/api/capabilities/ItemStorage.java | 9 +-- .../mc/lib/gui/client/AutomationDrawer.java | 8 ++- .../mc/lib/gui/layout/AutomationLayout.java | 6 +- .../mc/lib/gui/layout/ScreenLayout.java | 9 ++- .../mc/lib/gui/runtime/AutomationMenu.java | 61 +++++++++++-------- .../mc/lib/gui/runtime/AutomationNetwork.java | 6 +- .../mc/lib/gui/runtime/MachineMenu.java | 5 +- .../mc/lib/gui/runtime/ScreenStats.java | 14 +++++ .../lib/machine/feature/RecipeProcessor.java | 13 ++++ .../mc/lib/gui/RuntimeDrawerLayoutTest.java | 46 ++++++++++++++ .../mc/lib/gui/runtime/ScreenStatsTest.java | 51 ++++++++++++++++ 20 files changed, 329 insertions(+), 66 deletions(-) diff --git a/src/companion/java/zone/moddev/mc/companion/ProcessorEntity.java b/src/companion/java/zone/moddev/mc/companion/ProcessorEntity.java index d80cce9b..0bf84580 100644 --- a/src/companion/java/zone/moddev/mc/companion/ProcessorEntity.java +++ b/src/companion/java/zone/moddev/mc/companion/ProcessorEntity.java @@ -266,8 +266,6 @@ public int getProgress() { public List screenStats() { var parts = new ArrayList(); parts.add(energy); - parts.add(items); - parts.add(fluids); parts.addAll(lanes); return List.copyOf(parts); } @@ -313,7 +311,9 @@ public List getWarnings() { warnings.add(Component.translatable("screen.mmdlib_companion.redstone.waiting")); for (int i = 0; i < lanes.size(); i++) { var status = lanes.get(i).status(); - if (status == RecipeProcessor.Status.STALLED || status == RecipeProcessor.Status.BLOCKED) + if (status == RecipeProcessor.Status.STALLED + || status == RecipeProcessor.Status.BLOCKED + || status == RecipeProcessor.Status.HELD) warnings.add( Component.translatable("screen.mmdlib_companion.lane_" + (i == 0 ? "a" : "b")) .append(": ") diff --git a/src/companion/resources/assets/mmdlib_companion/lang/en_us.json b/src/companion/resources/assets/mmdlib_companion/lang/en_us.json index c97be778..0020c1a4 100644 --- a/src/companion/resources/assets/mmdlib_companion/lang/en_us.json +++ b/src/companion/resources/assets/mmdlib_companion/lang/en_us.json @@ -24,6 +24,7 @@ "screen.mmdlib_companion.rate": "1,000 FE per face per tick", "status.mmdlib_companion.stalled": "Needs power", "status.mmdlib_companion.blocked": "Blocked", + "status.mmdlib_companion.held": "Held by machine controls", "block.mmdlib_companion.crusher": "Crusher", "block.mmdlib_companion.transmuter": "Iron-to-Gold Converter", "block.mmdlib_companion.dual_crusher": "Dual Crusher", diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/AutomationGameTests.java b/src/companionTest/java/zone/moddev/mc/companion/test/AutomationGameTests.java index 48465c32..02adccb8 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/AutomationGameTests.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/AutomationGameTests.java @@ -26,6 +26,49 @@ private static void require(boolean b, String s) { if (!b) throw new AssertionError(s); } + @GameTest(template = "empty") + public static void menuRecoveryReturnsOnlyTheCurrentPlayersDetachedFilter(GameTestHelper h) { + var machine = place(h); + var a = machine.automation; + a.slots().setStackInSlot(0, new ItemStack(CompanionAddons.PULLER.get())); + a.slots().setStackInSlot(4, new ItemStack(CompanionAddons.ITEM_FILTER.get())); + a.slots().extractItem(0, 1, false); + a.load(a.save(h.getLevel().registryAccess()), h.getLevel().registryAccess()); + a.slots().setStackInSlot(0, new ItemStack(CompanionAddons.PULLER.get())); + a.slots().setStackInSlot(4, new ItemStack(CompanionAddons.ITEM_FILTER.get())); + var player = h.makeMockServerPlayerInLevel(); + player.setPos( + machine.getBlockPos().getX(), machine.getBlockPos().getY(), machine.getBlockPos().getZ()); + var menu = new MachineMenu(72, player.getInventory(), machine); + player.containerMenu = menu; + menu.automation.action( + player, + new AutomationNetwork.Action( + 72, menu.widgetSession(), 1, "select", 0, -1, 0, 0, "", 0, "")); + menu.clicked( + menu.automation.slotStart(), 0, net.minecraft.world.inventory.ClickType.PICKUP, player); + require( + player.getInventory().countItem(CompanionAddons.ITEM_FILTER.get()) == 1, + "Only the filter detached by this player's click goes to that player"); + var drops = + h.getLevel() + .getEntitiesOfClass( + net.minecraft.world.entity.item.ItemEntity.class, + new net.minecraft.world.phys.AABB(machine.getBlockPos()).inflate(2), + e -> e.getItem().is(CompanionAddons.ITEM_FILTER.get())); + require( + drops.size() == 1 && drops.getFirst().getItem().getCount() == 1, + "Previously queued recovery drops at the block"); + var other = h.makeMockServerPlayerInLevel(); + var otherMenu = new MachineMenu(73, other.getInventory(), machine); + otherMenu.clicked(-999, 0, net.minecraft.world.inventory.ClickType.PICKUP, other); + require( + other.getInventory().countItem(CompanionAddons.ITEM_FILTER.get()) == 0, + "A later viewer receives no leftovers"); + player.containerMenu = player.inventoryMenu; + h.succeed(); + } + @GameTest(template = "empty") public static void fullInventoryDropsConfiguredFilterOnce(GameTestHelper h) { var machine = place(h); @@ -272,7 +315,8 @@ public static void compatibilityAndFilterLifetime(GameTestHelper h) { } @GameTest(template = "empty") - public static void menuActionsAreSessionAndGenerationBound(GameTestHelper h) { + public static void menuActionsAreSessionAndGenerationBound(GameTestHelper h) + throws ReflectiveOperationException { var machine = place(h); var player = h.makeMockServerPlayerInLevel(); player.setPos( @@ -311,6 +355,17 @@ public static void menuActionsAreSessionAndGenerationBound(GameTestHelper h) { new AutomationNetwork.Action( 71, menu.widgetSession(), 3, "select", 1, -1, 0, 0, "", 0, "")); require(a.selected() == 0, "Replay refused"); + var revision = AutomationMenu.class.getDeclaredField("lastInventoryRevision"); + revision.setAccessible(true); + var selection = AutomationMenu.class.getDeclaredField("lastSelected"); + selection.setAccessible(true); + var open = AutomationMenu.class.getDeclaredField("lastOpen"); + open.setAccessible(true); + require( + revision.getLong(a) == machine.automation.viewRevision() + && selection.getInt(a) == a.selected() + && open.getBoolean(a) == a.open(), + "Action acknowledgement advances the broadcast markers, preventing a duplicate snapshot"); player.containerMenu = player.inventoryMenu; h.succeed(); } diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/AutomationRecoveryTests.java b/src/companionTest/java/zone/moddev/mc/companion/test/AutomationRecoveryTests.java index a1897b48..4dea68d6 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/AutomationRecoveryTests.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/AutomationRecoveryTests.java @@ -310,6 +310,9 @@ public ItemStack insertItem(int slot, ItemStack stack, boolean simulate) { require( power.stored() == 932 && a.pendingRefund() == 0, "Refund returns once when room becomes available"); + require( + power.inserted().signum() == 0 && power.used().signum() == 0, + "Reserved and refunded transfer charges never count as input or processor use"); h.succeed(); } diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/CompanionGameTests.java b/src/companionTest/java/zone/moddev/mc/companion/test/CompanionGameTests.java index 611d66d0..5ee6f045 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/CompanionGameTests.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/CompanionGameTests.java @@ -38,13 +38,64 @@ public final class CompanionGameTests { private static final BlockPos CENTER = new BlockPos(2, 2, 2); + @GameTest(template = "empty") + public static void heldProcessorsSaveSyncAndResumeWithoutReleasingReservations( + GameTestHelper helper) { + var machine = place(helper, CompanionMod.CRUSHER); + var lane = machine.lanes.getFirst(); + machine.items.setStackInSlot(0, new ItemStack(Items.COPPER_ORE, 4)); + machine.energy.setStored(100000); + machine.serverTick(); + machine.serverTick(); + int progress = lane.progress(); + var plan = machine.items.plan(0); + var changes = new java.util.concurrent.atomic.AtomicInteger(); + var syncs = new java.util.concurrent.atomic.AtomicInteger(); + var host = + new zone.moddev.mc.lib.machine.feature.FeatureHost( + changes::incrementAndGet, syncs::incrementAndGet) + .add(lane); + host.tick(helper.getLevel(), false); + require(lane.status() == Status.HELD && !lane.paused(), "Host hold differs from manual pause"); + require(changes.get() == 1 && syncs.get() == 1, "Entering hold saves and synchronizes status"); + require( + lane.state().status().orElseThrow() == Status.HELD + && lane.clientState().status().orElseThrow() == Status.HELD, + "Save and client snapshots show hold"); + require( + lane.progress() == progress && machine.items.plan(0).equals(plan), + "Hold retains progress and reservations"); + for (int i = 0; i < 20; i++) host.tick(helper.getLevel(), false); + require( + changes.get() == 1 && syncs.get() == 1, "Unchanged hold does not repeatedly save or sync"); + host.tick(helper.getLevel(), true); + require( + lane.status() == Status.RUNNING && lane.progress() == progress + 1 && syncs.get() == 2, + "Releasing hold resumes and synchronizes the running state"); + machine.setReservePercent(100); + machine.serverTick(); + require(lane.status() == Status.HELD, "Energy reserve gate reports held"); + machine.setReservePercent(0); + machine.redstone.mode(0, zone.moddev.mc.lib.api.redstone.RedstoneControl.Mode.READ); + machine.redstone.range(0, 0, 15); + machine.redstone.outside(0, false); + machine.serverTick(); + require(lane.status() == Status.HELD, "Redstone gate reports held"); + lane.interrupt(); + host.tick(helper.getLevel(), false); + require( + lane.status() == Status.PAUSED && machine.items.plan(0).take() == 0, + "Manual pause remains visible and releases reservations even during a host hold"); + helper.succeed(); + } + @GameTest(template = "empty") public static void statisticsProvidersDescribeCurrentRecipes(GameTestHelper helper) { var machine = place(helper, CompanionMod.DUAL_CRUSHER); var first = machine.lanes.getFirst(); require( - machine.items.statistics().rows().isEmpty() && machine.fluids.statistics().rows().isEmpty(), - "Item and fluid inventories contribute no statistics"); + machine.screenStats().size() == 1 + machine.lanes.size(), + "Only energy and processors contribute statistics"); require( first.statistics().rows().get(1).value() instanceof zone.moddev.mc.lib.api.stats.Statistics.Text, diff --git a/src/main/java/zone/moddev/mc/lib/api/automation/AutomationInventory.java b/src/main/java/zone/moddev/mc/lib/api/automation/AutomationInventory.java index 946e6d8a..33899566 100644 --- a/src/main/java/zone/moddev/mc/lib/api/automation/AutomationInventory.java +++ b/src/main/java/zone/moddev/mc/lib/api/automation/AutomationInventory.java @@ -3,10 +3,8 @@ import java.util.*; import java.util.function.Consumer; -import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; - -import net.minecraft.core.HolderLookup; import net.minecraft.core.Direction; +import net.minecraft.core.HolderLookup; import net.minecraft.nbt.*; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.Containers; @@ -14,10 +12,12 @@ import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.block.entity.BlockEntity; -import net.neoforged.neoforge.items.IItemHandler; -import net.neoforged.neoforge.items.ItemStackHandler; import net.neoforged.neoforge.capabilities.BlockCapability; import net.neoforged.neoforge.capabilities.BlockCapabilityCache; +import net.neoforged.neoforge.items.IItemHandler; +import net.neoforged.neoforge.items.ItemStackHandler; + +import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; import zone.moddev.mc.lib.api.capabilities.*; @@ -151,7 +151,7 @@ public void invalidateAddons() { public void refund(EnergyBuffer power, long amount) { if (amount < 0) throw new IllegalArgumentException("Negative refund"); if (amount == 0) return; - refund = Math.addExact(refund, amount - power.insert(amount, false)); + refund = Math.addExact(refund, amount - power.refund(amount)); owner.setChanged(); } @@ -161,7 +161,7 @@ public long pendingRefund() { public void settleRefund(EnergyBuffer power) { if (refund == 0) return; - long restored = power.insert(refund, false); + long restored = power.refund(refund); refund -= restored; if (restored != 0) owner.setChanged(); } diff --git a/src/main/java/zone/moddev/mc/lib/api/automation/AutomationTransfers.java b/src/main/java/zone/moddev/mc/lib/api/automation/AutomationTransfers.java index 31866aee..85d863e4 100644 --- a/src/main/java/zone/moddev/mc/lib/api/automation/AutomationTransfers.java +++ b/src/main/java/zone/moddev/mc/lib/api/automation/AutomationTransfers.java @@ -1,5 +1,7 @@ package zone.moddev.mc.lib.api.automation; +import static zone.moddev.mc.lib.api.automation.AutomationInventory.COLUMNS; + import java.util.*; import java.util.function.Predicate; @@ -241,11 +243,12 @@ public static void tick(AutomationInventory inventory, EnergyBuffer power) { var level = inventory.owner().getLevel(); if (level == null || level.isClientSide) return; inventory.revalidate(); + inventory.recover(null); inventory.settleRefund(power); - int total = inventory.groups().size() * 4; + int total = inventory.groups().size() * COLUMNS; int start = (int) Math.floorMod(level.getGameTime(), total); for (int n = 0; n < total; n++) { - int index = (start + n) % total, g = index / 4, c = index % 4; + int index = (start + n) % total, g = index / COLUMNS, c = index % COLUMNS; var group = inventory.group(g); var stack = inventory.addon(g, c); var addon = stack.getCapability(AddonCapabilities.ADDON); @@ -291,7 +294,7 @@ public static void tick(AutomationInventory inventory, EnergyBuffer power) { state.putInt("face", face); } long reserved = cost * max; - if (!power.consume(reserved)) break; + if (!power.reserve(reserved)) break; int moved; try { moved = move(group, inventory, direction, input, pending, max, accepts, state); diff --git a/src/main/java/zone/moddev/mc/lib/api/capabilities/EnergyBuffer.java b/src/main/java/zone/moddev/mc/lib/api/capabilities/EnergyBuffer.java index f1278858..2ff3aa87 100644 --- a/src/main/java/zone/moddev/mc/lib/api/capabilities/EnergyBuffer.java +++ b/src/main/java/zone/moddev/mc/lib/api/capabilities/EnergyBuffer.java @@ -160,6 +160,20 @@ public boolean consume(long amount) { return true; } + /** Escrows a transfer charge without counting it as processor use or external output. */ + public boolean reserve(long amount) { + if (amount < 0 || amount > stored) return false; + setStored(stored - amount); + return true; + } + + /** Returns unused transfer escrow without counting it as external input. */ + public long refund(long amount) { + long accepted = Math.clamp(amount, 0, space()); + setStored(stored + accepted); + return accepted; + } + @Override public int receiveEnergy(int toReceive, boolean simulate) { return (int) insert(Math.min(toReceive, maxInsert), simulate); diff --git a/src/main/java/zone/moddev/mc/lib/api/capabilities/FluidStorage.java b/src/main/java/zone/moddev/mc/lib/api/capabilities/FluidStorage.java index b120622c..fb6e5df6 100644 --- a/src/main/java/zone/moddev/mc/lib/api/capabilities/FluidStorage.java +++ b/src/main/java/zone/moddev/mc/lib/api/capabilities/FluidStorage.java @@ -17,7 +17,6 @@ import net.neoforged.neoforge.fluids.capability.IFluidHandler; import net.neoforged.neoforge.fluids.capability.templates.FluidTank; -import zone.moddev.mc.lib.api.stats.StatsProvider; import zone.moddev.mc.lib.machine.inventory.ReservationPlans; import zone.moddev.mc.lib.machine.inventory.TankPlan; import zone.moddev.mc.lib.machine.neoforge.FluidStacks; @@ -31,12 +30,7 @@ * accepts it. Draining picks the first tank holding the requested fluid, or for an amount, the * first tank that is not empty. */ -public class FluidStorage implements IFluidHandler, INBTSerializable, StatsProvider { - @Override - public zone.moddev.mc.lib.api.stats.Statistics statistics() { - return zone.moddev.mc.lib.api.stats.Statistics.EMPTY; - } - +public class FluidStorage implements IFluidHandler, INBTSerializable { private final List tanks = new ArrayList<>(); private final List roles = new ArrayList<>(); private final ReservationPlans plans = diff --git a/src/main/java/zone/moddev/mc/lib/api/capabilities/ItemStorage.java b/src/main/java/zone/moddev/mc/lib/api/capabilities/ItemStorage.java index 853c39d3..85ade1f6 100644 --- a/src/main/java/zone/moddev/mc/lib/api/capabilities/ItemStorage.java +++ b/src/main/java/zone/moddev/mc/lib/api/capabilities/ItemStorage.java @@ -14,8 +14,6 @@ import net.neoforged.neoforge.items.IItemHandler; import net.neoforged.neoforge.items.ItemStackHandler; -import zone.moddev.mc.lib.api.stats.StatsProvider; - /** * A machine inventory: an {@link ItemStackHandler} with a {@link Role}, filter and limit per slot, * and a change callback. The owning block entity and its screen use it directly; automation gets a @@ -25,12 +23,7 @@ * refuse placement, so players cannot put items into outputs. The machine fills outputs with {@link * #insertResult}. */ -public class ItemStorage extends PlannedItemHandler implements StatsProvider { - @Override - public zone.moddev.mc.lib.api.stats.Statistics statistics() { - return zone.moddev.mc.lib.api.stats.Statistics.EMPTY; - } - +public class ItemStorage extends PlannedItemHandler { private final Role[] roles; private final int[] limits; diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/AutomationDrawer.java b/src/main/java/zone/moddev/mc/lib/gui/client/AutomationDrawer.java index 2a5ef8a2..99c26b5b 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/AutomationDrawer.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/AutomationDrawer.java @@ -1,5 +1,7 @@ package zone.moddev.mc.lib.gui.client; +import static zone.moddev.mc.lib.api.automation.AutomationInventory.COLUMNS; + import java.util.*; import net.minecraft.client.Minecraft; @@ -90,7 +92,7 @@ public void layout() { panel.right(), panel.y() + AutomationLayout.TOP, RAIL, AutomationLayout.TAB_HEIGHT); syncEditor(); boolean hasFilterSlot = false, hasFilter = false; - for (int c = 0; c < 4; c++) { + for (int c = 0; c < COLUMNS; c++) { hasFilterSlot |= model.supportsFilter(model.selected(), c); hasFilter |= !model.filter(model.selected(), c).isEmpty(); } @@ -336,7 +338,7 @@ public void drawBackground(GuiGraphics g, int mx, int my) { arrow(g, new Rect(selector.x() + 3, selector.bottom() + 1, 8, 6), true, true); } for (int f = 0; f < 6; f++) drawFace(g, f, mx, my); - for (int c = 0; model.addonsEnabled() && c < 4; c++) { + for (int c = 0; model.addonsEnabled() && c < COLUMNS; c++) { sprite(g, "slot", module(c)); if (module(c).contains(mx, my) && model.addon(model.selected(), c).isEmpty()) tooltip = @@ -618,7 +620,7 @@ else if (y >= selector.bottom()) button == 1 ? "reverse" : ""); return true; } - for (int c = 0; c < 4; c++) { + for (int c = 0; c < COLUMNS; c++) { if (model.supportsFilter(model.selected(), c) && !model.filter(model.selected(), c).isEmpty() && filterToggle(c).contains(x, y)) { diff --git a/src/main/java/zone/moddev/mc/lib/gui/layout/AutomationLayout.java b/src/main/java/zone/moddev/mc/lib/gui/layout/AutomationLayout.java index 45344ebe..094a2669 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/layout/AutomationLayout.java +++ b/src/main/java/zone/moddev/mc/lib/gui/layout/AutomationLayout.java @@ -1,5 +1,7 @@ package zone.moddev.mc.lib.gui.layout; +import static zone.moddev.mc.lib.api.automation.AutomationInventory.COLUMNS; + /** Shared geometry for automation rendering, real menu slots, hit boxes and exclusions. */ public final class AutomationLayout { public static final int WIDTH = 112, RAIL = ScreenLayout.POPOUT_TAB; @@ -31,8 +33,8 @@ public static Rect filterToggle(int column) { /** Item bounds inside a frame, relative to the drawer body; addon slots precede filters. */ public static Rect item(int subSlot) { - var frame = subSlot < 4 ? addon(subSlot) : filter(subSlot - 4); - int size = subSlot < 4 ? 16 : FILTER_ITEM_SIZE; + var frame = subSlot < COLUMNS ? addon(subSlot) : filter(subSlot - COLUMNS); + int size = subSlot < COLUMNS ? 16 : FILTER_ITEM_SIZE; return new Rect(frame.x() + 1, frame.y() + 1, size, size); } diff --git a/src/main/java/zone/moddev/mc/lib/gui/layout/ScreenLayout.java b/src/main/java/zone/moddev/mc/lib/gui/layout/ScreenLayout.java index 417f1d08..a8995190 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/layout/ScreenLayout.java +++ b/src/main/java/zone/moddev/mc/lib/gui/layout/ScreenLayout.java @@ -133,8 +133,7 @@ public static ScreenLayout of( // The inventory already reserves one slot row beside the helmet. Only taller controls // need more space; ordinary flat controls and colour selectors use that existing row. int extraHeight = Math.max(0, rowHeight - 18); - layout.bounds.replaceAll( - (element, rect) -> rect.y() >= start ? rect.offset(0, extraHeight) : rect); + for (var element : screen.body()) layout.shiftInventoryBody(element, start, extraHeight); layout.inventoryColors = new Rect(layout.inventory.x(), start, colourWidth, rowHeight - GAP); if (controls != null) layout.placeAt( @@ -146,6 +145,12 @@ public static ScreenLayout of( return layout; } + private void shiftInventoryBody(Element element, int start, int dy) { + var rect = bounds.get(element); + if (rect != null && rect.y() >= start) bounds.put(element, rect.offset(0, dy)); + for (var child : element.children()) shiftInventoryBody(child, start, dy); + } + /** The main panel, at (0, 0). */ public Rect panel() { return panel; diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/AutomationMenu.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/AutomationMenu.java index 9921e276..91dea4d2 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/runtime/AutomationMenu.java +++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/AutomationMenu.java @@ -1,5 +1,8 @@ package zone.moddev.mc.lib.gui.runtime; +import static zone.moddev.mc.lib.api.automation.AutomationInventory.COLUMNS; +import static zone.moddev.mc.lib.api.automation.AutomationInventory.SLOTS_PER_GROUP; + import java.util.*; import java.util.function.Consumer; @@ -45,7 +48,7 @@ public AutomationMenu( this.client = client; addons = !(menu.blockEntity instanceof ScreenHost host) || host.screenAutomationAddons(); modes = new int[inventory.groups().size() * 6]; - generations = new long[inventory.groups().size() * 4]; + generations = new long[inventory.groups().size() * COLUMNS]; revisions = new long[generations.length]; items = client @@ -57,10 +60,10 @@ public int getSlotLimit(int slot) { @Override public boolean isItemValid(int slot, ItemStack stack) { - int g = slot / 8, sub = slot % 8; - if (sub < 4) + int g = slot / SLOTS_PER_GROUP, sub = slot % SLOTS_PER_GROUP; + if (sub < COLUMNS) return AutomationInventory.acceptsAddon(inventory.group(g), this, slot, stack); - var parent = getStackInSlot(g * 8 + sub - 4); + var parent = getStackInSlot(g * SLOTS_PER_GROUP + sub - COLUMNS); var addon = parent.getCapability(AddonCapabilities.ADDON); return addon != null && addon.specification(parent).accepts(inventory.group(g)) @@ -104,19 +107,19 @@ public int slotStart() { } public ItemStack addon(int g, int c) { - return addons ? items.getStackInSlot(g * 8 + c) : ItemStack.EMPTY; + return addons ? items.getStackInSlot(g * SLOTS_PER_GROUP + c) : ItemStack.EMPTY; } public ItemStack filter(int g, int c) { - return addons ? items.getStackInSlot(g * 8 + 4 + c) : ItemStack.EMPTY; + return addons ? items.getStackInSlot(g * SLOTS_PER_GROUP + COLUMNS + c) : ItemStack.EMPTY; } public long generation(int g, int c) { - return client ? generations[g * 4 + c] : inventory.generation(g, c); + return client ? generations[g * COLUMNS + c] : inventory.generation(g, c); } public long revision(int g, int c) { - return client ? revisions[g * 4 + c] : inventory.revision(g, c); + return client ? revisions[g * COLUMNS + c] : inventory.revision(g, c); } public IoMode mode(int g, int face) { @@ -137,16 +140,16 @@ public void addSlots(Consumer add, int start, int bodyX) { this.start = start; for (int i = 0; i < slotCount(); i++) { final int index = i; - var bounds = AutomationLayout.item(i % 8).offset(bodyX, AutomationLayout.TOP); + var bounds = AutomationLayout.item(i % SLOTS_PER_GROUP).offset(bodyX, AutomationLayout.TOP); var slot = new SlotItemHandler(items, index, bounds.x(), bounds.y()) { @Override public boolean isActive() { return open - && index / 8 == selected - && (index % 8 < 4 - || supportsFilter(index / 8, index % 4) - || !filter(index / 8, index % 4).isEmpty()) + && index / SLOTS_PER_GROUP == selected + && (index % SLOTS_PER_GROUP < COLUMNS + || supportsFilter(index / SLOTS_PER_GROUP, index % COLUMNS) + || !filter(index / SLOTS_PER_GROUP, index % COLUMNS).isEmpty()) && (!client || !pending); } @@ -176,6 +179,14 @@ public void setChanged() { } } + /** Flush unattributed recoveries before this player's synchronous inventory operation. */ + public void beforeInteraction() { + if (!client) { + inventory.revalidate(); + inventory.recover(null); + } + } + public void afterInteraction() { if (!client) { inventory.revalidate(); @@ -206,8 +217,8 @@ public void tick() { public boolean send(String kind, int group, int column, String control, int entry, String value) { if (!client || pending || nextRequest == Integer.MAX_VALUE) return false; - long generation = column >= 0 && column < 4 ? generation(group, column) : 0; - long revision = column >= 0 && column < 4 ? revision(group, column) : 0; + long generation = column >= 0 && column < COLUMNS ? generation(group, column) : 0; + long revision = column >= 0 && column < COLUMNS ? revision(group, column) : 0; pending = true; pendingTicks = 0; PacketDistributor.sendToServer( @@ -275,7 +286,7 @@ public void action(Player player, AutomationNetwork.Action action) { if (open && g == selected && c >= 0 - && c < 4 + && c < COLUMNS && supportsFilter(g, c) && !filter(g, c).isEmpty() && inventory.generation(g, c) == action.generation()) { @@ -297,7 +308,7 @@ && supportsFilter(g, c) if (open && g == selected && c >= 0 - && c < 4 + && c < COLUMNS && editor == c && action.generation() == editorGeneration) accepted = @@ -328,15 +339,15 @@ && supportsFilter(g, c) private AutomationNetwork.State snapshot(boolean accepted) { int n = inventory.groups().size(); int[] modes = new int[n * 6]; - long[] gens = new long[n * 4], revs = new long[n * 4]; + long[] gens = new long[n * COLUMNS], revs = new long[n * COLUMNS]; String[] ids = new String[n]; for (int g = 0; g < n; g++) { ids[g] = inventory.group(g).definitionKey(); for (int f = 0; f < 6; f++) modes[g * 6 + f] = inventory.group(g).sides().get(RelativeFace.values()[f]).ordinal(); - for (int c = 0; c < 4; c++) { - gens[g * 4 + c] = inventory.generation(g, c); - revs[g * 4 + c] = inventory.revision(g, c); + for (int c = 0; c < COLUMNS; c++) { + gens[g * COLUMNS + c] = inventory.generation(g, c); + revs[g * COLUMNS + c] = inventory.revision(g, c); } } return new AutomationNetwork.State( @@ -357,6 +368,10 @@ private void transmit(boolean accepted) { if (owner instanceof net.minecraft.server.level.ServerPlayer player && player.connection.hasChannel(AutomationNetwork.State.TYPE)) PacketDistributor.sendToPlayer(player, snapshot(accepted)); + lastInventoryRevision = inventory.viewRevision(); + lastSelected = selected; + lastOpen = open; + lastEditor = editor; } public void broadcast() { @@ -366,10 +381,6 @@ public void broadcast() { || selected != lastSelected || open != lastOpen || editor != lastEditor) { - lastInventoryRevision = revision; - lastSelected = selected; - lastOpen = open; - lastEditor = editor; transmit(true); } } diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/AutomationNetwork.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/AutomationNetwork.java index ea226780..86c0f185 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/runtime/AutomationNetwork.java +++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/AutomationNetwork.java @@ -1,5 +1,7 @@ package zone.moddev.mc.lib.gui.runtime; +import static zone.moddev.mc.lib.api.automation.AutomationInventory.COLUMNS; + import net.minecraft.network.RegistryFriendlyByteBuf; import net.minecraft.network.codec.StreamCodec; import net.minecraft.network.protocol.common.custom.CustomPacketPayload; @@ -109,10 +111,10 @@ public record State( if (modes.length != count * 6) throw new IllegalArgumentException("Face count"); for (int mode : modes) if (mode < 0 || mode > 3) throw new IllegalArgumentException("Face mode"); - long[] generations = new long[count * 4], revisions = new long[count * 4]; + long[] generations = new long[count * COLUMNS], revisions = new long[count * COLUMNS]; for (int i = 0; i < generations.length; i++) generations[i] = b.readLong(); for (int i = 0; i < revisions.length; i++) revisions[i] = b.readLong(); - if (selected < 0 || selected >= count || editor < -1 || editor >= 4) + if (selected < 0 || selected >= count || editor < -1 || editor >= COLUMNS) throw new IllegalArgumentException("Drawer selection"); return new State( menu, diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/MachineMenu.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/MachineMenu.java index 227fd540..4611af0c 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/runtime/MachineMenu.java +++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/MachineMenu.java @@ -36,7 +36,7 @@ public class MachineMenu extends AbstractContainerMenu { private final Map visibility = new IdentityHashMap<>(); private final Map elementIndexes = new IdentityHashMap<>(); private final Map values = new HashMap<>(); - private final ScreenStats stats = new ScreenStats(); + private final ScreenStats stats; /** Server-synchronized stats for tick, second or minute rate display. */ public List stats(int unit) { @@ -263,6 +263,7 @@ protected MachineMenu(int id, Inventory inventory, BlockEntity entity, boolean c if (element instanceof Element.SideConfig config && config.groups().size() > 682) throw new IllegalArgumentException("Too many side configuration groups"); host = entity instanceof ScreenHost h ? h : null; + stats = client || host == null ? new ScreenStats() : ScreenStats.forHost(host); automation = host != null && host.screenAutomation() != null ? new AutomationMenu(this, host.screenAutomation(), owner, client) @@ -507,6 +508,7 @@ public boolean stillValid(Player player) { public void clicked(int slot, int button, ClickType type, Player player) { if (slot >= 0 && slot < slots.size() && !slots.get(slot).isActive()) return; boolean previous = interacting; + if (!previous && automation != null) automation.beforeInteraction(); interacting = true; try { super.clicked(slot, button, type, player); @@ -526,6 +528,7 @@ private void endInteraction(boolean previous) { @Override public ItemStack quickMoveStack(Player player, int index) { boolean previous = interacting; + if (!previous && automation != null) automation.beforeInteraction(); interacting = true; try { return moveStack(player, index); diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenStats.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenStats.java index 0cbc040c..2a652cb0 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenStats.java +++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenStats.java @@ -13,6 +13,20 @@ final class ScreenStats { static final int KEY = 1 << 21; + // Menus own the sampler; weak keys AND values prevent provider closures retaining machines. + private static final Map> SHARED = + new WeakHashMap<>(); + + static ScreenStats forHost(ScreenHost host) { + var reference = SHARED.get(host); + var shared = reference == null ? null : reference.get(); + if (shared == null) { + shared = new ScreenStats(); + SHARED.put(host, new java.lang.ref.WeakReference<>(shared)); + } + return shared; + } + private record Sampled(int window, CounterRate rate) {} private record Part(StatsProvider provider, Statistics section) {} diff --git a/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java b/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java index 883fbde1..e76ae2e5 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java +++ b/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java @@ -48,6 +48,8 @@ public enum Status { STALLED, /** A cycle has run its time but cannot finish, say an input was taken; retried each tick. */ BLOCKED, + /** Held by the host's processing gate; progress and reservations are retained. */ + HELD, /** Held by an interrupt. */ PAUSED } @@ -205,6 +207,13 @@ private int redstoneProgress() { public void suspended(FeatureHost host) { redstoneRunning = false; justFinished = null; + var held = paused ? Status.PAUSED : Status.HELD; + if (changed || status != held) { + changed = false; + status = held; + host.markChanged(); + } + syncStatus(host); } @Override @@ -391,6 +400,10 @@ public void tick(FeatureHost host) { justFinished = null; run(host); redstoneRunning = !paused && (status == Status.RUNNING || justFinished != null); + syncStatus(host); + } + + private void syncStatus(FeatureHost host) { var seen = justFinished != null ? Status.RUNNING : status; int seenTime = justFinished != null ? justFinished.time() : time(); if (seen != marked || seenTime != markedTime) { diff --git a/src/test/java/zone/moddev/mc/lib/gui/RuntimeDrawerLayoutTest.java b/src/test/java/zone/moddev/mc/lib/gui/RuntimeDrawerLayoutTest.java index fad2083c..8bf99143 100644 --- a/src/test/java/zone/moddev/mc/lib/gui/RuntimeDrawerLayoutTest.java +++ b/src/test/java/zone/moddev/mc/lib/gui/RuntimeDrawerLayoutTest.java @@ -1,15 +1,61 @@ package zone.moddev.mc.lib.gui; import static org.junit.jupiter.api.Assertions.*; + import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; import java.util.Map; + import net.minecraft.resources.ResourceLocation; + import org.junit.jupiter.api.Test; + import zone.moddev.mc.lib.gui.layout.*; import zone.moddev.mc.lib.gui.load.ScreenReader; class RuntimeDrawerLayoutTest { + @Test + void tallInventoryControlsDoNotMoveEdgeTabsOrTheirContents() throws Exception { + ScreenLayout previous = null; + zone.moddev.mc.lib.gui.model.Screen previousScreen = null; + for (boolean flat : new boolean[] {true, false}) { + var xml = + """ + + +