Skip to content

Commit 0a6f933

Browse files
committed
Harden powered machine lifecycle and add warnings and client sync hooks
1 parent e937773 commit 0a6f933

7 files changed

Lines changed: 390 additions & 37 deletions

File tree

‎src/companion/java/zone/moddev/mc/companion/ProcessorEntity.java‎

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -216,10 +216,6 @@ public FluidStack getTank3() {
216216
return fluids.getFluidInTank(3);
217217
}
218218

219-
private boolean redstoneAllowsRun() {
220-
return !redstone.paused();
221-
}
222-
223219
public void toggle(int index) {
224220
if (index < 0 || index >= lanes.size()) return;
225221
var processor = lanes.get(index);
@@ -248,25 +244,10 @@ public int getSecondTime() {
248244

249245
@Override
250246
public List<Component> getWarnings() {
251-
var warnings = new ArrayList<Component>();
247+
var warnings = new ArrayList<Component>(super.getWarnings());
252248
if (energy.stored() < energy.capacity() * reservePercent / 100)
253249
warnings.add(
254250
Component.translatable("screen.mmdlib_companion.reserve.waiting", reservePercent));
255-
if (!redstoneAllowsRun())
256-
warnings.add(Component.translatable("screen.mmdlib_companion.redstone.waiting"));
257-
for (int i = 0; i < lanes.size(); i++) {
258-
var status = lanes.get(i).status();
259-
if (status == RecipeProcessor.Status.STALLED
260-
|| status == RecipeProcessor.Status.BLOCKED
261-
|| status == RecipeProcessor.Status.HELD)
262-
warnings.add(
263-
Component.translatable("screen.mmdlib_companion.lane_" + (i == 0 ? "a" : "b"))
264-
.append(": ")
265-
.append(
266-
Component.translatable(
267-
"status.mmdlib_companion."
268-
+ status.name().toLowerCase(java.util.Locale.ROOT))));
269-
}
270251
return warnings;
271252
}
272253

‎src/companion/resources/assets/mmdlib_companion/lang/en_us.json‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,12 @@
1515
"screen.mmdlib_companion.redstone.high": "Run when powered",
1616
"screen.mmdlib_companion.redstone.low": "Run when unpowered",
1717
"screen.mmdlib_companion.redstone.tip": "Redstone: {value}. Click to cycle Ignore / Powered / Unpowered.",
18-
"screen.mmdlib_companion.redstone.waiting": "Waiting for redstone",
1918
"screen.mmdlib_companion.lane_a": "Lane A",
2019
"screen.mmdlib_companion.lane_b": "Lane B",
2120
"screen.mmdlib_companion.single": "One iron in, one gold out",
2221
"screen.mmdlib_companion.power": "Powers adjacent blocks on enabled sides",
2322
"mmdlib_companion:creative_power.info": "*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.",
2423
"screen.mmdlib_companion.rate": "1,000 FE per face per tick",
25-
"status.mmdlib_companion.stalled": "Needs power",
26-
"status.mmdlib_companion.blocked": "Blocked",
27-
"status.mmdlib_companion.held": "Held by machine controls",
2824
"block.mmdlib_companion.crusher": "Crusher",
2925
"block.mmdlib_companion.transmuter": "Iron-to-Gold Converter",
3026
"block.mmdlib_companion.dual_crusher": "Dual Crusher",
Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
package zone.moddev.mc.companion.test;
2+
3+
import java.util.List;
4+
import java.util.Optional;
5+
6+
import com.mojang.serialization.Codec;
7+
8+
import net.minecraft.core.BlockPos;
9+
import net.minecraft.core.HolderLookup;
10+
import net.minecraft.gametest.framework.GameTest;
11+
import net.minecraft.gametest.framework.GameTestHelper;
12+
import net.minecraft.nbt.CompoundTag;
13+
import net.minecraft.network.chat.Component;
14+
import net.minecraft.network.chat.contents.TranslatableContents;
15+
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
16+
import net.minecraft.world.item.ItemStack;
17+
import net.minecraft.world.item.Items;
18+
import net.minecraft.world.level.Level;
19+
20+
import net.neoforged.neoforge.gametest.GameTestHolder;
21+
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
22+
23+
import zone.moddev.mc.companion.CompanionMod;
24+
import zone.moddev.mc.companion.ProcessorEntity;
25+
import zone.moddev.mc.lib.api.automation.*;
26+
import zone.moddev.mc.lib.api.capabilities.*;
27+
import zone.moddev.mc.lib.api.redstone.RedstoneControl;
28+
import zone.moddev.mc.lib.machine.feature.*;
29+
import zone.moddev.mc.lib.machine.neoforge.*;
30+
31+
@GameTestHolder(CompanionMod.ID)
32+
@PrefixGameTestTemplate(false)
33+
public final class BasePoweredMachineTests {
34+
private static void require(boolean condition, String message) {
35+
if (!condition) throw new AssertionError(message);
36+
}
37+
38+
@GameTest(template = "empty")
39+
public static void ownedPowerSurvivesEvenWhenCustomLifecycleOmitsIt(GameTestHelper helper) {
40+
var registries = helper.getLevel().registryAccess();
41+
for (int mode = 0; mode < 3; mode++) {
42+
var original = new Probe(helper, mode);
43+
original.energy.setStored(1234);
44+
original.featureValue = 7;
45+
if (mode != 0) original.items.setStackInSlot(0, new ItemStack(Items.DIAMOND, 3));
46+
var saved = original.saveWithoutMetadata(registries);
47+
var restored = new Probe(helper, mode);
48+
restored.loadWithComponents(saved, registries);
49+
require(
50+
restored.getEnergy() == 1234 && restored.getEnergyCapacity() == 3210,
51+
"Base owns power persistence with no lifecycle, a raw lifecycle, or its factory");
52+
require(
53+
restored.powerAtRestore == 1234 && restored.featureValue == 7,
54+
"Owned power loads before feature restoration");
55+
if (mode != 0)
56+
require(restored.items.getStackInSlot(0).getCount() == 3, "Custom storage also survives");
57+
restored.clearContent();
58+
require(
59+
restored.getEnergy() == 0
60+
&& restored.featureValue == 0
61+
&& restored.items.getStackInSlot(0).isEmpty(),
62+
"Clear includes power even when the subclass omitted it");
63+
}
64+
var conflict = new Probe(helper, 1);
65+
conflict.lifecycle.energy("energy", new EnergyBuffer(100, 10));
66+
try {
67+
conflict.saveWithoutMetadata(registries);
68+
throw new AssertionError("A conflicting energy binding must fail");
69+
} catch (IllegalArgumentException expected) {
70+
require(expected.getMessage().contains("energy"), "Binding error identifies energy");
71+
}
72+
helper.succeed();
73+
}
74+
75+
@GameTest(template = "empty")
76+
public static void customClientUpdatesAreIsolatedFromPersistence(GameTestHelper helper) {
77+
var registries = helper.getLevel().registryAccess();
78+
var server = new Probe(helper, 1);
79+
server.featureValue = 9;
80+
server.visual = 42;
81+
var client = new Probe(helper, 1);
82+
client.energy.setStored(2345);
83+
client.items.setStackInSlot(0, new ItemStack(Items.EMERALD, 4));
84+
client.metadata = 61;
85+
var packet = (ClientboundBlockEntityDataPacket) server.getUpdatePacket();
86+
client.loadWithComponents(packet.getTag(), registries);
87+
require(
88+
client.featureValue == 9 && client.visual == 42 && client.clientReads == 1,
89+
"Normal update packets carry features and custom renderer state together");
90+
require(
91+
client.getEnergy() == 2345
92+
&& client.items.getStackInSlot(0).getCount() == 4
93+
&& client.metadata == 61
94+
&& client.worldReads == 0,
95+
"Client hook data cannot overwrite common storage or run world-load hooks");
96+
client.loadWithComponents(FeatureSync.updateTag(server.features, registries), registries);
97+
require(
98+
client.visual == 42 && client.clientReads == 1,
99+
"Older feature-only updates do not reset custom client state");
100+
server.visual = 0;
101+
client.loadWithComponents(server.getUpdateTag(registries), registries);
102+
require(
103+
client.visual == 0 && client.clientReads == 2,
104+
"Custom state can be cleared by a later update");
105+
var saved = client.saveWithoutMetadata(registries);
106+
require(!saved.contains("mmdlib_machine"), "Client-only payload is not written to world saves");
107+
var restored = new Probe(helper, 1);
108+
restored.visual = 88;
109+
restored.loadWithComponents(saved, registries);
110+
require(
111+
restored.metadata == 61
112+
&& restored.visual == 88
113+
&& restored.clientReads == 0
114+
&& restored.worldReads == 1,
115+
"World saves use their own metadata hook without invoking the client hook");
116+
server.sendVisualUpdate();
117+
helper.succeed();
118+
}
119+
120+
@GameTest(template = "empty")
121+
public static void baseWarningsDescribeProcessorsAndRespectPausePolicy(GameTestHelper helper) {
122+
var probe = new Probe(helper, 0);
123+
var resources =
124+
new ProcessorEntity(
125+
CompanionMod.DUAL_CRUSHER.type.get(),
126+
probe.getBlockPos(),
127+
CompanionMod.DUAL_CRUSHER.block.get().defaultBlockState(),
128+
ProcessorEntity.Kind.DUAL);
129+
for (var lane : resources.lanes) probe.features.add(lane);
130+
require(
131+
probe.getWarnings().isEmpty(),
132+
"Idle processors and unrelated features produce no warnings");
133+
var first = resources.lanes.getFirst();
134+
for (var status : RecipeProcessor.Status.values()) {
135+
first.restoreOnClient(
136+
new RecipeProcessor.State(
137+
status == RecipeProcessor.Status.PAUSED,
138+
0,
139+
Optional.empty(),
140+
0,
141+
Optional.empty(),
142+
Optional.of(status)));
143+
var key =
144+
switch (status) {
145+
case STALLED -> "screen.mmdlib.machine.stalled";
146+
case BLOCKED -> "screen.mmdlib.machine.blocked";
147+
case HELD -> "screen.mmdlib.machine.held";
148+
default -> null;
149+
};
150+
var warnings = probe.getWarnings();
151+
if (key == null) require(warnings.isEmpty(), "Normal/manual states are not automatic holds");
152+
else {
153+
require(warnings.size() == 1, "Exactly one warning for the affected processor");
154+
var text = (TranslatableContents) warnings.getFirst().getContents();
155+
require(
156+
text.getArgs()[0].equals(first.displayName()),
157+
"Warnings use the registered processor name");
158+
require(
159+
((Component) text.getArgs()[1]).getContents() instanceof TranslatableContents cause
160+
&& cause.getKey().equals(key),
161+
"Warning uses the library's status translation");
162+
}
163+
}
164+
probe.redstone.migrate(1);
165+
probe.redstone.readInputs();
166+
require(
167+
probe.redstone.paused() && probe.getWarnings().size() == 1, "Redstone hold is reported");
168+
probe.policy = MachineInventoryLifecycle.PausePolicy.TRANSFERS_ONLY;
169+
require(
170+
probe.getWarnings().isEmpty(), "A transfer-only pause does not claim processing is held");
171+
resources.setReservePercent(50);
172+
require(
173+
resources.getWarnings().stream()
174+
.anyMatch(
175+
c ->
176+
c.getContents() instanceof TranslatableContents t
177+
&& t.getKey().equals("screen.mmdlib_companion.reserve.waiting")),
178+
"Subclasses retain custom warnings");
179+
helper.succeed();
180+
}
181+
182+
private static final class Probe extends BasePoweredMachine {
183+
final ItemStorage items = new ItemStorage(1);
184+
final MachineInventoryLifecycle lifecycle;
185+
final RedstoneControl redstone = new RedstoneControl(this, List.of());
186+
MachineInventoryLifecycle.PausePolicy policy =
187+
MachineInventoryLifecycle.PausePolicy.PROCESSING_ONLY;
188+
int featureValue, visual, metadata, clientReads, worldReads;
189+
long powerAtRestore;
190+
191+
Probe(GameTestHelper helper, int mode) {
192+
super(
193+
CompanionMod.CRUSHER.type.get(),
194+
helper.absolutePos(new BlockPos(2, 2, 2)),
195+
CompanionMod.CRUSHER.block.get().defaultBlockState(),
196+
3210,
197+
40,
198+
20);
199+
setLevel(helper.getLevel());
200+
var group =
201+
new ResourceGroup<net.neoforged.neoforge.energy.IEnergyStorage>(
202+
"energy",
203+
Component.literal("Energy"),
204+
0xffffff,
205+
ResourceType.ENERGY,
206+
IoMode.BOTH,
207+
List.of(0),
208+
List.of("energy"),
209+
new SideConfig(IoMode.BOTH));
210+
var automation = new AutomationInventory(this, List.of(group));
211+
lifecycle =
212+
mode == 0
213+
? null
214+
: (mode == 1
215+
? new MachineInventoryLifecycle(this, features, automation, energy)
216+
: poweredInventory(automation))
217+
.items("items", items);
218+
features.add(
219+
new MachineFeature<Integer>() {
220+
public String key() {
221+
return "probe";
222+
}
223+
224+
public Codec<Integer> codec() {
225+
return Codec.INT;
226+
}
227+
228+
public Integer state() {
229+
return featureValue;
230+
}
231+
232+
public void restore(Integer value) {
233+
featureValue = value;
234+
powerAtRestore = energy.stored();
235+
}
236+
237+
public void reset() {
238+
featureValue = 0;
239+
}
240+
241+
public boolean syncsToClient() {
242+
return true;
243+
}
244+
});
245+
}
246+
247+
@Override
248+
public ItemStorage screenItems() {
249+
return items;
250+
}
251+
252+
@Override
253+
public RedstoneControl screenRedstone() {
254+
return redstone;
255+
}
256+
257+
@Override
258+
protected MachineInventoryLifecycle machineInventory() {
259+
return lifecycle;
260+
}
261+
262+
@Override
263+
protected MachineInventoryLifecycle.PausePolicy pausePolicy() {
264+
return policy;
265+
}
266+
267+
@Override
268+
protected void saveMachineData(CompoundTag tag, HolderLookup.Provider registries) {
269+
tag.putInt("metadata", metadata);
270+
}
271+
272+
@Override
273+
protected void loadMachineData(CompoundTag tag, HolderLookup.Provider registries) {
274+
metadata = tag.getInt("metadata");
275+
worldReads++;
276+
}
277+
278+
@Override
279+
protected void saveClientData(CompoundTag tag, HolderLookup.Provider registries) {
280+
tag.putInt("visual", visual);
281+
tag.putInt("expected_feature", featureValue);
282+
// These names must remain isolated from the base's persistence and sync envelopes.
283+
tag.putLong("energy", -1);
284+
tag.putString("mmdlib_features", "custom");
285+
}
286+
287+
@Override
288+
protected void loadClientData(CompoundTag tag, HolderLookup.Provider registries) {
289+
require(
290+
featureValue == tag.getInt("expected_feature"),
291+
"Features restore before custom client state");
292+
visual = tag.getInt("visual");
293+
clientReads++;
294+
}
295+
296+
void sendVisualUpdate() {
297+
syncToClient();
298+
}
299+
300+
@Override
301+
protected void recoverResources(Level level, BlockPos pos, CompoundTag payload) {
302+
throw new AssertionError("Unexpected recovery");
303+
}
304+
}
305+
}

‎src/main/java/zone/moddev/mc/lib/machine/feature/FeatureHost.java‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,14 @@ public List<zone.moddev.mc.lib.api.stats.StatsProvider> statisticsProviders() {
8989
.toList();
9090
}
9191

92+
/** Recipe processors in registration order, excluding unrelated features. */
93+
public List<RecipeProcessor> processors() {
94+
return features.values().stream()
95+
.filter(RecipeProcessor.class::isInstance)
96+
.map(RecipeProcessor.class::cast)
97+
.toList();
98+
}
99+
92100
/** Persists and synchronizes manual changes even while the host's processing tick is held. */
93101
public boolean setProcessorPaused(String key, boolean paused) {
94102
var processor = processor(key).orElse(null);

0 commit comments

Comments
 (0)