diff --git a/src/main/java/com/gregtechceu/gtceu/api/machine/feature/multiblock/IMultiController.java b/src/main/java/com/gregtechceu/gtceu/api/machine/feature/multiblock/IMultiController.java index 95700eb2f56..f1f4b0136b3 100644 --- a/src/main/java/com/gregtechceu/gtceu/api/machine/feature/multiblock/IMultiController.java +++ b/src/main/java/com/gregtechceu/gtceu/api/machine/feature/multiblock/IMultiController.java @@ -39,7 +39,7 @@ default MultiblockControllerMachine self() { /** * Check MultiBlock Pattern. Just checking pattern without any other logic. - * You can override it but it's unsafe for calling. because it will also be called in an async thread. + * You can override it, but direct calls are unsafe because multiple lifecycle paths may request a check. *
* you should always use {@link IMultiController#checkPatternWithLock()} and * {@link IMultiController#checkPatternWithTryLock()} instead. @@ -91,13 +91,68 @@ default BlockPattern getPattern() { } /** - * Whether Multiblock Formed. - *
- * NOTE: even machine is formed, it doesn't mean to workable! - * Its parts maybe invalid due to chunk unload. + * Whether the multiblock is currently safe to expose as formed to gameplay code. Implementations with deferred + * validation should return false while that validation is pending. */ boolean isFormed(); + /** + * The last successfully persisted or observed formation state, including while transient revalidation is pending. + */ + boolean isStructureFormedSnapshot(); + + /** + * The mirrored orientation owned by the last completed structure validation. + */ + boolean isStructureFlippedSnapshot(); + + /** + * Whether this controller is waiting for a full, server-thread structure validation. + *

+ * This is deliberately separate from {@link #isStructureFormedSnapshot()}: a controller may retain its formation + * snapshot while one of its structure chunks is temporarily unavailable, but it must not operate until validation + * succeeds. + */ + boolean isStructureRevalidationPending(); + + /** + * Update the transient structure-validation state. Implementations must pause and resume work subscriptions when + * this value changes. + */ + void setStructureRevalidationPending(boolean pending); + + /** + * Whether the structure is both formed and fully validated for operation. + */ + default boolean isStructureOperational() { + return isStructureFormedSnapshot() && !isStructureRevalidationPending(); + } + + /** + * Stable identity for the controller's current structure-ownership epoch. A definitive invalidation retires the + * epoch, while transient unloads retain it; the next successful formation receives a newer identity. Zero means + * no ownership epoch has been assigned. Callers must pair it with {@code self().getPos()}. + */ + long getStructureInstanceId(); + + /** + * Assign a persistent ownership-epoch identity. + */ + void setStructureInstanceId(long instanceId); + + /** + * Called after this exact controller state has been installed in the world mapping. Controllers persist the + * participating chunk set so their first post-restart validation cannot select another geometry while an old + * structure chunk is unavailable. + */ + void onStructureMappingInstalled(long[] mappedChunks); + + /** + * Snapshot of chunks owned by the last confirmed structure validation. Schedulers use this to wait for the exact + * unloaded chunks without requesting them. + */ + long[] getConfirmedStructureChunks(); + /** * Get MultiblockState. It records all structure-related information. */ @@ -105,10 +160,9 @@ default BlockPattern getPattern() { MultiblockState getMultiblockState(); /** - * Called in an async thread. It's unsafe, Don't modify anything of world but checking information. - * It will be called per 5 tick. + * Called on the server thread for controllers waiting to form or recover from an unloaded chunk. * - * @param periodID period Tick + * @param periodID current level tick counter */ void asyncCheckPattern(long periodID); diff --git a/src/main/java/com/gregtechceu/gtceu/api/machine/feature/multiblock/IMultiPart.java b/src/main/java/com/gregtechceu/gtceu/api/machine/feature/multiblock/IMultiPart.java index f08a17f5a37..3e6a79ac23f 100644 --- a/src/main/java/com/gregtechceu/gtceu/api/machine/feature/multiblock/IMultiPart.java +++ b/src/main/java/com/gregtechceu/gtceu/api/machine/feature/multiblock/IMultiPart.java @@ -34,6 +34,11 @@ default boolean canShared() { */ boolean hasController(BlockPos controllerPos); + /** + * Whether this part belongs to this exact controller ownership epoch. + */ + boolean hasController(IMultiController controller); + /** * Whether it belongs to a formed Multiblock. */ @@ -52,6 +57,18 @@ default boolean canShared() { */ void removedFromController(IMultiController controller); + /** + * Unlink a controller whose block entity is unloading without treating the structure as broken. + */ + void unloadedFromController(IMultiController controller); + + /** + * Called after a persisted controller binding is known to have ended definitively (structure invalidation, + * controller retirement, or replacement by a newer ownership epoch). Transient chunk unloads never invoke this + * hook. + */ + default void onControllerBindingRetired(BlockPos controllerPos, long instanceId) {} + /** * Called when it was added to a multiblock. */ diff --git a/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/MultiblockControllerMachine.java b/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/MultiblockControllerMachine.java index 5b61313df18..f5fc81ca152 100644 --- a/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/MultiblockControllerMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/MultiblockControllerMachine.java @@ -7,6 +7,7 @@ import com.gregtechceu.gtceu.api.machine.IMachineBlockEntity; import com.gregtechceu.gtceu.api.machine.MetaMachine; import com.gregtechceu.gtceu.api.machine.MultiblockMachineDefinition; +import com.gregtechceu.gtceu.api.machine.feature.IWorkLogicMachine; import com.gregtechceu.gtceu.api.machine.feature.multiblock.IMaintenanceMachine; import com.gregtechceu.gtceu.api.machine.feature.multiblock.IMultiController; import com.gregtechceu.gtceu.api.machine.feature.multiblock.IMultiPart; @@ -31,7 +32,7 @@ import net.minecraft.world.InteractionHand; import net.minecraft.world.InteractionResult; import net.minecraft.world.entity.player.Player; -import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.ChunkPos; import net.minecraft.world.phys.BlockHitResult; import lombok.Getter; @@ -42,7 +43,9 @@ import snownee.jade.api.config.IPluginConfig; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; +import java.util.IdentityHashMap; import java.util.List; import java.util.Optional; import java.util.Set; @@ -55,18 +58,26 @@ @MethodsReturnNonnullByDefault public class MultiblockControllerMachine extends MetaMachine implements IMultiController { + private static final String STRUCTURE_INSTANCE_ID = "gtceuStructureInstanceId"; + private static final String CONFIRMED_STRUCTURE_CHUNKS = "gtceuConfirmedStructureChunks"; + private static final long[] NO_CONFIRMED_STRUCTURE_CHUNKS = new long[0]; + private MultiblockState multiblockState; private final List parts = new ArrayList<>(); + private long structureInstanceId; + private long[] confirmedStructureChunks = NO_CONFIRMED_STRUCTURE_CHUNKS; @Getter @DescSynced @UpdateListener(methodName = "onPartsUpdated") private BlockPos[] partPositions = new BlockPos[0]; - @Getter @Persisted @DescSynced @RequireRerender protected boolean isFormed; @Getter + @DescSynced + protected boolean structureRevalidationPending = true; + @Getter @Setter @Persisted @DescSynced @@ -112,15 +123,124 @@ public MultiblockMachineDefinition getDefinition() { public void onLoad() { super.onLoad(); if (getLevel() instanceof ServerLevel serverLevel) { - MultiblockWorldSavedData.getOrCreate(serverLevel).addAsyncLogic(this); + var savedData = MultiblockWorldSavedData.getOrCreate(serverLevel); + if (isStructureFormedSnapshot()) { + // A persisted formation may be newer than SavedData. Repair/adopt its epoch before revalidation. + savedData.ensureControllerActive(this); + } else { + // An unformed controller must not publish or adopt an empty ACTIVE epoch. Tombstone the highest value + // known by its NBT or SavedData; a later successful formation alone may allocate the replacement. + savedData.retireControllerPosition(getPos(), structureInstanceId); + clearConfirmedStructureChunks(); + } + savedData.addAsyncLogic(this); + } + } + + @Override + public void saveCustomPersistedData(@NotNull CompoundTag tag, boolean forDrop) { + super.saveCustomPersistedData(tag, forDrop); + if (!forDrop && structureInstanceId > 0) { + tag.putLong(STRUCTURE_INSTANCE_ID, structureInstanceId); + } else { + // A dropped and placed controller cannot inherit the previous block's ownership epoch. + tag.remove(STRUCTURE_INSTANCE_ID); + } + if (!forDrop && isFormed && confirmedStructureChunks.length > 0) { + tag.putLongArray(CONFIRMED_STRUCTURE_CHUNKS, confirmedStructureChunks); + } else { + // A placed controller must validate its own new structure rather than inheriting the dropped block's + // geometry. Controllers without a confirmed structure have no geometry to persist. + tag.remove(CONFIRMED_STRUCTURE_CHUNKS); + } + } + + @Override + public void loadCustomPersistedData(@NotNull CompoundTag tag) { + super.loadCustomPersistedData(tag); + structureInstanceId = Math.max(0, tag.getLong(STRUCTURE_INSTANCE_ID)); + confirmedStructureChunks = normalizeStructureChunks(tag.getLongArray(CONFIRMED_STRUCTURE_CHUNKS)); + } + + @Override + public long getStructureInstanceId() { + return structureInstanceId; + } + + @Override + public void setStructureInstanceId(long instanceId) { + structureInstanceId = Math.max(0, instanceId); + } + + @Override + public void onStructureMappingInstalled(long[] mappedChunks) { + long[] normalized = normalizeStructureChunks(mappedChunks); + if (!Arrays.equals(confirmedStructureChunks, normalized)) { + confirmedStructureChunks = normalized; + markDirty(); + } + } + + @Override + public long[] getConfirmedStructureChunks() { + return Arrays.copyOf(confirmedStructureChunks, confirmedStructureChunks.length); + } + + @Override + public void onMachineDestroyed() { + // MetaMachineBlock invokes this synchronously only when the controller block is actually replaced. It is the + // authoritative retirement point; ordinary chunk unloads never pass through here. + if (getLevel() instanceof ServerLevel serverLevel) { + var savedData = MultiblockWorldSavedData.getOrCreate(serverLevel); + savedData.retireController(this); + savedData.removeAsyncLogic(this); + if (multiblockState != null) { + savedData.removeMapping(multiblockState); + } + } + if (isFormed || !parts.isEmpty()) { + onStructureInvalid(); + } else { + clearConfirmedStructureChunks(); } + super.onMachineDestroyed(); } @Override public void onUnload() { + if (getLevel() instanceof ServerLevel) { + // Do not rely on ChunkEvent ordering: every server-side controller unload must pause gameplay and addon + // topology before traits and runtime part links disappear. The persisted formed snapshot remains intact. + setStructureRevalidationPending(true); + } super.onUnload(); if (getLevel() instanceof ServerLevel serverLevel) { - MultiblockWorldSavedData.getOrCreate(serverLevel).removeAsyncLogic(this); + var savedData = MultiblockWorldSavedData.getOrCreate(serverLevel); + savedData.removeAsyncLogic(this); + if (multiblockState != null) { + savedData.removeMapping(multiblockState); + } + var controllerChunk = serverLevel.getChunkSource().getChunkNow(getPos().getX() >> 4, getPos().getZ() >> 4); + MetaMachine currentMachine = controllerChunk == null ? null : MetaMachine.getMachine(controllerChunk, + getPos()); + boolean controllerRemoved = controllerChunk != null && + (!controllerChunk.getBlockState(getPos()).is(getBlockState().getBlock()) || + currentMachine != null && currentMachine != this); + for (IMultiPart part : List.copyOf(parts)) { + MetaMachine partMachine = part.self(); + BlockPos partPos = partMachine.getPos(); + var partChunk = serverLevel.getChunkSource().getChunkNow(partPos.getX() >> 4, partPos.getZ() >> 4); + if (!partMachine.isInValid() && + partChunk != null && MetaMachine.getMachine(partChunk, partPos) == partMachine) { + if (controllerRemoved) { + part.removedFromController(this); + } else { + part.unloadedFromController(this); + } + } + } + parts.clear(); + updatePartPositions(); } } @@ -133,6 +253,39 @@ public MultiblockState getMultiblockState() { return multiblockState; } + @Override + public boolean isFormed() { + return isFormed && !structureRevalidationPending; + } + + @Override + public boolean isStructureFormedSnapshot() { + return isFormed; + } + + @Override + public boolean isStructureFlippedSnapshot() { + return isFlipped; + } + + @Override + public void setStructureRevalidationPending(boolean pending) { + if (structureRevalidationPending == pending) { + return; + } + structureRevalidationPending = pending; + if (this instanceof IWorkLogicMachine workMachine) { + workMachine.getWorkLogic().updateTickSubscription(); + } + onStructureRevalidationChanged(pending); + } + + /** + * Called after the transient validation state changes. Controllers with independent tick subscriptions should use + * this hook to pause them immediately and restore them after validation succeeds. + */ + protected void onStructureRevalidationChanged(boolean pending) {} + @SuppressWarnings("unused") protected void onPartsUpdated(BlockPos[] newValue, BlockPos[] oldValue) { parts.clear(); @@ -162,6 +315,14 @@ public List getParts() { return this.parts; } + /** + * Identity membership in the already attached runtime part list. Unlike {@link #getParts()}, this never attempts + * to resolve persisted part positions and therefore cannot request an unavailable structure chunk. + */ + public boolean hasRuntimePart(IMultiPart part) { + return parts.stream().anyMatch(candidate -> candidate == part); + } + @Override public Optional getParallelHatch() { return getTraitOptional(ParallelHatchTrait.class).map(ParallelHatchTrait::getParallelHatch); @@ -175,41 +336,108 @@ public Optional getParallelHatch() { @Override public void asyncCheckPattern(long periodID) { - if ((getMultiblockState().hasError() || !isFormed) && (getHolder().getOffset() + periodID) % 4 == 0 && - checkPatternWithTryLock()) { // per second - if (getLevel() instanceof ServerLevel serverLevel) { - serverLevel.getServer().execute(() -> { - patternLock.lock(); - if (checkPatternWithLock()) { // formed - setFlipped(getMultiblockState().isNeededFlip()); - onStructureFormed(); - var mwsd = MultiblockWorldSavedData.getOrCreate(serverLevel); - mwsd.addMapping(getMultiblockState()); - mwsd.removeAsyncLogic(this); - } - patternLock.unlock(); - }); + if (!(getLevel() instanceof ServerLevel serverLevel)) { + return; + } + var state = getMultiblockState(); + var savedData = MultiblockWorldSavedData.getOrCreate(serverLevel); + if (!structureRevalidationPending && !state.hasError() && isFormed && + savedData.mapping.get(getPos()) == state) { + savedData.removeAsyncLogic(this); + return; + } + boolean wasFormed = isFormed; + savedData.removeMapping(state); + if (wasFormed && (hasUnloadedConfirmedStructureChunk(serverLevel) || + state.hasPatternCache() && state.hasUnloadedCachedPosition())) { + // Keep the last confirmed pattern and part ownership intact. Trying alternate orientations or dynamic + // patterns while an old structure chunk is unavailable can produce a valid but different match, orphaning + // parts and addon-side topology that belonged to the previous structure. + state.setError(MultiblockState.UNLOAD_ERROR); + setStructureRevalidationPending(true); + return; + } + if (checkPatternWithLock()) { + setFlipped(state.isNeededFlip()); + state.setError(null); + setStructureRevalidationPending(false); + boolean mappingInstalled = false; + try { + onStructureFormed(); + if (isFormed) { + mappingInstalled = savedData.tryAddMapping(state); + } + if (mappingInstalled) { + savedData.removeAsyncLogic(this); + } + } finally { + if (!mappingInstalled) { + savedData.removeMapping(state); + setStructureRevalidationPending(true); + } + } + } else if (state.error == MultiblockState.UNLOAD_ERROR) { + // Some dynamic pattern factories probe dimensions by mutating the raw formed field. An incomplete chunk + // is not authoritative, so retain the snapshot captured before entering arbitrary pattern code. + isFormed = wasFormed; + state.restoreConfirmedPatternCache(); + } else { + // A persisted formed flag is provisional until the first post-load check. Only a definitive pattern + // failure invalidates it; an unloaded structure remains queued until all participating chunks return. + if (wasFormed) { + setFlipped(false); + onStructureInvalid(); } + // The controller stays in the retry queue so a later block placement can form it, but this check produced + // an authoritative result. Clear the transient pending state only after onStructureInvalid() has dropped + // the formed snapshot, keeping the operational facade unavailable without misclassifying a known-invalid + // structure as still waiting on unloaded chunks. + setStructureRevalidationPending(false); } } @Override public void onStructureFormed() { + if (getLevel() instanceof ServerLevel serverLevel) { + // This must precede every part, trait, and addon formation callback. If a definitive invalidation retired + // the previous epoch, all claims made by this formation must carry the newly allocated identity. + var savedData = MultiblockWorldSavedData.getOrCreate(serverLevel); + if (!isFormed) { + // A new formation must never adopt a stray ACTIVE record left by non-atomic save ordering. A + // formed-to-formed refresh skips this step and retains its current ACTIVE epoch. + savedData.retireControllerPosition(getPos(), structureInstanceId); + } + savedData.ensureControllerActive(this); + } + // Direct formation paths such as the pattern preview also represent a completed validation. + setStructureRevalidationPending(false); isFormed = true; MachineRenderState renderState = getRenderState(); if (renderState.hasProperty(GTMachineModelProperties.IS_FORMED)) { setRenderState(renderState.setValue(GTMachineModelProperties.IS_FORMED, true)); } - this.parts.clear(); + List previousParts = List.copyOf(this.parts); + List nextParts = new ArrayList<>(); Set set = getMultiblockState().getMatchContext().getOrCreate("parts", Collections::emptySet); for (IMultiPart part : set) { if (shouldAddPartToController(part)) { - this.parts.add(part); + nextParts.add(part); } } - this.parts.sort(getPartSorter()); + nextParts.sort(getPartSorter()); + Set nextPartIdentities = Collections.newSetFromMap(new IdentityHashMap<>()); + nextPartIdentities.addAll(nextParts); + this.parts.clear(); + this.parts.addAll(nextParts); updatePartPositions(); + for (IMultiPart previousPart : previousParts) { + if (!nextPartIdentities.contains(previousPart)) { + previousPart.removedFromController(this); + } + } + // Retained parts deliberately receive the existing refresh callback as well. Several part implementations + // rebuild transient handlers and subscriptions after every successful structure check. for (var part : parts) { part.addedToController(this); } @@ -218,7 +446,13 @@ public void onStructureFormed() { @Override public void onStructureInvalid() { + if (getLevel() instanceof ServerLevel serverLevel) { + // Structure invalidation is authoritative even though the controller block remains. Retire ownership + // before loaded parts are unlinked so unloaded parts can reconcile the same epoch without a timeout. + MultiblockWorldSavedData.getOrCreate(serverLevel).retireController(this); + } isFormed = false; + clearConfirmedStructureChunks(); MachineRenderState renderState = getRenderState(); if (renderState.hasProperty(GTMachineModelProperties.IS_FORMED)) { setRenderState(renderState.setValue(GTMachineModelProperties.IS_FORMED, false)); @@ -233,17 +467,21 @@ public void onStructureInvalid() { } /** - * mark multiblockState as unload error first. - * if it's actually cuz by block breaking. - * {@link #onStructureInvalid()} will be called from - * {@link MultiblockState#onBlockStateChanged(BlockPos, BlockState)} + * Mark the state as temporarily unloaded and queue one bounded validation. If the part was actually broken, the + * queued pattern check turns this into a definitive {@link #onStructureInvalid()} callback; a mere chunk unload + * keeps the persisted formed state intact until all chunks return. */ @Override public void onPartUnload() { parts.removeIf(part -> part.self().isInValid()); getMultiblockState().setError(MultiblockState.UNLOAD_ERROR); if (getLevel() instanceof ServerLevel serverLevel) { - MultiblockWorldSavedData.getOrCreate(serverLevel).addAsyncLogic(this); + var savedData = MultiblockWorldSavedData.getOrCreate(serverLevel); + savedData.removeMapping(getMultiblockState()); + var controllerChunk = serverLevel.getChunkSource().getChunkNow(getPos().getX() >> 4, getPos().getZ() >> 4); + if (!isInValid() && controllerChunk != null && MetaMachine.getMachine(controllerChunk, getPos()) == this) { + savedData.addAsyncLogic(this); + } } updatePartPositions(); getTraits(IMultiblockMachineTrait.class).forEach(IMultiblockMachineTrait::onPartUnload); @@ -253,9 +491,9 @@ public void onPartUnload() { public void onRotated(Direction oldFacing, Direction newFacing) { if (oldFacing != newFacing && getLevel() instanceof ServerLevel serverLevel) { // invalid structure - this.onStructureInvalid(); var mwsd = MultiblockWorldSavedData.getOrCreate(serverLevel); mwsd.removeMapping(getMultiblockState()); + this.onStructureInvalid(); mwsd.addAsyncLogic(this); } } @@ -314,4 +552,35 @@ public void setFrontFacing(Direction facing) { checkPattern(); } } + + private boolean hasUnloadedConfirmedStructureChunk(ServerLevel serverLevel) { + for (long chunk : confirmedStructureChunks) { + if (!serverLevel.hasChunk(ChunkPos.getX(chunk), ChunkPos.getZ(chunk))) { + return true; + } + } + return false; + } + + private void clearConfirmedStructureChunks() { + if (confirmedStructureChunks.length > 0) { + confirmedStructureChunks = NO_CONFIRMED_STRUCTURE_CHUNKS; + markDirty(); + } + } + + private static long[] normalizeStructureChunks(long[] chunks) { + if (chunks.length == 0) { + return NO_CONFIRMED_STRUCTURE_CHUNKS; + } + long[] normalized = Arrays.copyOf(chunks, chunks.length); + Arrays.sort(normalized); + int unique = 1; + for (int i = 1; i < normalized.length; i++) { + if (normalized[i] != normalized[unique - 1]) { + normalized[unique++] = normalized[i]; + } + } + return unique == normalized.length ? normalized : Arrays.copyOf(normalized, unique); + } } diff --git a/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/WorkableMultiblockMachine.java b/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/WorkableMultiblockMachine.java index f3eb6bc9c03..4695b949974 100644 --- a/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/WorkableMultiblockMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/WorkableMultiblockMachine.java @@ -114,15 +114,20 @@ public void clientTick() { } public void updateActiveBlocks(boolean active) { - if (activeBlocks != null) { - for (long pos : activeBlocks) { - var blockPos = BlockPos.of(pos); - var blockState = getLevel().getBlockState(blockPos); - if (blockState.hasProperty(GTBlockStateProperties.ACTIVE)) { - var newState = blockState.setValue(GTBlockStateProperties.ACTIVE, active); - if (newState != blockState) { - getLevel().setBlock(blockPos, newState, Block.UPDATE_CLIENTS | Block.UPDATE_KNOWN_SHAPE); - } + var level = getLevel(); + if (activeBlocks == null || level == null) { + return; + } + for (long pos : activeBlocks) { + var blockPos = BlockPos.of(pos); + if (!level.isLoaded(blockPos)) { + continue; + } + var blockState = level.getBlockState(blockPos); + if (blockState.hasProperty(GTBlockStateProperties.ACTIVE)) { + var newState = blockState.setValue(GTBlockStateProperties.ACTIVE, active); + if (newState != blockState) { + level.setBlock(blockPos, newState, Block.UPDATE_CLIENTS | Block.UPDATE_KNOWN_SHAPE); } } } @@ -186,6 +191,8 @@ public void setWorkingEnabled(boolean isWorkingAllowed) { @Override public boolean isWorkLogicAvailable() { - return isFormed && !getMultiblockState().hasError(); + // Pattern errors are server-owned and are not synchronized as a client-side MultiblockState. The pending + // facade is synchronized, so it remains safe to use for status rendering on either side. + return isStructureOperational() && (isRemote() || !getMultiblockState().hasError()); } } diff --git a/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/part/MultiblockPartMachine.java b/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/part/MultiblockPartMachine.java index c69b4cc8a5c..75d65c726af 100644 --- a/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/part/MultiblockPartMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/part/MultiblockPartMachine.java @@ -4,10 +4,12 @@ import com.gregtechceu.gtceu.api.capability.recipe.IRecipeHandler; import com.gregtechceu.gtceu.api.machine.IMachineBlockEntity; import com.gregtechceu.gtceu.api.machine.MetaMachine; +import com.gregtechceu.gtceu.api.machine.TickableSubscription; import com.gregtechceu.gtceu.api.machine.feature.multiblock.IDistinctPart; import com.gregtechceu.gtceu.api.machine.feature.multiblock.IMultiController; import com.gregtechceu.gtceu.api.machine.feature.multiblock.IMultiPart; import com.gregtechceu.gtceu.api.machine.property.GTMachineModelProperties; +import com.gregtechceu.gtceu.api.pattern.MultiblockWorldSavedData; import com.gregtechceu.gtceu.api.recipe.handler.RecipeHandlerList; import com.gregtechceu.gtceu.client.model.machine.MachineRenderState; @@ -17,6 +19,9 @@ import net.minecraft.MethodsReturnNonnullByDefault; import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.level.block.state.BlockState; @@ -28,7 +33,9 @@ import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.SortedSet; @@ -38,10 +45,19 @@ @MethodsReturnNonnullByDefault public class MultiblockPartMachine extends MetaMachine implements IMultiPart { + private static final String CONTROLLER_BINDINGS = "gtceuMultiblockControllerBindings"; + private static final String CONTROLLER_POS = "pos"; + private static final String CONTROLLER_INSTANCE_ID = "instanceId"; + @DescSynced @UpdateListener(methodName = "onControllersUpdated") protected final Set controllerPositions = new ObjectOpenHashSet<>(8); protected final SortedSet controllers = new ReferenceLinkedOpenHashSet<>(8); + private final Map controllerBindings = new HashMap<>(); + @Nullable + private TickableSubscription controllerBindingSubscription; + private boolean controllerBindingReconciliationArmed; + private boolean controllerBindingInitialReconciliationComplete; protected @Nullable RecipeHandlerList handlerList; @@ -55,12 +71,34 @@ public MultiblockPartMachine(IMachineBlockEntity holder) { @Override public boolean hasController(BlockPos controllerPos) { - return controllerPositions.contains(controllerPos); + return controllerPositions.contains(controllerPos) || controllerBindings.containsKey(controllerPos); + } + + @Override + public boolean hasController(IMultiController controller) { + if (controllers.contains(controller)) { + return true; + } + BlockPos controllerPos = controller.self().getPos(); + Long boundInstance = controllerBindings.get(controllerPos); + long controllerInstance = controller.getStructureInstanceId(); + if (boundInstance != null && controllerInstance > 0 && boundInstance == controllerInstance) { + return true; + } + if (boundInstance != null && getLevel() instanceof ServerLevel serverLevel) { + var savedData = MultiblockWorldSavedData.getOrCreate(serverLevel); + if (savedData.isControllerBindingRetired(controllerPos, boundInstance)) { + retireControllerBinding(controllerPos, boundInstance); + } + } + return false; } @Override public boolean isFormed() { - return !controllerPositions.isEmpty(); + // A persisted owner whose chunk is unavailable still owns an exclusive part. Treating the transient runtime + // set as authoritative would let another controller steal the part and inherit locked recipe contents. + return !controllerPositions.isEmpty() || !controllerBindings.isEmpty(); } // Not sure if necessary, but added to match the Controller class @@ -110,15 +148,28 @@ protected RecipeHandlerList getHandlerList() { return handlerList; } + @Override + public void onLoad() { + super.onLoad(); + if (getLevel() instanceof ServerLevel serverLevel) { + reconcileRetiredControllerBindings(MultiblockWorldSavedData.getOrCreate(serverLevel)); + updateControllerBindingSubscription(); + } + } + @Override public void onUnload() { + unsubscribe(controllerBindingSubscription); + controllerBindingSubscription = null; + controllerBindingReconciliationArmed = false; + controllerBindingInitialReconciliationComplete = false; super.onUnload(); if (getLevel() instanceof ServerLevel serverLevel) { - // Need to copy if > 1 so that we can call removedFromController safely without CME + // Need to copy if > 1 so that we can unlink safely without CME Set toIter = controllers.size() > 1 ? new ObjectOpenHashSet<>(controllers) : controllers; for (IMultiController controller : toIter) { if (serverLevel.isLoaded(controller.self().getPos())) { - removedFromController(controller); + unloadedFromController(controller); controller.onPartUnload(); } } @@ -127,6 +178,119 @@ public void onUnload() { controllers.clear(); } + @Override + public void saveCustomPersistedData(CompoundTag tag, boolean forDrop) { + super.saveCustomPersistedData(tag, forDrop); + if (forDrop || controllerBindings.isEmpty()) { + // A placed part must establish ownership through a newly validated structure, never through item NBT. + tag.remove(CONTROLLER_BINDINGS); + return; + } + ListTag bindings = new ListTag(); + for (var entry : controllerBindings.entrySet()) { + CompoundTag binding = new CompoundTag(); + binding.putLong(CONTROLLER_POS, entry.getKey().asLong()); + binding.putLong(CONTROLLER_INSTANCE_ID, entry.getValue()); + bindings.add(binding); + } + tag.put(CONTROLLER_BINDINGS, bindings); + } + + @Override + public void loadCustomPersistedData(CompoundTag tag) { + super.loadCustomPersistedData(tag); + controllerBindings.clear(); + ListTag bindings = tag.getList(CONTROLLER_BINDINGS, Tag.TAG_COMPOUND); + for (int i = 0; i < bindings.size(); i++) { + CompoundTag binding = bindings.getCompound(i); + long instanceId = binding.getLong(CONTROLLER_INSTANCE_ID); + if (instanceId > 0 && binding.contains(CONTROLLER_POS, Tag.TAG_LONG)) { + controllerBindings.put(BlockPos.of(binding.getLong(CONTROLLER_POS)), instanceId); + } + } + } + + /** + * Reconcile persisted owner pairs without requesting any owner chunk. Unknown registry state, an unloaded owner, + * and a controller still waiting for structure validation are deliberately inconclusive and retain ownership. + */ + public void reconcileControllerBindings(MultiblockWorldSavedData savedData) { + for (var entry : List.copyOf(controllerBindings.entrySet())) { + BlockPos controllerPos = entry.getKey(); + long instanceId = entry.getValue(); + var status = savedData.getControllerBindingStatus(controllerPos, instanceId); + if (status == MultiblockWorldSavedData.ControllerBindingStatus.RETIRED) { + retireControllerBinding(controllerPos, instanceId); + continue; + } + IMultiController controller = savedData.getLoadedControllerInstance(controllerPos, instanceId); + if (controller == null) { + // Chunk NBT and SavedData are not saved atomically. Even an UNKNOWN pair can be newer than the + // registry and then outlive a controller whose deletion chunk was saved first. Once this position is + // entity-ticking, the absence of the exact ownership epoch is positive removal/replacement evidence. A + // missing or not-yet-ticking chunk remains inconclusive and never gets requested by this check. + if (savedData.isControllerPositionLoadedNoChunkRequest(controllerPos)) { + retireControllerBinding(controllerPos, instanceId); + } + continue; + } + if (controller.isStructureRevalidationPending()) { + continue; + } + if (!controller.isStructureFormedSnapshot() || controller.isStructureOperational() && + !controller.self().hasRuntimePart(this)) { + retireControllerBinding(controllerPos, instanceId); + } + } + updateControllerBindingSubscription(); + } + + private void reconcileRetiredControllerBindings(MultiblockWorldSavedData savedData) { + for (var entry : List.copyOf(controllerBindings.entrySet())) { + if (savedData.getControllerBindingStatus(entry.getKey(), entry.getValue()) == + MultiblockWorldSavedData.ControllerBindingStatus.RETIRED) { + retireControllerBinding(entry.getKey(), entry.getValue()); + } + } + } + + private void tickControllerBindings() { + if (!controllerBindingReconciliationArmed) { + // Block entities in the same freshly loaded chunk may not all have completed onLoad yet. Never interpret + // that first transient tick as evidence that an ACTIVE controller is absent. + controllerBindingReconciliationArmed = true; + return; + } + if (!controllerBindingInitialReconciliationComplete) { + // The second tick is already past the same-chunk onLoad window. Reconcile immediately instead of waiting + // up to another 20 ticks with a stale source or capability still attached to a positively absent owner. + controllerBindingInitialReconciliationComplete = true; + if (getLevel() instanceof ServerLevel serverLevel) { + reconcileControllerBindings(MultiblockWorldSavedData.getOrCreate(serverLevel)); + } + return; + } + if (getOffsetTimer() % 20 == 0 && getLevel() instanceof ServerLevel serverLevel) { + reconcileControllerBindings(MultiblockWorldSavedData.getOrCreate(serverLevel)); + } + } + + private void updateControllerBindingSubscription() { + if (!isRemote() && !controllerBindings.isEmpty()) { + controllerBindingSubscription = subscribeServerTick(controllerBindingSubscription, + this::tickControllerBindings); + } else if (controllerBindingSubscription != null) { + controllerBindingSubscription.unsubscribe(); + controllerBindingSubscription = null; + controllerBindingReconciliationArmed = false; + controllerBindingInitialReconciliationComplete = false; + } + } + + public long getControllerBindingInstanceId(BlockPos controllerPos) { + return controllerBindings.getOrDefault(controllerPos, 0L); + } + ////////////////////////////////////// // *** Multiblock LifeCycle ***// ////////////////////////////////////// @@ -134,21 +298,76 @@ public void onUnload() { @MustBeInvokedByOverriders @Override public void removedFromController(IMultiController controller) { - controllerPositions.remove(controller.self().getPos()); + BlockPos controllerPos = controller.self().getPos(); + long instanceId = controller.getStructureInstanceId(); + Long boundInstance = controllerBindings.get(controllerPos); + unlinkRuntimeController(controller); + if (boundInstance != null && instanceId > 0 && boundInstance == instanceId) { + controllerBindings.remove(controllerPos); + markDirty(); + updateControllerBindingSubscription(); + if (!isFormed()) { + onControllerBindingRetired(controllerPos, instanceId); + } + } + } + + @Override + public void unloadedFromController(IMultiController controller) { + unlinkRuntimeController(controller); + } + + private void unlinkRuntimeController(IMultiController controller) { + BlockPos controllerPos = controller.self().getPos(); controllers.remove(controller); + if (controllers.stream().noneMatch(candidate -> candidate.self().getPos().equals(controllerPos))) { + controllerPositions.remove(controllerPos); + } - if (controllers.isEmpty()) { - MachineRenderState renderState = getRenderState(); - if (renderState.hasProperty(GTMachineModelProperties.IS_FORMED)) { - setRenderState(renderState.setValue(GTMachineModelProperties.IS_FORMED, false)); - } + updateFormedRenderState(); + } + + private void retireControllerBinding(BlockPos controllerPos, long instanceId) { + Long boundInstance = controllerBindings.get(controllerPos); + if (boundInstance == null || boundInstance != instanceId) { + return; + } + controllerBindings.remove(controllerPos); + controllers.removeIf(controller -> controller.self().getPos().equals(controllerPos) && + controller.getStructureInstanceId() == instanceId); + if (controllers.stream().noneMatch(controller -> controller.self().getPos().equals(controllerPos))) { + controllerPositions.remove(controllerPos); + } + markDirty(); + updateFormedRenderState(); + updateControllerBindingSubscription(); + onControllerBindingRetired(controllerPos, instanceId); + } + + private void updateFormedRenderState() { + MachineRenderState renderState = getRenderState(); + if (renderState.hasProperty(GTMachineModelProperties.IS_FORMED)) { + setRenderState(renderState.setValue(GTMachineModelProperties.IS_FORMED, isFormed())); } } @MustBeInvokedByOverriders @Override public void addedToController(IMultiController controller) { - controllerPositions.add(controller.self().getPos()); + BlockPos controllerPos = controller.self().getPos().immutable(); + long instanceId = controller.getStructureInstanceId(); + if (getLevel() instanceof ServerLevel serverLevel) { + instanceId = MultiblockWorldSavedData.getOrCreate(serverLevel).ensureControllerActive(controller); + } + Long previousInstance = controllerBindings.get(controllerPos); + if (previousInstance != null && instanceId > 0 && previousInstance != instanceId) { + retireControllerBinding(controllerPos, previousInstance); + } + if (instanceId > 0 && !Long.valueOf(instanceId).equals(controllerBindings.put(controllerPos, instanceId))) { + markDirty(); + } + updateControllerBindingSubscription(); + controllerPositions.add(controllerPos); controllers.add(controller); MachineRenderState renderState = getRenderState(); diff --git a/src/main/java/com/gregtechceu/gtceu/api/machine/trait/MultiblockComputationPortTrait.java b/src/main/java/com/gregtechceu/gtceu/api/machine/trait/MultiblockComputationPortTrait.java index 2c1e467d9a4..4f1711efd1a 100644 --- a/src/main/java/com/gregtechceu/gtceu/api/machine/trait/MultiblockComputationPortTrait.java +++ b/src/main/java/com/gregtechceu/gtceu/api/machine/trait/MultiblockComputationPortTrait.java @@ -34,6 +34,7 @@ public Optional getComputationConsumer() { private Optional findProducer() { if (!(machine instanceof IMultiPart part) || !part.isFormed()) return Optional.empty(); for (IMultiController controller : part.getControllers()) { + if (!controller.isStructureOperational()) continue; if (controller instanceof ComputationProducer producer) { return Optional.of(producer); } @@ -49,6 +50,7 @@ private Optional findProducer() { private Optional findConsumer() { if (!(machine instanceof IMultiPart part) || !part.isFormed()) return Optional.empty(); for (IMultiController controller : part.getControllers()) { + if (!controller.isStructureOperational()) continue; if (controller instanceof ComputationConsumer consumer) { return Optional.of(consumer); } diff --git a/src/main/java/com/gregtechceu/gtceu/api/machine/trait/RecipeLogic.java b/src/main/java/com/gregtechceu/gtceu/api/machine/trait/RecipeLogic.java index c0b036c1635..5d749baad81 100644 --- a/src/main/java/com/gregtechceu/gtceu/api/machine/trait/RecipeLogic.java +++ b/src/main/java/com/gregtechceu/gtceu/api/machine/trait/RecipeLogic.java @@ -141,6 +141,10 @@ public RecipeManager getRecipeManager() { @Override public void serverTick() { + if (!workMachine.isWorkLogicAvailable()) { + unsubscribeTick(); + return; + } if (!isSuspend()) { if (!isIdle() && lastRecipe != null) { if (progress < duration) { diff --git a/src/main/java/com/gregtechceu/gtceu/api/machine/trait/WorkLogic.java b/src/main/java/com/gregtechceu/gtceu/api/machine/trait/WorkLogic.java index c4f2e5f2156..7f76e9afae7 100644 --- a/src/main/java/com/gregtechceu/gtceu/api/machine/trait/WorkLogic.java +++ b/src/main/java/com/gregtechceu/gtceu/api/machine/trait/WorkLogic.java @@ -5,6 +5,7 @@ import com.gregtechceu.gtceu.api.machine.TickableSubscription; import com.gregtechceu.gtceu.api.machine.feature.IComputationProgressMachine; import com.gregtechceu.gtceu.api.machine.feature.IWorkLogicMachine; +import com.gregtechceu.gtceu.api.machine.feature.multiblock.IMultiController; import com.gregtechceu.gtceu.api.machine.property.GTMachineModelProperties; import com.gregtechceu.gtceu.api.machine.trait.feature.IMultiblockMachineTrait; @@ -103,6 +104,10 @@ public void unsubscribeTick() { } public void serverTick() { + if (!workMachine.isWorkLogicAvailable()) { + unsubscribeTick(); + return; + } if (!isSuspend()) { workMachine.serverRunningTick(); } @@ -132,7 +137,7 @@ public void setWaiting(@Nullable Component reason) { protected void onWaiting() {} public final boolean isWorking() { - return status == Status.WORKING; + return status == Status.WORKING && isStructureOperational(); } public final boolean isIdle() { @@ -158,7 +163,11 @@ public void setWorkingEnabled(boolean isWorkingAllowed) { } public boolean isActive() { - return isWorking() || isWaiting(); + return isStructureOperational() && (status == Status.WORKING || status == Status.WAITING); + } + + private boolean isStructureOperational() { + return !(workMachine instanceof IMultiController controller) || controller.isStructureOperational(); } @Override diff --git a/src/main/java/com/gregtechceu/gtceu/api/pattern/BlockPattern.java b/src/main/java/com/gregtechceu/gtceu/api/pattern/BlockPattern.java index 7f420ec996d..8528332f2b9 100644 --- a/src/main/java/com/gregtechceu/gtceu/api/pattern/BlockPattern.java +++ b/src/main/java/com/gregtechceu/gtceu/api/pattern/BlockPattern.java @@ -90,7 +90,11 @@ public BlockPattern(TraceabilityPredicate[][][] predicatesIn, RelativeDirection[ public boolean checkPatternAt(MultiblockState worldState, boolean savePredicate) { IMultiController controller = worldState.getController(); if (controller == null) { - worldState.setError(new PatternStringError("no controller found")); + if (!worldState.world.isLoaded(worldState.controllerPos)) { + worldState.setError(MultiblockState.UNLOAD_ERROR); + } else { + worldState.setError(new PatternStringError("no controller found")); + } return false; } BlockPos centerPos = controller.self().getPos(); @@ -99,13 +103,37 @@ public boolean checkPatternAt(MultiblockState worldState, boolean savePredicate) new Direction[] { Direction.SOUTH, Direction.NORTH, Direction.EAST, Direction.WEST }; Direction upwardsFacing = controller.self().getUpwardsFacing(); boolean allowsFlip = controller.self().allowFlip(); + boolean preserveOwnedFlip = allowsFlip && controller.isStructureFormedSnapshot(); + boolean preferredFlip = preserveOwnedFlip && controller.isStructureFlippedSnapshot(); + boolean sawUnloadedPosition = false; for (Direction direction : facings) { - boolean result = checkPatternAt(worldState, centerPos, direction, upwardsFacing, false, savePredicate); - if (result) { + if (checkPatternAt(worldState, centerPos, direction, upwardsFacing, preferredFlip, savePredicate)) { return true; - } else if (allowsFlip) { - return checkPatternAt(worldState, centerPos, direction, upwardsFacing, true, savePredicate); } + sawUnloadedPosition |= worldState.error == MultiblockState.UNLOAD_ERROR; + if (allowsFlip) { + // A formed controller owns one persisted mirrored orientation. If that exact candidate cannot be + // inspected, accepting the opposite orientation could orphan its unloaded parts and addon state. + if (preserveOwnedFlip && sawUnloadedPosition) { + worldState.setError(MultiblockState.UNLOAD_ERROR); + return false; + } + if (checkPatternAt(worldState, centerPos, direction, upwardsFacing, !preferredFlip, savePredicate)) { + return true; + } + sawUnloadedPosition |= worldState.error == MultiblockState.UNLOAD_ERROR; + if (sawUnloadedPosition) { + worldState.setError(MultiblockState.UNLOAD_ERROR); + } + // Preserve the existing flippable-controller search space. Expanding it to all four facings would turn + // one queued validation into as many as eight complete structure scans. + return false; + } + } + if (sawUnloadedPosition) { + // A later orientation mismatch must not turn an earlier unloaded-chunk result into a definitive structure + // failure. Any uninspectable candidate can still be the valid orientation once its chunks return. + worldState.setError(MultiblockState.UNLOAD_ERROR); } return false; } @@ -117,6 +145,11 @@ public int[] getDimensions() { public boolean checkPatternAt(MultiblockState worldState, BlockPos centerPos, Direction frontFacing, Direction upwardsFacing, boolean isFlipped, boolean savePredicate) { + IMultiController controller = worldState.getController(); + if (controller == null) { + worldState.setError(new PatternStringError("no controller found")); + return false; + } boolean findFirstAisle = false; int minZ = -centerOffset[4]; worldState.clean(); @@ -149,7 +182,7 @@ public boolean checkPatternAt(MultiblockState worldState, BlockPos centerPos, Di machineBlockEntity.getMetaMachine() instanceof IMultiPart part) { // add detected parts if (!predicate.isAny()) { if (part.isFormed() && !part.canShared() && - !part.hasController(worldState.controllerPos)) { // check part can be shared + !part.hasController(controller)) { // check part can be shared canPartShared = false; worldState.setError(new PatternStringError("multiblocked.pattern.error.share")); } else { diff --git a/src/main/java/com/gregtechceu/gtceu/api/pattern/MultiblockState.java b/src/main/java/com/gregtechceu/gtceu/api/pattern/MultiblockState.java index 5e3befeff39..6e815b1c00c 100644 --- a/src/main/java/com/gregtechceu/gtceu/api/pattern/MultiblockState.java +++ b/src/main/java/com/gregtechceu/gtceu/api/pattern/MultiblockState.java @@ -12,6 +12,7 @@ import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.Level; import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.state.BlockState; @@ -51,8 +52,13 @@ public class MultiblockState { public final BlockPos controllerPos; public IMultiController lastController; - @Getter private Long2ObjectOpenHashMap predicateMap; + private LongSet cachedChunks; + private boolean workingCacheExposed; + @Nullable + private Long2ObjectOpenHashMap confirmedPredicateMap; + @Nullable + private LongSet confirmedCachedChunks; public MultiblockState(Level world, BlockPos controllerPos) { this.world = world; @@ -67,6 +73,8 @@ public void clean() { this.layerCount = new Object2IntOpenHashMap<>(); predicateMap = new Long2ObjectOpenHashMap<>(); + cachedChunks = new LongOpenHashSet(); + workingCacheExposed = false; } public Map getPosPredicateMap() { @@ -77,6 +85,16 @@ public Map getPosPredicateMap() { Long2ObjectMap.Entry::getValue)); } + /** + * Return the mutable working predicate map retained by the historical API. If the working cache is currently the + * last confirmed cache, detach it first so an addon mutating the returned map cannot corrupt the confirmed shape. + */ + public Long2ObjectOpenHashMap getPredicateMap() { + detachWorkingPatternCache(); + workingCacheExposed = true; + return predicateMap; + } + public boolean update(BlockPos posIn, TraceabilityPredicate predicate) { this.pos = posIn; this.blockState = null; @@ -84,6 +102,10 @@ public boolean update(BlockPos posIn, TraceabilityPredicate predicate) { this.tileEntityInitialized = false; this.predicate = predicate; this.error = null; + if (world.isOutsideBuildHeight(posIn)) { + error = UNINIT_ERROR; + return false; + } if (!world.isLoaded(posIn)) { error = UNLOAD_ERROR; return false; @@ -109,7 +131,9 @@ public boolean hasError() { public void setError(PatternError error) { this.error = error; - if (error != null) { + if (error != null && error != UNLOAD_ERROR && error != UNINIT_ERROR) { + // These two errors are static identity sentinels. Binding either one to this state would keep its Level + // reachable from a static root after a server world is closed. error.setWorldState(this); } } @@ -152,65 +176,138 @@ public BlockState getOffsetState(Direction face) { } public LongSet getCache() { + // keySet() is mutable (remove/clear write through to predicateMap), so preserve that API while protecting the + // confirmed alias. Internal read-only mapping code uses getCacheForMapping() and avoids this copy-on-access. + detachWorkingPatternCache(); + workingCacheExposed = true; return predicateMap.keySet(); } + /** Read-only-by-convention internal view used while installing a mapping. */ + LongSet getCacheForMapping() { + return predicateMap == null ? LongSets.EMPTY_SET : predicateMap.keySet(); + } + + /** Whether this state has a predicate cache from a successfully mapped pattern scan. */ + public boolean hasPatternCache() { + return confirmedPredicateMap != null; + } + + /** + * A formed controller must not switch to another legal orientation or pattern while any position belonging to its + * last confirmed structure is unavailable. Positions outside the build height are definitive mismatches and are + * deliberately left for the normal pattern scan to diagnose. + */ + public boolean hasUnloadedCachedPosition() { + if (confirmedCachedChunks == null) { + return false; + } + for (long cachedChunk : confirmedCachedChunks) { + if (!world.hasChunk(ChunkPos.getX(cachedChunk), ChunkPos.getZ(cachedChunk))) { + return true; + } + } + return false; + } + public void addPosCache(BlockPos pos, TraceabilityPredicate predicate) { + detachWorkingPatternCache(); predicateMap.put(pos.asLong(), predicate); + // update() rejects out-of-height positions before they can enter a normal pattern cache. Keep the guard for + // compatibility callers which populate caches directly so those positions remain definitive mismatches. + if (!world.isOutsideBuildHeight(pos)) { + cachedChunks.add(ChunkPos.asLong(pos.getX() >> 4, pos.getZ() >> 4)); + } } public boolean isPosInCache(BlockPos pos) { - return predicateMap.keySet().contains(pos.asLong()); + Long2ObjectOpenHashMap cache = confirmedPredicateMap != null ? + confirmedPredicateMap : predicateMap; + return cache != null && cache.containsKey(pos.asLong()); + } + + /** + * Commit the cache built by the current scan as the last fully validated structure. Failed scans must never + * replace this snapshot, because it is the authoritative set used to protect unloaded old structure chunks. + */ + public void confirmPatternCache() { + if (predicateMap == null) { + predicateMap = new Long2ObjectOpenHashMap<>(); + } + if (cachedChunks == null) { + cachedChunks = new LongOpenHashSet(); + } + if (workingCacheExposed) { + // A caller can retain a mutable handle obtained before this commit. Detaching only when the getter is + // called after commit cannot protect against that handle. Copy once at this boundary, and rebuild chunk + // dependencies because direct map/key-set edits bypass addPosCache(). The normal scanner never exposes a + // mutable handle and therefore still commits without copying. + predicateMap = new Long2ObjectOpenHashMap<>(predicateMap); + cachedChunks = new LongOpenHashSet(); + for (long packedPos : predicateMap.keySet()) { + BlockPos cachedPos = BlockPos.of(packedPos); + if (!world.isOutsideBuildHeight(cachedPos)) { + cachedChunks.add(ChunkPos.asLong(cachedPos.getX() >> 4, cachedPos.getZ() >> 4)); + } + } + workingCacheExposed = false; + } + // A successful scan is immutable until the next clean() or a compatibility caller asks for a mutable view. + // Keep one resident copy in the common stable state; every write entry point detaches before mutation. + confirmedPredicateMap = predicateMap; + confirmedCachedChunks = cachedChunks; + } + + /** Restore the working cache after an incomplete scan so legacy callers also keep seeing the confirmed shape. */ + public void restoreConfirmedPatternCache() { + if (confirmedPredicateMap == null || confirmedCachedChunks == null) { + return; + } + predicateMap = confirmedPredicateMap; + cachedChunks = confirmedCachedChunks; + workingCacheExposed = false; + } + + private void detachWorkingPatternCache() { + if (predicateMap != null && predicateMap == confirmedPredicateMap) { + predicateMap = new Long2ObjectOpenHashMap<>(predicateMap); + } + if (cachedChunks != null && cachedChunks == confirmedCachedChunks) { + cachedChunks = new LongOpenHashSet(cachedChunks); + } } public void onBlockStateChanged(BlockPos pos, BlockState state) { if (world instanceof ServerLevel serverLevel) { + var mwsd = MultiblockWorldSavedData.getOrCreate(serverLevel); if (pos.equals(controllerPos)) { - if (lastController != null) { + if (lastController != null && lastController.getMultiblockState() == this) { if (!state.is(lastController.self().getBlockState().getBlock())) { - lastController.onStructureInvalid(); - var mwsd = MultiblockWorldSavedData.getOrCreate(serverLevel); mwsd.removeMapping(this); + lastController.onStructureInvalid(); } + } else { + mwsd.removeMapping(this); } } else { IMultiController controller = getController(); - if (controller == null && error == UNLOAD_ERROR) { - if (!serverLevel.isLoaded(controllerPos)) { + if (controller == null || controller.getMultiblockState() != this) { + mwsd.removeMapping(this); + if (controller == null && error == UNLOAD_ERROR && !serverLevel.isLoaded(controllerPos)) { GTCEu.LOGGER.info("Controller not loaded, pos {}", controllerPos); } + return; } - if (controller != null) { - if (controller.shouldIgnoreChange(pos, state)) { - return; - } - if (controller.isFormed()) { - var predicate = predicateMap.get(pos.asLong()); - if (predicate != null) { - if (!update(pos, predicate) || !predicate.test(this)) { - controller.self().setFlipped(false); - controller.onStructureInvalid(); - var mwsd = MultiblockWorldSavedData.getOrCreate(serverLevel); - mwsd.removeMapping(this); - mwsd.addAsyncLogic(controller); - return; - } - } - } - - if (controller.checkPatternWithLock()) { - // refresh structure - controller.self().setFlipped(this.neededFlip); - controller.onStructureFormed(); - } else { - // invalid structure - controller.self().setFlipped(false); - controller.onStructureInvalid(); - var mwsd = MultiblockWorldSavedData.getOrCreate(serverLevel); - mwsd.removeMapping(this); - mwsd.addAsyncLogic(controller); - } + if (controller.shouldIgnoreChange(pos, state)) { + return; } + // A block update may arrive in a burst (for example while a chunk restores block entities). Do not + // mutate this state's match context or run a full pattern scan in the notification callback. Removing + // the mapping coalesces subsequent updates, and the bounded server-tick queue checks the final world + // state once. The queue marks the controller as pending immediately, pausing work without turning a + // loaded block update into a synthetic chunk-unload pattern error or unlinking any parts. + mwsd.removeMapping(this); + mwsd.addAsyncLogic(controller); } } } diff --git a/src/main/java/com/gregtechceu/gtceu/api/pattern/MultiblockWorldSavedData.java b/src/main/java/com/gregtechceu/gtceu/api/pattern/MultiblockWorldSavedData.java index 76d2f83152e..99d1c8eadd0 100644 --- a/src/main/java/com/gregtechceu/gtceu/api/pattern/MultiblockWorldSavedData.java +++ b/src/main/java/com/gregtechceu/gtceu/api/pattern/MultiblockWorldSavedData.java @@ -1,28 +1,60 @@ package com.gregtechceu.gtceu.api.pattern; import com.gregtechceu.gtceu.GTCEu; +import com.gregtechceu.gtceu.api.machine.MetaMachine; import com.gregtechceu.gtceu.api.machine.feature.multiblock.IMultiController; import net.minecraft.core.BlockPos; import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.saveddata.SavedData; -import com.google.common.util.concurrent.ThreadFactoryBuilder; import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.*; -import java.util.concurrent.*; public class MultiblockWorldSavedData extends SavedData { + /** A pattern scan is unbounded internally, so never start more than one from a level tick. */ + private static final int MAX_STRUCTURE_CHECKS_PER_TICK = 1; + private static final int MAX_QUEUE_POLLS_PER_TICK = 64; + /** + * Soft wall-clock budget for starting structure scans. The first due scan is always allowed; this guard prevents + * future increases to {@link #MAX_STRUCTURE_CHECKS_PER_TICK} from accidentally turning one slow tick into a scan + * storm. + */ + private static final long STRUCTURE_CHECK_SOFT_BUDGET_NANOS = 2_000_000L; + private static final int INITIAL_INVALID_RETRY_DELAY_TICKS = 20; + private static final int MAX_INVALID_RETRY_DELAY_TICKS = 200; + private static final int INITIAL_UNLOAD_READINESS_DELAY_TICKS = 20; + private static final int MAX_UNLOAD_READINESS_DELAY_TICKS = 100; + private static final int CHUNK_LOAD_RECHECK_DELAY_TICKS = 2; + private static final String NEXT_CONTROLLER_INSTANCE_ID = "nextControllerInstanceId"; + private static final String CONTROLLER_INSTANCES = "controllerInstances"; + private static final String CONTROLLER_POS = "pos"; + private static final String CONTROLLER_INSTANCE_ID = "instanceId"; + private static final String CONTROLLER_ACTIVE = "active"; + public static MultiblockWorldSavedData getOrCreate(ServerLevel serverLevel) { - return serverLevel.getDataStorage() + MultiblockWorldSavedData data = serverLevel.getDataStorage() .computeIfAbsent(MultiblockWorldSavedData::new, MultiblockWorldSavedData::new, "gtceu_multiblock"); + data.serverLevel = serverLevel; + return data; } + public enum ControllerBindingStatus { + UNKNOWN, + ACTIVE, + RETIRED + } + + private record ControllerInstance(long instanceId, boolean active) {} + /** * Store all formed multiblocks' structure info */ @@ -31,6 +63,11 @@ public static MultiblockWorldSavedData getOrCreate(ServerLevel serverLevel) { * Chunk pos mapping. */ public final Map> chunkPosMapping; + private final Map> mappedChunks = new IdentityHashMap<>(); + private final Map controllerInstances = new HashMap<>(); + private long nextControllerInstanceId = 1; + @Nullable + private ServerLevel serverLevel; private MultiblockWorldSavedData() { this.mapping = new Object2ObjectOpenHashMap<>(); @@ -39,103 +76,715 @@ private MultiblockWorldSavedData() { private MultiblockWorldSavedData(CompoundTag tag) { this(); + nextControllerInstanceId = Math.max(1, tag.getLong(NEXT_CONTROLLER_INSTANCE_ID)); + ListTag instances = tag.getList(CONTROLLER_INSTANCES, Tag.TAG_COMPOUND); + for (int i = 0; i < instances.size(); i++) { + CompoundTag instanceTag = instances.getCompound(i); + long instanceId = instanceTag.getLong(CONTROLLER_INSTANCE_ID); + if (instanceId <= 0 || !instanceTag.contains(CONTROLLER_POS, Tag.TAG_LONG)) { + continue; + } + BlockPos pos = BlockPos.of(instanceTag.getLong(CONTROLLER_POS)); + controllerInstances.put(pos, new ControllerInstance(instanceId, + instanceTag.getBoolean(CONTROLLER_ACTIVE))); + if (instanceId < Long.MAX_VALUE) { + nextControllerInstanceId = Math.max(nextControllerInstanceId, instanceId + 1); + } + } } public Set getControllersInChunk(ChunkPos chunkPos) { return chunkPosMapping.getOrDefault(chunkPos, Collections.emptySet()); } - public void addMapping(MultiblockState state) { - this.mapping.put(state.controllerPos, state); - for (long blockPos : state.getCache()) { - chunkPosMapping.computeIfAbsent(new ChunkPos(BlockPos.of(blockPos)), c -> new HashSet<>()).add(state); + /** + * Install a mapping only when the controller at the persisted position still owns this exact state. + * + * @return whether the mapping was installed + */ + public boolean tryAddMapping(MultiblockState state) { + if (!(state.world instanceof ServerLevel serverLevel)) { + return false; + } + MetaMachine currentMachine = getLoadedMachine(serverLevel, state.controllerPos); + if (!(currentMachine instanceof IMultiController currentController) || currentMachine.isInValid() || + currentController.getMultiblockState() != state) { + removeMapping(state); + return false; } + // Custom pattern implementations are not required to call MultiblockState#getController themselves. + state.lastController = currentController; + MultiblockState previous = this.mapping.put(state.controllerPos, state); + if (previous != null && previous != state) { + removeFromChunkMappings(previous); + } + // A dynamic pattern may have changed its cache since the last successful check. + removeFromChunkMappings(state); + Set chunks = new HashSet<>(); + chunks.add(new ChunkPos(state.controllerPos)); + for (long blockPos : state.getCacheForMapping()) { + chunks.add(new ChunkPos(BlockPos.of(blockPos))); + } + mappedChunks.put(state, chunks); + for (ChunkPos chunk : chunks) { + chunkPosMapping.computeIfAbsent(chunk, c -> new HashSet<>()).add(state); + } + state.confirmPatternCache(); + currentController.onStructureMappingInstalled(chunks.stream() + .mapToLong(ChunkPos::toLong) + .sorted() + .toArray()); + return true; } public void removeMapping(MultiblockState state) { - this.mapping.remove(state.controllerPos); - for (Set set : chunkPosMapping.values()) { - set.remove(state); + if (this.mapping.get(state.controllerPos) == state) { + this.mapping.remove(state.controllerPos); + } + removeFromChunkMappings(state); + } + + private void removeFromChunkMappings(MultiblockState state) { + Set chunks = mappedChunks.remove(state); + if (chunks == null) { + return; + } + for (ChunkPos chunk : chunks) { + Set states = chunkPosMapping.get(chunk); + if (states == null) { + continue; + } + states.removeIf(candidate -> candidate == state); + if (states.isEmpty()) { + chunkPosMapping.remove(chunk); + } } } @NotNull @Override public CompoundTag save(@NotNull CompoundTag compound) { + compound.putLong(NEXT_CONTROLLER_INSTANCE_ID, nextControllerInstanceId); + ListTag instances = new ListTag(); + for (var entry : controllerInstances.entrySet()) { + CompoundTag instanceTag = new CompoundTag(); + instanceTag.putLong(CONTROLLER_POS, entry.getKey().asLong()); + instanceTag.putLong(CONTROLLER_INSTANCE_ID, entry.getValue().instanceId()); + instanceTag.putBoolean(CONTROLLER_ACTIVE, entry.getValue().active()); + instances.add(instanceTag); + } + compound.put(CONTROLLER_INSTANCES, instances); return compound; } - // ********************************* thread for searching ********************************* // - private final CopyOnWriteArrayList controllers = new CopyOnWriteArrayList<>(); - private ScheduledExecutorService executorService; - private final static ThreadFactory THREAD_FACTORY = new ThreadFactoryBuilder() - .setNameFormat("GTCEu Multiblock Async Thread-%d") - .setDaemon(true) - .build(); - private static final ThreadLocal IN_SERVICE = ThreadLocal.withInitial(() -> false); - private long periodID = Long.MIN_VALUE; + /** + * Ensure that a loaded base controller has a current, persistent structure-ownership epoch. A stale identity can + * never reactivate a tombstone or replace a newer epoch at the same position. + * + * @return the active identity + */ + public long ensureControllerActive(IMultiController controller) { + BlockPos pos = controller.self().getPos().immutable(); + ControllerInstance current = controllerInstances.get(pos); + long requested = controller.getStructureInstanceId(); + boolean adoptActiveRegistryIdentity = requested <= 0 && current != null && current.active(); + boolean allocate = !adoptActiveRegistryIdentity && (requested <= 0 || current != null && + (requested < current.instanceId() || requested == current.instanceId() && !current.active())); + long activeId = adoptActiveRegistryIdentity ? current.instanceId() : requested; + if (allocate) { + activeId = allocateControllerInstanceId(current); + } + if (allocate || adoptActiveRegistryIdentity) { + controller.setStructureInstanceId(activeId); + controller.self().markDirty(); + } else if (requested < Long.MAX_VALUE) { + nextControllerInstanceId = Math.max(nextControllerInstanceId, requested + 1); + } + + ControllerInstance active = new ControllerInstance(activeId, true); + if (!active.equals(current)) { + controllerInstances.put(pos, active); + setDirty(); + } + return activeId; + } + + private long allocateControllerInstanceId(@Nullable ControllerInstance current) { + long minimum = current == null || current.instanceId() == Long.MAX_VALUE ? 1 : current.instanceId() + 1; + long allocated = Math.max(nextControllerInstanceId, minimum); + if (allocated <= 0 || allocated == Long.MAX_VALUE) { + throw new IllegalStateException("Exhausted multiblock controller ownership epoch identities"); + } + nextControllerInstanceId = allocated + 1; + setDirty(); + return allocated; + } - public void createExecutorService() { - if (executorService != null && !executorService.isShutdown()) return; - executorService = Executors.newSingleThreadScheduledExecutor(THREAD_FACTORY); - executorService.scheduleAtFixedRate(this::searchingTask, 0, 250, TimeUnit.MILLISECONDS); // per 5 tick + /** Mark a controller ownership epoch as definitively ended. Chunk unloads must never call this method. */ + public void retireController(IMultiController controller) { + retireControllerPosition(controller.self().getPos(), controller.getStructureInstanceId()); } /** - * add a async logic runnable + * Mark a controller position definitively unowned, retaining the highest identity known by either SavedData or the + * controller chunk. This is used only when the loaded controller block is known to be unformed or destroyed; it + * closes both directions of non-atomic chunk/SavedData save ordering without reviving an older epoch. + */ + public void retireControllerPosition(BlockPos controllerPos, long knownInstanceId) { + BlockPos immutablePos = controllerPos.immutable(); + ControllerInstance current = controllerInstances.get(immutablePos); + long retiredInstanceId = Math.max(Math.max(0, knownInstanceId), current == null ? 0 : current.instanceId()); + if (retiredInstanceId <= 0) { + return; + } + ControllerInstance retired = new ControllerInstance(retiredInstanceId, false); + if (!retired.equals(current)) { + controllerInstances.put(immutablePos, retired); + if (retiredInstanceId < Long.MAX_VALUE) { + nextControllerInstanceId = Math.max(nextControllerInstanceId, retiredInstanceId + 1); + } + setDirty(); + } + } + + /** + * Mark one exact position/epoch pair retired without overwriting a newer epoch already registered at that position. + */ + public void retireControllerBinding(BlockPos controllerPos, long instanceId) { + if (instanceId <= 0) { + return; + } + BlockPos immutablePos = controllerPos.immutable(); + ControllerInstance current = controllerInstances.get(immutablePos); + if (current != null && current.instanceId() > instanceId) { + return; + } + ControllerInstance retired = new ControllerInstance(instanceId, false); + if (!retired.equals(current)) { + controllerInstances.put(immutablePos, retired); + if (instanceId < Long.MAX_VALUE) { + nextControllerInstanceId = Math.max(nextControllerInstanceId, instanceId + 1); + } + setDirty(); + } + } + + /** Pure persisted-registry lookup; this method never requests or loads a chunk. */ + public ControllerBindingStatus getControllerBindingStatus(BlockPos controllerPos, long instanceId) { + if (instanceId <= 0) { + return ControllerBindingStatus.UNKNOWN; + } + ControllerInstance current = controllerInstances.get(controllerPos); + if (current == null) { + return ControllerBindingStatus.UNKNOWN; + } + if (instanceId < current.instanceId()) { + return ControllerBindingStatus.RETIRED; + } + if (instanceId > current.instanceId()) { + // A chunk can be saved after this SavedData during a crash. A newer owner id is therefore not positive + // retirement evidence until that controller is loaded and advances the registry. + return ControllerBindingStatus.UNKNOWN; + } + return current.active() ? ControllerBindingStatus.ACTIVE : ControllerBindingStatus.RETIRED; + } + + public boolean isControllerBindingRetired(BlockPos controllerPos, long instanceId) { + return getControllerBindingStatus(controllerPos, instanceId) == ControllerBindingStatus.RETIRED; + } + + /** + * Exact loaded-controller check which uses {@code getChunkNow}; it never requests a missing owner chunk. + */ + public boolean isLoadedControllerInstance(BlockPos controllerPos, long instanceId) { + return getLoadedControllerInstance(controllerPos, instanceId) != null; + } + + /** + * Whether the controller position's chunk is already loaded and entity-ticking. Requiring both states prevents a + * FULL-but-not-yet-ticking chunk from looking like positive evidence that its controller block entity is absent. + * This method never requests the chunk. + */ + public boolean isControllerPositionLoadedNoChunkRequest(BlockPos controllerPos) { + return serverLevel != null && serverLevel.getChunkSource() + .getChunkNow(controllerPos.getX() >> 4, controllerPos.getZ() >> 4) != null && + serverLevel.shouldTickBlocksAt(controllerPos); + } + + /** + * Return the exact loaded controller ownership epoch, or {@code null}. The lookup uses {@code getChunkNow} and + * never + * requests a missing owner chunk; callers may apply controller-specific membership rules to the result. + */ + @Nullable + public IMultiController getLoadedControllerInstance(BlockPos controllerPos, long instanceId) { + if (instanceId <= 0 || serverLevel == null) { + return null; + } + MetaMachine machine = getLoadedMachine(serverLevel, controllerPos); + if (machine instanceof IMultiController controller && !machine.isInValid() && + controller.getStructureInstanceId() == instanceId) { + return controller; + } + return null; + } + + /** + * Whether a loaded, fully validated controller ownership epoch currently claims a cached structure position. This + * is + * an exact positive check; {@code false} may also mean that either chunk is merely unavailable. + */ + public boolean isLoadedValidatedControllerMember(BlockPos controllerPos, long instanceId, BlockPos memberPos) { + if (getControllerBindingStatus(controllerPos, instanceId) != ControllerBindingStatus.ACTIVE || + !isLoadedControllerInstance(controllerPos, instanceId)) { + return false; + } + MultiblockState state = mapping.get(controllerPos); + IMultiController controller = state == null ? null : state.lastController; + return controller != null && controller.isStructureOperational() && + controller.getStructureInstanceId() == instanceId && + controller.getMultiblockState() == state && state.hasPatternCache() && state.isPosInCache(memberPos); + } + + // ******************************** structure check queue ******************************** // + private final Map controllers = new IdentityHashMap<>(); + private final Set inFlightChecks = Collections.newSetFromMap(new IdentityHashMap<>()); + private final NavigableSet controllerQueue = new TreeSet<>(Comparator + .comparingLong((PendingStructureCheck pending) -> pending.nextCheckTick) + .thenComparingLong(pending -> pending.sequence)); + private final Map> checksWaitingForChunk = new HashMap<>(); + private final Set checksWaitingForAnyChunk = Collections + .newSetFromMap(new IdentityHashMap<>()); + private final Map> checksWaitingForBlockChange = new HashMap<>(); + private long periodID; + private long nextSequence; + + private static final class PendingStructureCheck { + + private final IMultiController controller; + private long nextCheckTick; + private long sequence; + private int invalidRetryCount; + private int unloadReadinessRetryCount; + private boolean queued; + private boolean parkedForChunkLoad; + private boolean wakeRequested; + private final Set awaitedChunks = new HashSet<>(); + private final Set blockWakeChunks = new HashSet<>(); + + private PendingStructureCheck(IMultiController controller, long nextCheckTick, long sequence) { + this.controller = controller; + this.nextCheckTick = nextCheckTick; + this.sequence = sequence; + this.queued = true; + } + } + + /** + * Queue a controller for a structure check. All accesses are made from the server thread and the identity set makes + * repeated unload notifications idempotent. * * @param controller controller */ public void addAsyncLogic(IMultiController controller) { - controllers.add(controller); - createExecutorService(); + controller.setStructureRevalidationPending(true); + PendingStructureCheck pending = controllers.get(controller); + if (pending == null) { + pending = new PendingStructureCheck(controller, periodID + 1, nextSequence++); + controllers.put(controller, pending); + controllerQueue.add(pending); + } else + if (!pending.parkedForChunkLoad || controller.getMultiblockState().error != MultiblockState.UNLOAD_ERROR) { + // A mapped block update or a manual controller notification should not wait for an old exponential + // retry. + // Repeated unload callbacks, however, must not wake a controller before its missing chunk returns. + wakeStructureCheck(pending, 1, true); + } } /** - * remove async controller + * Remove a queued controller. * * @param controller controller */ public void removeAsyncLogic(IMultiController controller) { - if (controllers.contains(controller)) { - controllers.remove(controller); - if (controllers.isEmpty()) { - releaseExecutorService(); - } + PendingStructureCheck removed = controllers.remove(controller); + if (removed != null) { + unregisterWakeIndexes(removed); + removed.queued = false; + removed.parkedForChunkLoad = false; + } + if (controllers.isEmpty()) { + controllerQueue.clear(); + checksWaitingForChunk.clear(); + checksWaitingForAnyChunk.clear(); + checksWaitingForBlockChange.clear(); + } else if (removed != null && controllerQueue.size() > Math.max(64, controllers.size() * 2)) { + // Identity tokens make cancellation O(1), but cancelled tokens remain in the ordered queue until polled. + // Rebuild occasionally so a world with at least one permanently unloaded controller cannot accumulate + // stale tokens forever as other structures repeatedly form and unload. A token currently executing is + // still present in controllers, but must not be copied back into the queue: tick() alone owns its eventual + // requeue. + controllerQueue.clear(); + controllers.values().stream() + .filter(pending -> !inFlightChecks.contains(pending)) + .forEach(pending -> { + pending.queued = true; + controllerQueue.add(pending); + }); } } - private void searchingTask() { - try { - if (!GTCEu.canGetServerLevel()) return; - IN_SERVICE.set(true); - for (var controller : controllers) { - try { - controller.asyncCheckPattern(periodID); - } catch (Throwable e) { - GTCEu.LOGGER.error("Error while assembling multiblock {}: {}", controller, e.getMessage()); + /** + * Process pending checks from the level tick. Pattern matching reads mutable world and block-entity state, so it + * must never run on a wall-clock executor. At most one full scan starts per tick. Definitive failures use bounded + * exponential backoff, while unloaded structures are parked until a relevant chunk-load event rather than scanned + * every 20 ticks. Both pattern checks and discarded stale queue tokens are bounded per tick. + */ + public void tick() { + periodID++; + long tickStartNanos = System.nanoTime(); + int polls = 0; + int checks = 0; + while (checks < MAX_STRUCTURE_CHECKS_PER_TICK && polls < MAX_QUEUE_POLLS_PER_TICK && + !controllerQueue.isEmpty() && controllerQueue.first().nextCheckTick <= periodID && + (checks == 0 || System.nanoTime() - tickStartNanos < STRUCTURE_CHECK_SOFT_BUDGET_NANOS)) { + PendingStructureCheck pending = controllerQueue.pollFirst(); + pending.queued = false; + polls++; + IMultiController controller = pending.controller; + if (controllers.get(controller) != pending) { + continue; + } + var machine = controller.self(); + if (machine.isInValid() || !(machine.getLevel() instanceof ServerLevel serverLevel) || + getLoadedMachine(serverLevel, machine.getPos()) != machine) { + controllers.remove(controller); + unregisterWakeIndexes(pending); + removeMapping(controller.getMultiblockState()); + continue; + } + if (pending.parkedForChunkLoad) { + if (!areAwaitedChunksReady(pending)) { + scheduleParkedReadiness(pending, nextUnloadReadinessDelay(pending)); + continue; + } + unregisterChunkWake(pending); + pending.parkedForChunkLoad = false; + } + unregisterBlockWake(pending); + checks++; + inFlightChecks.add(pending); + try { + controller.asyncCheckPattern(periodID); + } catch (Throwable e) { + GTCEu.LOGGER.error("Error while assembling multiblock {}", controller, e); + } finally { + inFlightChecks.remove(pending); + } + // A remove-then-add during the callback installs a new identity token. Never append this stale token too. + if (controllers.get(controller) == pending) { + if (pending.wakeRequested) { + pending.wakeRequested = false; + wakeStructureCheck(pending, 1, true); + } else if (controller.getMultiblockState().error == MultiblockState.UNLOAD_ERROR) { + parkUntilChunkLoad(pending, null); + } else { + scheduleInvalidRetry(pending); } } - } catch (Throwable e) { - GTCEu.LOGGER.error("Error while assembling multiblocks: {}", e.getMessage()); - } finally { - IN_SERVICE.set(false); } - periodID++; } - public static boolean isThreadService() { - return IN_SERVICE.get() && GTCEu.canGetServerLevel(); + private void scheduleInvalidRetry(PendingStructureCheck pending) { + pending.unloadReadinessRetryCount = 0; + int shift = Math.min(pending.invalidRetryCount, 30); + long exponentialDelay = (long) INITIAL_INVALID_RETRY_DELAY_TICKS << shift; + int delay = (int) Math.min(MAX_INVALID_RETRY_DELAY_TICKS, exponentialDelay); + pending.invalidRetryCount++; + registerBlockWake(pending); + enqueue(pending, periodID + delay); } - public void releaseExecutorService() { - if (executorService != null) { - executorService.shutdownNow(); + private void wakeStructureCheck(PendingStructureCheck pending, int delay, boolean resetBackoff) { + if (controllers.get(pending.controller) != pending) { + return; + } + if (resetBackoff) { + pending.invalidRetryCount = 0; + pending.unloadReadinessRetryCount = 0; + } + if (inFlightChecks.contains(pending)) { + pending.wakeRequested = true; + return; + } + unregisterWakeIndexes(pending); + if (pending.queued) { + controllerQueue.remove(pending); + pending.queued = false; + } + pending.parkedForChunkLoad = false; + enqueue(pending, periodID + Math.max(1, delay)); + } + + private void enqueue(PendingStructureCheck pending, long nextCheckTick) { + if (pending.queued || pending.parkedForChunkLoad || controllers.get(pending.controller) != pending) { + return; + } + pending.nextCheckTick = nextCheckTick; + pending.sequence = nextSequence++; + pending.queued = true; + controllerQueue.add(pending); + } + + private void parkUntilChunkLoad(PendingStructureCheck pending, @Nullable ChunkPos knownMissingChunk) { + if (controllers.get(pending.controller) != pending) { + return; + } + unregisterWakeIndexes(pending); + if (pending.queued) { + controllerQueue.remove(pending); + pending.queued = false; + } + pending.parkedForChunkLoad = true; + pending.invalidRetryCount = 0; + + if (knownMissingChunk != null) { + pending.awaitedChunks.add(knownMissingChunk.toLong()); + } + MultiblockState state = pending.controller.getMultiblockState(); + long[] confirmedChunks = pending.controller.getConfirmedStructureChunks(); + for (long chunk : confirmedChunks) { + pending.awaitedChunks.add(chunk); + } + if (confirmedChunks.length == 0) { + for (long blockPos : state.getCacheForMapping()) { + BlockPos pos = BlockPos.of(blockPos); + pending.awaitedChunks.add(ChunkPos.asLong(pos.getX() >> 4, pos.getZ() >> 4)); + } + } + if (pending.awaitedChunks.isEmpty()) { + // Old persisted formed states have only controller-owned chunk metadata until their first successful scan. + // Wake those on the next chunk-load event; the one-scan-per-tick cap still prevents an unrelated load + // storm. + checksWaitingForAnyChunk.add(pending); + } else { + for (long chunk : pending.awaitedChunks) { + checksWaitingForChunk.computeIfAbsent(chunk, + ignored -> Collections.newSetFromMap(new IdentityHashMap<>())).add(pending); + } + } + scheduleParkedReadiness(pending, nextUnloadReadinessDelay(pending)); + } + + private void scheduleParkedReadiness(PendingStructureCheck pending, int delay) { + if (controllers.get(pending.controller) != pending || !pending.parkedForChunkLoad) { + return; + } + if (pending.queued) { + controllerQueue.remove(pending); + pending.queued = false; + } + pending.nextCheckTick = periodID + Math.max(1, delay); + pending.sequence = nextSequence++; + pending.queued = true; + controllerQueue.add(pending); + } + + private static int nextUnloadReadinessDelay(PendingStructureCheck pending) { + int shift = Math.min(pending.unloadReadinessRetryCount, 30); + long exponentialDelay = (long) INITIAL_UNLOAD_READINESS_DELAY_TICKS << shift; + pending.unloadReadinessRetryCount++; + return (int) Math.min(MAX_UNLOAD_READINESS_DELAY_TICKS, exponentialDelay); + } + + private boolean areAwaitedChunksReady(PendingStructureCheck pending) { + if (pending.awaitedChunks.isEmpty()) { + // Before the first confirmed geometry is available, use the bounded readiness timer for UNLOAD causes + // which have no corresponding Forge chunk event (for example a late entity). + return true; + } + for (long chunk : pending.awaitedChunks) { + if (!isChunkReadyForStructureCheck(chunk)) { + return false; + } + } + return true; + } + + private boolean isChunkReadyForStructureCheck(long chunk) { + if (serverLevel == null) { + return false; + } + int chunkX = ChunkPos.getX(chunk); + int chunkZ = ChunkPos.getZ(chunk); + // getChunkNow is a no-request FULL-chunk probe. A pattern-specific resource (for example a late contraption + // entity) can still report UNLOAD after this becomes true; that path parks again with the bounded timer below. + return serverLevel.getChunkSource().getChunkNow(chunkX, chunkZ) != null; + } + + private void registerBlockWake(PendingStructureCheck pending) { + MultiblockState state = pending.controller.getMultiblockState(); + pending.blockWakeChunks.add(new ChunkPos(state.controllerPos).toLong()); + for (long blockPos : state.getCacheForMapping()) { + BlockPos pos = BlockPos.of(blockPos); + pending.blockWakeChunks.add(ChunkPos.asLong(pos.getX() >> 4, pos.getZ() >> 4)); + } + for (long chunk : pending.blockWakeChunks) { + checksWaitingForBlockChange.computeIfAbsent(chunk, + ignored -> Collections.newSetFromMap(new IdentityHashMap<>())).add(pending); + } + } + + private void unregisterWakeIndexes(PendingStructureCheck pending) { + unregisterChunkWake(pending); + unregisterBlockWake(pending); + } + + private void unregisterChunkWake(PendingStructureCheck pending) { + checksWaitingForAnyChunk.remove(pending); + for (long chunk : pending.awaitedChunks) { + removeIndexedCheck(checksWaitingForChunk, chunk, pending); + } + pending.awaitedChunks.clear(); + } + + private void unregisterBlockWake(PendingStructureCheck pending) { + for (long chunk : pending.blockWakeChunks) { + removeIndexedCheck(checksWaitingForBlockChange, chunk, pending); } - executorService = null; + pending.blockWakeChunks.clear(); + } + + private static void removeIndexedCheck(Map> index, long key, + PendingStructureCheck pending) { + Set checks = index.get(key); + if (checks == null) { + return; + } + checks.remove(pending); + if (checks.isEmpty()) { + index.remove(key); + } + } + + /** + * Pause every mapped structure touching a chunk that is about to unload. The reverse index is copied before any + * mapping is removed, and this path intentionally does not inspect blocks or block entities in the unloading + * chunk. + */ + public void onChunkUnload(ChunkPos chunkPos) { + for (MultiblockState state : List.copyOf(getControllersInChunk(chunkPos))) { + IMultiController controller = state.lastController; + removeMapping(state); + if (controller == null || controller.getMultiblockState() != state || controller.self().isInValid()) { + continue; + } + state.setError(MultiblockState.UNLOAD_ERROR); + controller.setStructureRevalidationPending(true); + if (new ChunkPos(state.controllerPos).equals(chunkPos)) { + // The controller's own unload lifecycle removes it from the queue; do not create a doomed token. + continue; + } + addAsyncLogic(controller); + PendingStructureCheck pending = controllers.get(controller); + if (pending != null) { + // A scan cannot produce an authoritative result while this known structure chunk is absent. Park it + // immediately instead of spending one of the global scan slots just to rediscover the unload. + parkUntilChunkLoad(pending, chunkPos); + } + } + } + + /** + * Wake parked structures after one of their unavailable chunks loads. Structures without known chunk dependencies + * are conservatively woken by the next chunk load. + */ + public void onChunkLoad(ChunkPos chunkPos) { + long loadedChunk = chunkPos.toLong(); + Set candidates = Collections.newSetFromMap(new IdentityHashMap<>()); + Set exact = checksWaitingForChunk.get(loadedChunk); + if (exact != null) { + candidates.addAll(exact); + } + candidates.addAll(checksWaitingForAnyChunk); + for (PendingStructureCheck pending : candidates) { + if (controllers.get(pending.controller) != pending || !pending.parkedForChunkLoad) { + unregisterChunkWake(pending); + continue; + } + pending.unloadReadinessRetryCount = 0; + if (areAwaitedChunksReady(pending)) { + wakeStructureCheck(pending, CHUNK_LOAD_RECHECK_DELAY_TICKS, true); + } else { + // ChunkEvent.Load can precede all of a structure's other chunks. Keep this as a cheap readiness probe; + // do not run a full pattern scan until every confirmed structure chunk is resident. + scheduleParkedReadiness(pending, CHUNK_LOAD_RECHECK_DELAY_TICKS); + } + } + } + + /** + * Expedite invalid-pattern retries whose previous scan touched the changed chunk. This preserves a low idle retry + * rate without making a player wait for the exponential timer after placing or breaking a structure block. + */ + public void onBlockChanged(BlockPos pos) { + Set checks = checksWaitingForBlockChange.get(new ChunkPos(pos).toLong()); + if (checks == null || checks.isEmpty()) { + return; + } + for (PendingStructureCheck pending : List.copyOf(checks)) { + wakeStructureCheck(pending, 1, true); + } + } + + public void releaseExecutorService() { + controllers.clear(); + controllerQueue.clear(); + inFlightChecks.clear(); + checksWaitingForChunk.clear(); + checksWaitingForAnyChunk.clear(); + checksWaitingForBlockChange.clear(); } public long getPeriodID() { return periodID; } + + boolean isStructureCheckPending(IMultiController controller) { + return controllers.containsKey(controller); + } + + int getPendingStructureCheckCount() { + return controllers.size(); + } + + int getQueuedStructureCheckTokenCount() { + return controllerQueue.size(); + } + + int getQueuedStructureCheckTokenCount(IMultiController controller) { + return (int) controllerQueue.stream().filter(pending -> pending.controller == controller).count(); + } + + boolean isStructureCheckParked(IMultiController controller) { + PendingStructureCheck pending = controllers.get(controller); + return pending != null && pending.parkedForChunkLoad; + } + + int getStructureCheckInvalidRetryCount(IMultiController controller) { + PendingStructureCheck pending = controllers.get(controller); + return pending == null ? 0 : pending.invalidRetryCount; + } + + long getStructureCheckDelay(IMultiController controller) { + PendingStructureCheck pending = controllers.get(controller); + return pending == null || !pending.queued ? -1 : Math.max(0, pending.nextCheckTick - periodID); + } + + @Nullable + private static MetaMachine getLoadedMachine(ServerLevel level, BlockPos pos) { + var chunk = level.getChunkSource().getChunkNow(pos.getX() >> 4, pos.getZ() >> 4); + return chunk == null ? null : MetaMachine.getMachine(chunk, pos); + } } diff --git a/src/main/java/com/gregtechceu/gtceu/common/computation/ComputationNetworkManager.java b/src/main/java/com/gregtechceu/gtceu/common/computation/ComputationNetworkManager.java index f613a521328..888a808d7f7 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/computation/ComputationNetworkManager.java +++ b/src/main/java/com/gregtechceu/gtceu/common/computation/ComputationNetworkManager.java @@ -33,6 +33,11 @@ public static ComputationNetworkManager get(ServerLevel level) { return MANAGERS.computeIfAbsent(level, ComputationNetworkManager::new); } + /** Drop the strong level/port graph when a dimension closes. */ + public static void release(ServerLevel level) { + MANAGERS.remove(level); + } + private final ServerLevel level; private final Set ports = new HashSet<>(); private final List networks = new ArrayList<>(); diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/CentralMonitorMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/CentralMonitorMachine.java index 980ab5f9fab..9696b768088 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/CentralMonitorMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/CentralMonitorMachine.java @@ -65,6 +65,12 @@ public class CentralMonitorMachine extends WorkableElectricMultiblockMachine implements IMonitorComponent, IDataInfoProvider, IMachineLife, ICentralMonitor { + protected enum DimensionScanResult { + VALID, + INVALID, + UNLOADED + } + @Persisted @DescSynced @Getter @@ -78,6 +84,8 @@ public class CentralMonitorMachine extends WorkableElectricMultiblockMachine private final List selectedTargets = new ArrayList<>(); private MultiblockState patternFindingState; + @Nullable + private DimensionScanResult lastDimensionScanResult; private static TraceabilityPredicate MULTI_PREDICATE = null; @@ -190,6 +198,7 @@ protected MultiblockState getPatternFindingState() { public boolean isValidMonitorBlock(Level level, BlockPos pos) { if (level.isOutsideBuildHeight(pos)) return false; + if (!level.isLoaded(pos)) return false; MultiblockState state = getPatternFindingState(); if (!state.update(pos, getMultiPredicate())) { @@ -202,8 +211,12 @@ public boolean isValidMonitorBlock(Level level, BlockPos pos) { } public void updateStructureDimensions() { + lastDimensionScanResult = scanStructureDimensions(); + } + + protected DimensionScanResult scanStructureDimensions() { Level level = getLevel(); - if (level == null) return; + if (level == null) return DimensionScanResult.INVALID; Direction front = getFrontFacing(); Direction spin = getUpwardsFacing(); @@ -216,43 +229,98 @@ public void updateStructureDimensions() { BlockPos.MutableBlockPos posRight = getPos().mutable().move(right); BlockPos.MutableBlockPos posUp = getPos().mutable().move(up); BlockPos.MutableBlockPos posDown = getPos().mutable().move(down); - this.leftDist = 0; - this.rightDist = 0; - this.upDist = 0; - this.downDist = 0; - while (isValidMonitorBlock(level, posLeft)) { + int leftDist = 0; + int rightDist = 0; + int upDist = 0; + int downDist = 0; + + DimensionScanResult result; + while ((result = scanMonitorBlock(level, posLeft)) == DimensionScanResult.VALID) { posLeft.move(left); leftDist++; } - while (isValidMonitorBlock(level, posRight)) { + if (result == DimensionScanResult.UNLOADED) return result; + + while ((result = scanMonitorBlock(level, posRight)) == DimensionScanResult.VALID) { posRight.move(right); rightDist++; } - while (isValidMonitorBlockRow(level, posUp, leftDist, rightDist, left, right)) { + if (result == DimensionScanResult.UNLOADED) return result; + + while ((result = scanMonitorBlockRow(level, posUp, leftDist, rightDist, left, right)) == + DimensionScanResult.VALID) { posUp.move(up); upDist++; } - while (isValidMonitorBlockRow(level, posDown, leftDist, rightDist, left, right)) { + if (result == DimensionScanResult.UNLOADED) return result; + + while ((result = scanMonitorBlockRow(level, posDown, leftDist, rightDist, left, right)) == + DimensionScanResult.VALID) { posDown.move(down); downDist++; } + if (result == DimensionScanResult.UNLOADED) return result; + + if (leftDist + rightDist < 1 || upDist + downDist < 1) { + return DimensionScanResult.INVALID; + } + + this.leftDist = leftDist; + this.rightDist = rightDist; + this.upDist = upDist; + this.downDist = downDist; + return DimensionScanResult.VALID; } - private boolean isValidMonitorBlockRow(Level level, BlockPos pos, int leftDist, int rightDist, Direction left, - Direction right) { + private DimensionScanResult scanMonitorBlock(Level level, BlockPos pos) { + if (level.isOutsideBuildHeight(pos)) return DimensionScanResult.INVALID; + if (!level.isLoaded(pos)) return DimensionScanResult.UNLOADED; + return isValidMonitorBlock(level, pos) ? DimensionScanResult.VALID : DimensionScanResult.INVALID; + } + + private DimensionScanResult scanMonitorBlockRow(Level level, BlockPos pos, int leftDist, int rightDist, + Direction left, Direction right) { BlockPos.MutableBlockPos mutable = pos.mutable(); mutable.move(left, leftDist); - for (int i = 0; i < leftDist + rightDist; i++) { - if (!isValidMonitorBlock(level, mutable)) return false; + for (int i = 0; i <= leftDist + rightDist; i++) { + DimensionScanResult result = scanMonitorBlock(level, mutable); + if (result != DimensionScanResult.VALID) return result; mutable.move(right); } - return isValidMonitorBlock(level, mutable); + return DimensionScanResult.VALID; + } + + private DimensionScanResult updateStructureDimensionsForValidation() { + lastDimensionScanResult = null; + updateStructureDimensions(); + // Keep subclasses overriding the long-standing public update hook functional. If they do not call super, + // their dimensions are still validated by the generated BlockPattern, just without the new early tri-state. + return lastDimensionScanResult == null ? DimensionScanResult.VALID : lastDimensionScanResult; + } + + @Override + public boolean checkPattern() { + DimensionScanResult result = updateStructureDimensionsForValidation(); + if (result != DimensionScanResult.VALID) { + getMultiblockState().setError(result == DimensionScanResult.UNLOADED ? + MultiblockState.UNLOAD_ERROR : MultiblockState.UNINIT_ERROR); + return false; + } + return buildPatternFromCurrentDimensions().checkPatternAt(getMultiblockState(), false); } @Override public BlockPattern getPattern() { updateStructureDimensions(); + return buildPatternFromCurrentDimensions(); + } + + protected BlockPattern buildPatternFromCurrentDimensions() { + int leftDist = this.leftDist; + int rightDist = this.rightDist; + int upDist = this.upDist; + int downDist = this.downDist; if (leftDist + rightDist < 1 || upDist + downDist < 1) { leftDist = 3; rightDist = 0; diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/CleanroomMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/CleanroomMachine.java index 2fdaa126091..db86b05e9e8 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/CleanroomMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/CleanroomMachine.java @@ -23,6 +23,7 @@ import com.gregtechceu.gtceu.api.misc.EnergyContainerList; import com.gregtechceu.gtceu.api.pattern.BlockPattern; import com.gregtechceu.gtceu.api.pattern.FactoryBlockPattern; +import com.gregtechceu.gtceu.api.pattern.MultiblockState; import com.gregtechceu.gtceu.api.pattern.Predicates; import com.gregtechceu.gtceu.api.pattern.TraceabilityPredicate; import com.gregtechceu.gtceu.common.capability.EnvironmentalHazardSavedData; @@ -77,6 +78,12 @@ public class CleanroomMachine extends WorkableElectricMultiblockMachine implements ICleanroomProvider, IDisplayUIMachine, IDataInfoProvider { + protected enum DimensionScanResult { + VALID, + INVALID, + UNLOADED + } + public static final int CLEAN_AMOUNT_THRESHOLD = 95; public static final int MIN_CLEAN_AMOUNT = 0; @@ -87,6 +94,7 @@ public class CleanroomMachine extends WorkableElectricMultiblockMachine @Persisted @Getter private int lDist = 0, rDist = 0, bDist = 0, fDist = 0, hDist = 0; + private DimensionScanResult lastDimensionScanResult = DimensionScanResult.INVALID; @Nullable private CleanroomType cleanroomType = null; @Persisted @@ -116,6 +124,7 @@ public void onStructureFormed() { super.onStructureFormed(); tier = Math.min(GTValues.MAX, tier); + maintenanceMachine = null; for (IMultiPart part : getParts()) { if (part instanceof IMaintenanceMachine maintenance) { maintenanceMachine = maintenance; @@ -198,8 +207,12 @@ private static boolean isPartIgnored(IMultiPart part) { * Scans for blocks around the controller to update the dimensions */ public void updateStructureDimensions() { + lastDimensionScanResult = scanStructureDimensions(); + } + + protected DimensionScanResult scanStructureDimensions() { Level world = getLevel(); - if (world == null) return; + if (world == null) return DimensionScanResult.INVALID; Direction front = getFrontFacing(); Direction back = front.getOpposite(); Direction left = front.getCounterClockWise(); @@ -222,27 +235,48 @@ public void updateStructureDimensions() { // find the left, right, back, and front distances for the structure pattern // maximum size is 15x15x15 including walls, so check 7 block radius around the controller for blocks for (int i = 1; i < 8; i++) { - if (lDist == 0 && isBlockEdge(world, lPos, left)) lDist = i; - if (rDist == 0 && isBlockEdge(world, rPos, right)) rDist = i; - if (bDist == 0 && isBlockEdge(world, bPos, back)) bDist = i; - if (fDist == 0 && isBlockEdge(world, fPos, front)) fDist = i; + if (lDist == 0) { + BlockPos next = lPos.relative(left); + if (world.isOutsideBuildHeight(next)) return DimensionScanResult.INVALID; + if (!world.isLoaded(next)) return DimensionScanResult.UNLOADED; + if (isBlockEdge(world, lPos, left)) lDist = i; + } + if (rDist == 0) { + BlockPos next = rPos.relative(right); + if (world.isOutsideBuildHeight(next)) return DimensionScanResult.INVALID; + if (!world.isLoaded(next)) return DimensionScanResult.UNLOADED; + if (isBlockEdge(world, rPos, right)) rDist = i; + } + if (bDist == 0) { + BlockPos next = bPos.relative(back); + if (world.isOutsideBuildHeight(next)) return DimensionScanResult.INVALID; + if (!world.isLoaded(next)) return DimensionScanResult.UNLOADED; + if (isBlockEdge(world, bPos, back)) bDist = i; + } + if (fDist == 0) { + BlockPos next = fPos.relative(front); + if (world.isOutsideBuildHeight(next)) return DimensionScanResult.INVALID; + if (!world.isLoaded(next)) return DimensionScanResult.UNLOADED; + if (isBlockEdge(world, fPos, front)) fDist = i; + } if (lDist != 0 && rDist != 0 && bDist != 0 && fDist != 0) break; } // height is diameter instead of radius, so it needs to be done separately for (int i = 1; i < 15; i++) { + BlockPos next = hPos.relative(Direction.DOWN); + if (world.isOutsideBuildHeight(next)) return DimensionScanResult.INVALID; + if (!world.isLoaded(next)) return DimensionScanResult.UNLOADED; if (isBlockFloor(world, hPos, Direction.DOWN)) hDist = i; if (hDist != 0) break; } if (Math.abs(lDist - rDist) > 1 || Math.abs(bDist - fDist) > 1) { - this.isFormed = false; - return; + return DimensionScanResult.INVALID; } if (lDist < MIN_RADIUS || rDist < MIN_RADIUS || bDist < MIN_RADIUS || fDist < MIN_RADIUS || hDist < MIN_DEPTH) { - this.isFormed = false; - return; + return DimensionScanResult.INVALID; } this.lDist = lDist; @@ -250,6 +284,17 @@ public void updateStructureDimensions() { this.bDist = bDist; this.fDist = fDist; this.hDist = hDist; + return DimensionScanResult.VALID; + } + + protected boolean isBlockEdgeAt(Level world, BlockPos pos) { + var state = world.getBlockState(pos); + return state == getCasingState() || state == getGlassState(); + } + + protected boolean isBlockFloorAt(Level world, BlockPos pos) { + var state = world.getBlockState(pos); + return state == getCasingState() || state == getGlassState() || state.is(CustomTags.CLEANROOM_FLOORS); } /** @@ -259,8 +304,8 @@ public void updateStructureDimensions() { * @return if a block is a valid wall block at pos moved in direction */ public boolean isBlockEdge(Level world, BlockPos.MutableBlockPos pos, Direction direction) { - var state = world.getBlockState(pos.move(direction)); - return state == getCasingState() || state == getGlassState(); + pos.move(direction); + return world.isLoaded(pos) && isBlockEdgeAt(world, pos); } /** @@ -270,8 +315,22 @@ public boolean isBlockEdge(Level world, BlockPos.MutableBlockPos pos, Direction * @return if a block is a valid floor block at pos moved in direction */ public boolean isBlockFloor(Level world, BlockPos.MutableBlockPos pos, Direction direction) { - var state = world.getBlockState(pos.move(direction)); - return state == getCasingState() || state == getGlassState() || state.is(CustomTags.CLEANROOM_FLOORS); + pos.move(direction); + return world.isLoaded(pos) && isBlockFloorAt(world, pos); + } + + @Override + public boolean checkPattern() { + // Assume compatibility implementations which override the legacy void hook completed their own scan. + lastDimensionScanResult = DimensionScanResult.VALID; + updateStructureDimensions(); + DimensionScanResult result = lastDimensionScanResult; + if (result != DimensionScanResult.VALID) { + getMultiblockState().setError(result == DimensionScanResult.UNLOADED ? + MultiblockState.UNLOAD_ERROR : MultiblockState.UNINIT_ERROR); + return false; + } + return buildPatternFromCurrentDimensions().checkPatternAt(getMultiblockState(), false); } @Override @@ -279,13 +338,16 @@ public BlockPattern getPattern() { // return the default structure, even if there is no valid size found // this means auto-build will still work, and prevents terminal crashes. if (getLevel() != null) updateStructureDimensions(); + return buildPatternFromCurrentDimensions(); + } + protected BlockPattern buildPatternFromCurrentDimensions() { // these can sometimes get set to 0 when loading the game, breaking JEI - if (lDist < MIN_RADIUS) lDist = MIN_RADIUS; - if (rDist < MIN_RADIUS) rDist = MIN_RADIUS; - if (bDist < MIN_RADIUS) bDist = MIN_RADIUS; - if (fDist < MIN_RADIUS) fDist = MIN_RADIUS; - if (hDist < MIN_DEPTH) hDist = MIN_DEPTH; + int lDist = Math.max(this.lDist, MIN_RADIUS); + int rDist = Math.max(this.rDist, MIN_RADIUS); + int bDist = Math.max(this.bDist, MIN_RADIUS); + int fDist = Math.max(this.fDist, MIN_RADIUS); + int hDist = Math.max(this.hDist, MIN_DEPTH); if (this.getFrontFacing() == Direction.EAST || this.getFrontFacing() == Direction.WEST) { int tmp = lDist; diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/DistillationTowerMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/DistillationTowerMachine.java index 9c476a61f69..540503cc9e5 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/DistillationTowerMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/DistillationTowerMachine.java @@ -101,6 +101,7 @@ public void attachConfigurators(ConfiguratorPanel left, ConfiguratorPanel right) @Override public void onStructureFormed() { super.onStructureFormed(); + firstValid = null; final int startY = getPos().getY() + yOffset; List parts = getParts().stream() .filter(part -> PartAbility.EXPORT_FLUIDS.isApplicable(part.self().getBlockState().getBlock())) diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/FusionReactorMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/FusionReactorMachine.java index b2fc394de51..1d2ab082e5a 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/FusionReactorMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/FusionReactorMachine.java @@ -144,7 +144,8 @@ public void onStructureInvalid() { ////////////////////////////////////// protected void updatePreHeatSubscription() { // do preheat logic for heat cool down and charge internal energy container - if (heat > 0 || (inputEnergyContainers != null && inputEnergyContainers.getEnergyStored() > 0 && + if (heat > 0 || (isStructureOperational() && inputEnergyContainers != null && + inputEnergyContainers.getEnergyStored() > 0 && innerEnergyContainer.getEnergyStored() < innerEnergyContainer.getEnergyCapacity())) { preHeatSubs = subscribeServerTick(preHeatSubs, this::updateHeat); } else if (preHeatSubs != null) { @@ -234,13 +235,13 @@ public void updateHeat() { // Don't drain heat when there is not enough energy and there is still some recipe progress, as that makes it // doubly hard to complete the recipe // (Will have to recover heat and recipe progress) - if ((getRecipeLogic().isIdle() || !isWorkingEnabled() || + if ((!isStructureOperational() || getRecipeLogic().isIdle() || !isWorkingEnabled() || (getRecipeLogic().isWaiting() && getRecipeLogic().getProgress() == 0)) && heat > 0) { heat = heat <= 10000 ? 0 : (heat - 10000); } // charge the internal energy storage var leftStorage = innerEnergyContainer.getEnergyCapacity() - innerEnergyContainer.getEnergyStored(); - if (inputEnergyContainers != null && leftStorage > 0) { + if (isStructureOperational() && inputEnergyContainers != null && leftStorage > 0) { innerEnergyContainer.addEnergy(inputEnergyContainers.removeEnergy(leftStorage)); } updatePreHeatSubscription(); diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/MultiblockTankMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/MultiblockTankMachine.java index bb93ab8e15e..978ead9a445 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/MultiblockTankMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/MultiblockTankMachine.java @@ -63,7 +63,7 @@ public InteractionResult onUse(BlockState state, Level world, BlockPos pos, Play var superResult = super.onUse(state, world, pos, player, hand, hit); if (superResult != InteractionResult.PASS) return superResult; - if (!isFormed()) return InteractionResult.FAIL; + if (!isStructureOperational()) return InteractionResult.FAIL; return InteractionResult.PASS; // Otherwise let MetaMachineBlock.use() open the UI } @@ -71,7 +71,7 @@ public InteractionResult onUse(BlockState state, Level world, BlockPos pos, Play @Override @Nullable public IFluidHandlerModifiable getFluidHandlerCap(@Nullable Direction side, boolean useCoverCapability) { - if (isFormed) { + if (isStructureOperational()) { return super.getFluidHandlerCap(side, useCoverCapability); } return null; diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/gcym/LargeMacerationTowerMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/gcym/LargeMacerationTowerMachine.java index 143f2af3fd9..0d69eea0356 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/gcym/LargeMacerationTowerMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/gcym/LargeMacerationTowerMachine.java @@ -36,12 +36,13 @@ public LargeMacerationTowerMachine(IMachineBlockEntity holder) { public void onStructureFormed() { super.onStructureFormed(); updateBounds(); + handlers.clear(); for (var holder : getCapabilitiesFlat(IO.IN, ItemRecipeCapability.CAP)) { if (holder instanceof IItemHandler ih) { handlers.add(ih); } } - hurtSub = subscribeServerTick(this::spinWheels); + hurtSub = subscribeServerTick(hurtSub, this::spinWheels); } @Override @@ -67,7 +68,7 @@ private void updateBounds() { } private void spinWheels() { - if (isRemote() || getLevel() == null) return; + if (isRemote() || getLevel() == null || !isStructureOperational()) return; if (getOffsetTimer() % 10 != 0) return; List itemEntities = new ArrayList<>(); diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/research/DataBankMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/research/DataBankMachine.java index 027d456eb53..bb681b0ed82 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/research/DataBankMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/research/DataBankMachine.java @@ -71,6 +71,10 @@ protected void appendMachineJadeTooltip(CompoundTag data, ITooltip tooltip, Bloc @Override public void onStructureFormed() { super.onStructureFormed(); + dataAccesses.clear(); + transmitters.clear(); + receivers.clear(); + maintenance = null; for (IMultiPart part : getParts()) { Block block = part.self().getBlockState().getBlock(); if (part instanceof IDataAccessMachine hatch && PartAbility.DATA_ACCESS.isApplicable(block)) { @@ -123,6 +127,7 @@ protected int calculateEnergyUsage() { public void onStructureInvalid() { super.onStructureInvalid(); energyUsage = 0; + maintenance = null; dataAccesses.clear(); transmitters.clear(); receivers.clear(); diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/research/HPCAMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/research/HPCAMachine.java index fcb9dfc06d5..13fba39a1f6 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/research/HPCAMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/research/HPCAMachine.java @@ -90,6 +90,7 @@ public void onStructureFormed() { super.onStructureFormed(); List coolantContainers = new ArrayList<>(); List componentHatches = new ArrayList<>(); + maintenance = null; for (IMultiPart part : getParts()) { @@ -204,7 +205,7 @@ public Widget createUIWidget() { // we need to know what components we have on the client if (getLevel().isClientSide) { - if (isFormed) { + if (isStructureOperational()) { hpcaHandler.tryGatherClientComponents(this.getLevel(), this.getPos(), this.getFrontFacing(), this.getUpwardsFacing(), this.isFlipped); } else { diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/generator/LargeCombustionEngineMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/generator/LargeCombustionEngineMachine.java index 9dee2807a39..29d162f26a7 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/generator/LargeCombustionEngineMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/generator/LargeCombustionEngineMachine.java @@ -198,7 +198,7 @@ public void addDisplayText(List textList) { builder.addFuelNeededLine(getRecipeFluidInputInfo(), recipeLogic.getMaxProgress()); - if (isFormed && isOxygenBoosted) { + if (isStructureOperational() && isOxygenBoosted) { final var key = isExtreme() ? "gtceu.multiblock.large_combustion_engine.liquid_oxygen_boosted" : "gtceu.multiblock.large_combustion_engine.oxygen_boosted"; builder.addCustom(tl -> tl.add(Component.translatable(key).withStyle(ChatFormatting.AQUA))); diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/CleaningMaintenanceHatchPartMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/CleaningMaintenanceHatchPartMachine.java index 431c512283c..cf1b665a525 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/CleaningMaintenanceHatchPartMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/CleaningMaintenanceHatchPartMachine.java @@ -48,6 +48,16 @@ public void addedToController(IMultiController controller) { @Override public void removedFromController(IMultiController controller) { super.removedFromController(controller); + clearCleanroom(controller); + } + + @Override + public void unloadedFromController(IMultiController controller) { + super.unloadedFromController(controller); + clearCleanroom(controller); + } + + private void clearCleanroom(IMultiController controller) { if (controller instanceof MetaMachine machine) { var receiver = machine.getTrait(CleanroomReceiverTrait.class); if (receiver != null && receiver.getCleanroom() == DUMMY_CLEANROOM) { diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/CokeOvenHatch.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/CokeOvenHatch.java index c7d51cc9799..025daf907db 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/CokeOvenHatch.java +++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/CokeOvenHatch.java @@ -48,9 +48,21 @@ public CokeOvenHatch(IMachineBlockEntity holder, Object... args) { @Override public void onUnload() { super.onUnload(); + clearControllerRuntime(); + } + + private void clearControllerRuntime() { inputInventory.setProxy(null); outputInventory.setProxy(null); tank.setProxy(null); + unsubscribeChanges(); + if (autoIOSubs != null) { + autoIOSubs.unsubscribe(); + autoIOSubs = null; + } + } + + private void unsubscribeChanges() { if (outputInventorySubs != null) { outputInventorySubs.unsubscribe(); outputInventorySubs = null; @@ -65,6 +77,7 @@ public void onUnload() { public void addedToController(IMultiController controller) { super.addedToController(controller); if (controller instanceof CokeOvenMachine cokeOven) { + unsubscribeChanges(); outputInventorySubs = cokeOven.exportItems.addChangedListener(this::updateAutoIOSubscription); outputTankSubs = cokeOven.exportFluids.addChangedListener(this::updateAutoIOSubscription); inputInventory.setProxy(cokeOven.importItems); @@ -77,17 +90,13 @@ public void addedToController(IMultiController controller) { @Override public void removedFromController(IMultiController controller) { super.removedFromController(controller); - inputInventory.setProxy(null); - outputInventory.setProxy(null); - tank.setProxy(null); - if (outputInventorySubs != null) { - outputInventorySubs.unsubscribe(); - outputInventorySubs = null; - } - if (outputTankSubs != null) { - outputTankSubs.unsubscribe(); - outputTankSubs = null; - } + clearControllerRuntime(); + } + + @Override + public void unloadedFromController(IMultiController controller) { + super.unloadedFromController(controller); + clearControllerRuntime(); } @Override @@ -117,9 +126,9 @@ public void onRotated(Direction oldFacing, Direction newFacing) { } protected void updateAutoIOSubscription() { - if ((!outputInventory.isEmpty() && + if (hasOperationalController() && ((!outputInventory.isEmpty() && GTTransferUtils.hasAdjacentItemHandler(getLevel(), getPos(), getFrontFacing())) || - (!tank.isEmpty() && GTTransferUtils.hasAdjacentFluidHandler(getLevel(), getPos(), getFrontFacing()))) { + (!tank.isEmpty() && GTTransferUtils.hasAdjacentFluidHandler(getLevel(), getPos(), getFrontFacing())))) { autoIOSubs = subscribeServerTick(autoIOSubs, this::autoIO); } else if (autoIOSubs != null) { autoIOSubs.unsubscribe(); @@ -128,6 +137,10 @@ protected void updateAutoIOSubscription() { } protected void autoIO() { + if (!hasOperationalController()) { + updateAutoIOSubscription(); + return; + } if (getOffsetTimer() % 5 == 0) { outputInventory.exportToNearby(getFrontFacing()); tank.exportToNearby(getFrontFacing()); @@ -135,6 +148,10 @@ protected void autoIO() { } } + private boolean hasOperationalController() { + return controllers.stream().anyMatch(IMultiController::isStructureOperational); + } + ////////////////////////////////////// // ********* GUI *********// ////////////////////////////////////// diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/DataAccessHatchMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/DataAccessHatchMachine.java index 537e0e61fda..ae7b38b2271 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/DataAccessHatchMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/DataAccessHatchMachine.java @@ -67,11 +67,10 @@ protected NotifiableItemStackHandler createImportItemHandler() { if (isCreative) return new NotifiableItemStackHandler(this, 0, IO.NONE); var inventory = new NotifiableItemStackHandler(this, getInventorySize(), IO.NONE, IO.BOTH) .setFilter(stack -> { - boolean isDataBank = isFormed() && getControllers().first() instanceof DataBankMachine; + boolean isDataBank = hasOperationalDataBankController(); return ResearchManager.isStackDataItem(stack, isDataBank); }); - inventory.addChangedListener( - () -> rebuildData(isFormed() && getControllers().first() instanceof DataBankMachine)); + inventory.addChangedListener(() -> rebuildData(hasOperationalDataBankController())); return inventory; } @@ -133,7 +132,8 @@ private void rebuildData(boolean isDataBank) { @Override public boolean isRecipeAvailable(@NotNull GTRecipe recipe) { - return isCreative || recipes.stream().anyMatch(definition -> definition.getId().equals(recipe.getId())); + return isCreative || hasOperationalController() && + recipes.stream().anyMatch(definition -> definition.getId().equals(recipe.getId())); } @NotNull @@ -168,8 +168,29 @@ public boolean canShared() { @Override public void addedToController(IMultiController controller) { - rebuildData(controller instanceof DataBankMachine); super.addedToController(controller); + rebuildData(hasOperationalDataBankController()); + } + + @Override + public void removedFromController(IMultiController controller) { + super.removedFromController(controller); + rebuildData(hasOperationalDataBankController()); + } + + @Override + public void unloadedFromController(IMultiController controller) { + super.unloadedFromController(controller); + rebuildData(hasOperationalDataBankController()); + } + + private boolean hasOperationalController() { + return controllers.stream().anyMatch(IMultiController::isStructureOperational); + } + + private boolean hasOperationalDataBankController() { + return controllers.stream().anyMatch(controller -> controller.isStructureOperational() && + controller instanceof DataBankMachine); } @Override diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/MufflerPartMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/MufflerPartMachine.java index b65244e362d..9caea1cc817 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/MufflerPartMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/MufflerPartMachine.java @@ -79,7 +79,7 @@ private boolean calculateChance() { public void clientTick() { super.clientTick(); for (IMultiController controller : getControllers()) { - if (controller instanceof IWorkLogicMachine workLogicMachine && + if (controller.isStructureOperational() && controller instanceof IWorkLogicMachine workLogicMachine && workLogicMachine.getWorkLogic().isWorking()) { emitPollutionParticles(); break; @@ -99,6 +99,16 @@ public void addedToController(IMultiController controller) { @Override public void removedFromController(IMultiController controller) { super.removedFromController(controller); + unsubscribeSnowIfDetached(); + } + + @Override + public void unloadedFromController(IMultiController controller) { + super.unloadedFromController(controller); + unsubscribeSnowIfDetached(); + } + + private void unsubscribeSnowIfDetached() { if (controllers.isEmpty()) { unsubscribe(snowSubscription); snowSubscription = null; @@ -108,7 +118,7 @@ public void removedFromController(IMultiController controller) { private void tryBreakSnow() { if (getOffsetTimer() % 10 == 0) { for (IMultiController controller : getControllers()) { - if (controller instanceof IWorkLogicMachine workLogicMachine && + if (controller.isStructureOperational() && controller instanceof IWorkLogicMachine workLogicMachine && workLogicMachine.getWorkLogic().isWorking()) { BlockPos mufflerPos = getPos().relative(getFrontFacing()); GTUtil.tryBreakSnow(getLevel(), mufflerPos, getLevel().getBlockState(mufflerPos), true); diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/ObjectHolderMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/ObjectHolderMachine.java index 3c80f5a6a27..a068058b2e9 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/ObjectHolderMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/ObjectHolderMachine.java @@ -25,11 +25,11 @@ import com.lowdragmc.lowdraglib.utils.Position; import net.minecraft.MethodsReturnNonnullByDefault; +import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; import net.minecraft.world.item.ItemStack; import net.minecraftforge.items.IItemHandlerModifiable; -import org.jetbrains.annotations.MustBeInvokedByOverriders; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -90,11 +90,18 @@ public void setFrontFacing(Direction frontFacing) { } } - @MustBeInvokedByOverriders @Override - public void removedFromController(IMultiController controller) { - super.removedFromController(controller); - isLocked = false; + public void onControllerBindingRetired(BlockPos controllerPos, long instanceId) { + if (!isFormed()) { + isLocked = false; + } + } + + @Override + public void unloadedFromController(IMultiController controller) { + // Keep the consumed research inputs locked while either side is merely unloaded. A successful revalidation + // reconnects this holder to the same in-progress recipe; only a definitive invalidation may unlock it. + super.unloadedFromController(controller); } private boolean isDataItemFacing(@Nullable Direction direction) { diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/OpticalComputationHatchMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/OpticalComputationHatchMachine.java index 3597c53e5ca..a246830e8e6 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/OpticalComputationHatchMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/OpticalComputationHatchMachine.java @@ -56,6 +56,12 @@ public void removedFromController(IMultiController controller) { markComputationTopologyDirty(); } + @Override + public void unloadedFromController(IMultiController controller) { + super.unloadedFromController(controller); + markComputationTopologyDirty(); + } + private void markComputationTopologyDirty() { if (getLevel() instanceof ServerLevel serverLevel) { ComputationNetworkManager.get(serverLevel).markTopologyDirty(); diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/OpticalDataHatchMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/OpticalDataHatchMachine.java index cef7a0b05f1..89ab0f378a6 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/OpticalDataHatchMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/OpticalDataHatchMachine.java @@ -36,7 +36,7 @@ public OpticalDataHatchMachine(IMachineBlockEntity holder, boolean isTransmitter @Override public boolean isRecipeAvailable(@NotNull GTRecipe recipe) { - if (!isFormed()) { + if (!isFormed() || getControllers().isEmpty()) { return false; } if (isTransmitter()) { diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/RotorHolderPartMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/RotorHolderPartMachine.java index 332702649a0..3c16c13a6e9 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/RotorHolderPartMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/RotorHolderPartMachine.java @@ -159,7 +159,8 @@ protected void updateRotorSubscription() { } private void updateRotorSpeed() { - if (isFormed() && getControllers().first() instanceof IWorkableMultiController workable) { + if (isFormed() && !getControllers().isEmpty() && + getControllers().first() instanceof IWorkableMultiController workable) { if (workable.getWorkLogic().isWorking()) return; } if (!hasRotor()) { @@ -185,7 +186,8 @@ public boolean onWorking(IWorkableMultiController controller) { } if (self().getOffsetTimer() % 20 == 0) { var numMaintenanceProblems = 0; - if (isFormed() && getControllers().first() instanceof IMaintenanceMachine maintenance) { + if (isFormed() && !getControllers().isEmpty() && + getControllers().first() instanceof IMaintenanceMachine maintenance) { numMaintenanceProblems = maintenance.getNumMaintenanceProblems(); } damageRotor(1 + numMaintenanceProblems); @@ -194,7 +196,8 @@ public boolean onWorking(IWorkableMultiController controller) { } public int getTierDifference() { - if (isFormed() && getControllers().first() instanceof ITieredMachine tieredMachine) { + if (isFormed() && !getControllers().isEmpty() && + getControllers().first() instanceof ITieredMachine tieredMachine) { return getTier() - tieredMachine.getTier(); } return -1; diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/TankValvePartMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/TankValvePartMachine.java index 3ab7533cd61..a6b81bc8bb8 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/TankValvePartMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/TankValvePartMachine.java @@ -48,7 +48,7 @@ public boolean canShared() { @Override public void onUnload() { super.onUnload(); - autoIOSubscription.updateSubscription(); + clearControllerRuntime(); } @Override @@ -66,15 +66,26 @@ public void addedToController(IMultiController controller) { @Override public void removedFromController(IMultiController controller) { super.removedFromController(controller); + clearControllerRuntime(); + } + + @Override + public void unloadedFromController(IMultiController controller) { + super.unloadedFromController(controller); + clearControllerRuntime(); + } + private void clearControllerRuntime() { tankProxy.setProxy(null); - autoIOSubscription.updateSubscription(); + autoIOSubscription.unsubscribe(); unsubscribeChanges(); } private void unsubscribeChanges() { - if (tankChangeListener != null) + if (tankChangeListener != null) { tankChangeListener.unsubscribe(); + tankChangeListener = null; + } } @Override @@ -95,6 +106,10 @@ private IFluidHandler getTargetTank() { } private void autoIO() { + if (!hasOperationalController()) { + autoIOSubscription.updateSubscription(); + return; + } if (getOffsetTimer() % 5 == 0) { tankProxy.exportToNearby(getFrontFacing()); } @@ -103,11 +118,15 @@ private void autoIO() { } private boolean shouldAutoIO() { - if (!isFormed()) return false; + if (!hasOperationalController()) return false; if (getFrontFacing() != Direction.DOWN) return false; if (tankProxy.isEmpty()) return false; if (getTargetTank() == null) return false; return true; } + + private boolean hasOperationalController() { + return controllers.stream().anyMatch(IMultiController::isStructureOperational); + } } diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/monitor/AdvancedMonitorPartMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/monitor/AdvancedMonitorPartMachine.java index 2694d20833a..51e44122ece 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/monitor/AdvancedMonitorPartMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/monitor/AdvancedMonitorPartMachine.java @@ -79,5 +79,7 @@ public void onLoad() { @Override public void onUnload() { unsubscribe(clickResetSubscription); + clickResetSubscription = null; + super.onUnload(); } } diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/primitive/CharcoalPileIgniterMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/primitive/CharcoalPileIgniterMachine.java index 515c8ff0f67..51584b475c6 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/primitive/CharcoalPileIgniterMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/primitive/CharcoalPileIgniterMachine.java @@ -7,6 +7,7 @@ import com.gregtechceu.gtceu.api.machine.trait.WorkLogic; import com.gregtechceu.gtceu.api.pattern.BlockPattern; import com.gregtechceu.gtceu.api.pattern.FactoryBlockPattern; +import com.gregtechceu.gtceu.api.pattern.MultiblockState; import com.gregtechceu.gtceu.api.pattern.Predicates; import com.gregtechceu.gtceu.api.pattern.TraceabilityPredicate; import com.gregtechceu.gtceu.api.pattern.util.RelativeDirection; @@ -49,6 +50,12 @@ public class CharcoalPileIgniterMachine extends WorkableMultiblockMachine { + protected enum DimensionScanResult { + VALID, + INVALID, + UNLOADED + } + private static final int MIN_RADIUS = 1; private static final int MIN_DEPTH = 2; private static final int MAX_HEIGHT = 5; @@ -64,6 +71,7 @@ public class CharcoalPileIgniterMachine extends WorkableMultiblockMachine { private int fDist = 0; @DescSynced private int hDist = 0; + private DimensionScanResult lastDimensionScanResult = DimensionScanResult.INVALID; private boolean hasAir = false; @@ -83,6 +91,7 @@ public CharcoalPileIgniterMachine(IMachineBlockEntity holder) { public void onStructureFormed() { super.onStructureFormed(); hasAir = false; + logPos.clear(); if (getMultiblockState().getMatchContext().containsKey("logPos")) { Long2BooleanMap logPositions = getMultiblockState().getMatchContext().get("logPos"); for (var entry : logPositions.long2BooleanEntrySet()) { @@ -107,12 +116,29 @@ public void setWorkingEnabled(boolean isWorkingAllowed) {} @Override public BlockPattern getPattern() { updateDimensions(); + return buildPatternFromCurrentDimensions(); + } - if (lDist < MIN_RADIUS) lDist = MIN_RADIUS; - if (rDist < MIN_RADIUS) rDist = MIN_RADIUS; - if (fDist < MIN_RADIUS) fDist = MIN_RADIUS; - if (bDist < MIN_RADIUS) bDist = MIN_RADIUS; - if (hDist < MIN_DEPTH) hDist = MIN_DEPTH; + @Override + public boolean checkPattern() { + // Assume compatibility implementations which override the legacy void hook completed their own scan. + lastDimensionScanResult = DimensionScanResult.VALID; + updateDimensions(); + DimensionScanResult result = lastDimensionScanResult; + if (result != DimensionScanResult.VALID) { + getMultiblockState().setError(result == DimensionScanResult.UNLOADED ? + MultiblockState.UNLOAD_ERROR : MultiblockState.UNINIT_ERROR); + return false; + } + return buildPatternFromCurrentDimensions().checkPatternAt(getMultiblockState(), false); + } + + protected BlockPattern buildPatternFromCurrentDimensions() { + int lDist = Math.max(this.lDist, MIN_RADIUS); + int rDist = Math.max(this.rDist, MIN_RADIUS); + int fDist = Math.max(this.fDist, MIN_RADIUS); + int bDist = Math.max(this.bDist, MIN_RADIUS); + int hDist = Math.max(this.hDist, MIN_DEPTH); if (this.getFrontFacing().getAxis() == Direction.Axis.X) { int tmp = lDist; @@ -208,8 +234,12 @@ protected static TraceabilityPredicate logPredicate() { } public void updateDimensions() { + lastDimensionScanResult = scanDimensions(); + } + + protected DimensionScanResult scanDimensions() { Level level = getLevel(); - if (level == null) return; + if (level == null) return DimensionScanResult.INVALID; Direction front = getFrontFacing(); Direction back = front.getOpposite(); Direction left = RelativeDirection.LEFT.getRelative(front, getUpwardsFacing(), false); @@ -230,22 +260,45 @@ public void updateDimensions() { int hDist = 0; for (int i = 1; i <= MAX_HEIGHT; i++) { - if (lDist != 0 && rDist != 0 && hDist != 0) break; - if (lDist == 0 && isBlockWall(level, lPos, left)) lDist = i; - if (rDist == 0 && isBlockWall(level, rPos, right)) rDist = i; - if (bDist == 0 && isBlockWall(level, bPos, back)) bDist = i; - if (fDist == 0 && isBlockWall(level, fPos, front)) fDist = i; - if (hDist == 0 && isBlockFloor(level, hPos)) hDist = i; + if (lDist != 0 && rDist != 0 && bDist != 0 && fDist != 0 && hDist != 0) break; + if (lDist == 0) { + lPos.move(left); + if (level.isOutsideBuildHeight(lPos)) return DimensionScanResult.INVALID; + if (!level.isLoaded(lPos)) return DimensionScanResult.UNLOADED; + if (isBlockWallAt(level, lPos)) lDist = i; + } + if (rDist == 0) { + rPos.move(right); + if (level.isOutsideBuildHeight(rPos)) return DimensionScanResult.INVALID; + if (!level.isLoaded(rPos)) return DimensionScanResult.UNLOADED; + if (isBlockWallAt(level, rPos)) rDist = i; + } + if (bDist == 0) { + bPos.move(back); + if (level.isOutsideBuildHeight(bPos)) return DimensionScanResult.INVALID; + if (!level.isLoaded(bPos)) return DimensionScanResult.UNLOADED; + if (isBlockWallAt(level, bPos)) bDist = i; + } + if (fDist == 0) { + fPos.move(front); + if (level.isOutsideBuildHeight(fPos)) return DimensionScanResult.INVALID; + if (!level.isLoaded(fPos)) return DimensionScanResult.UNLOADED; + if (isBlockWallAt(level, fPos)) fDist = i; + } + if (hDist == 0) { + hPos.move(Direction.DOWN); + if (level.isOutsideBuildHeight(hPos)) return DimensionScanResult.INVALID; + if (!level.isLoaded(hPos)) return DimensionScanResult.UNLOADED; + if (isBlockFloorAt(level, hPos)) hDist = i; + } } if (Math.abs(lDist - rDist) > 1 || Math.abs(bDist - fDist) > 1) { - this.isFormed = false; - return; + return DimensionScanResult.INVALID; } if (lDist < MIN_RADIUS || rDist < MIN_RADIUS || fDist < MIN_RADIUS || bDist < MIN_RADIUS || hDist < MIN_DEPTH) { - this.isFormed = false; - return; + return DimensionScanResult.INVALID; } this.lDist = lDist; @@ -253,14 +306,15 @@ public void updateDimensions() { this.fDist = fDist; this.bDist = bDist; this.hDist = hDist; + return DimensionScanResult.VALID; } - private static boolean isBlockWall(Level level, BlockPos.MutableBlockPos pos, Direction direction) { - return level.getBlockState(pos.move(direction)).is(CustomTags.CHARCOAL_PILE_IGNITER_WALLS); + private static boolean isBlockWallAt(Level level, BlockPos pos) { + return level.getBlockState(pos).is(CustomTags.CHARCOAL_PILE_IGNITER_WALLS); } - private static boolean isBlockFloor(Level level, BlockPos.MutableBlockPos pos) { - return level.getBlockState(pos.move(Direction.DOWN)).is(Blocks.BRICKS); + private static boolean isBlockFloorAt(Level level, BlockPos pos) { + return level.getBlockState(pos).is(Blocks.BRICKS); } @Override @@ -303,7 +357,7 @@ private void convertLogBlocks() { @Override public InteractionResult onUse(BlockState state, Level level, BlockPos pos, Player player, InteractionHand hand, BlockHitResult hit) { - if (!isFormed() || hasAir) { + if (!isStructureOperational() || hasAir) { return super.onUse(state, level, pos, player, hand, hit); } ItemStack stack = player.getItemInHand(hand); diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/primitive/PrimitiveBlastFurnaceMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/primitive/PrimitiveBlastFurnaceMachine.java index 5032acb91ce..b3d14b61c22 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/primitive/PrimitiveBlastFurnaceMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/primitive/PrimitiveBlastFurnaceMachine.java @@ -90,7 +90,7 @@ public void notifyWorkStatusChanged(WorkLogic.Status oldStatus, WorkLogic.Status @OnlyIn(Dist.CLIENT) public void clientTick() { super.clientTick(); - if (isFormed) { + if (isStructureOperational()) { var pos = this.getPos(); var facing = this.getFrontFacing().getOpposite(); float xPos = facing.getStepX() * 0.76F + pos.getX() + 0.5F; @@ -178,6 +178,9 @@ public void animateTick(RandomSource random) { } private void hurtEntitiesAndBreakSnow() { + if (!isStructureOperational()) { + return; + } BlockPos middlePos = self().getPos().offset(getFrontFacing().getOpposite().getNormal()); getLevel().getEntities(null, new AABB(middlePos)).forEach(e -> e.hurt(e.damageSources().lava(), 3.0f)); diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/primitive/PrimitivePumpMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/primitive/PrimitivePumpMachine.java index 0db73a90ded..6ac10f4b70d 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/primitive/PrimitivePumpMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/primitive/PrimitivePumpMachine.java @@ -56,8 +56,10 @@ protected void appendMachineJadeTooltip(CompoundTag data, ITooltip tooltip, Bloc @Override public void onStructureFormed() { super.onStructureFormed(); + hatchModifier = 0; + fluidTank = null; initializeTank(); - produceWaterSubscription = subscribeServerTick(this::produceWater); + produceWaterSubscription = subscribeServerTick(produceWaterSubscription, this::produceWater); } private void initializeTank() { @@ -102,12 +104,13 @@ public void onUnload() { private void resetState() { unsubscribe(produceWaterSubscription); + produceWaterSubscription = null; hatchModifier = 0; fluidTank = null; } private void produceWater() { - if (getOffsetTimer() % 20 == 0 && isFormed() && !getMultiblockState().hasError()) { + if (getOffsetTimer() % 20 == 0 && isStructureOperational() && !getMultiblockState().hasError()) { if (biomeModifier == 0) { biomeModifier = GTUtil.getPumpBiomeModifier(getLevel().getBiome(getPos())); } else if (biomeModifier > 0) { diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/steam/LargeBoilerMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/steam/LargeBoilerMachine.java index b2b74e73967..92c01a65d09 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/steam/LargeBoilerMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/steam/LargeBoilerMachine.java @@ -110,7 +110,8 @@ protected void updateSteamSubscription() { } protected void updateCurrentTemperature() { - if (recipeLogic.isWorking()) { + boolean structureOperational = isStructureOperational() && !getMultiblockState().hasError(); + if (structureOperational && recipeLogic.isWorking()) { if (getOffsetTimer() % 10 == 0) { if (currentTemperature < getMaxTemperature()) { currentTemperature = Mth.clamp(currentTemperature + heatSpeed * 10, 0, getMaxTemperature()); @@ -120,7 +121,7 @@ protected void updateCurrentTemperature() { currentTemperature -= getCoolDownRate(); } - if (isFormed() && getOffsetTimer() % TICKS_PER_STEAM_GENERATION == 0) { + if (structureOperational && getOffsetTimer() % TICKS_PER_STEAM_GENERATION == 0) { var maxDrain = currentTemperature * throttle * TICKS_PER_STEAM_GENERATION / (ConfigHolder.INSTANCE.machines.largeBoilers.steamPerWater * 100); if (currentTemperature < 100) { diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/trait/multiblock/ParallelHatchTrait.java b/src/main/java/com/gregtechceu/gtceu/common/machine/trait/multiblock/ParallelHatchTrait.java index f87aa3cf425..a7ea6a0297d 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/machine/trait/multiblock/ParallelHatchTrait.java +++ b/src/main/java/com/gregtechceu/gtceu/common/machine/trait/multiblock/ParallelHatchTrait.java @@ -52,6 +52,7 @@ public void appendJadeTooltip(CompoundTag data, ITooltip tooltip, BlockAccessor @Override public void onStructureFormed() { IMultiblockMachineTrait.super.onStructureFormed(); + parallelHatch = null; for (var part : getMultiMachine().getParts()) { if (part instanceof IParallelHatch pHatch) { parallelHatch = pHatch; diff --git a/src/main/java/com/gregtechceu/gtceu/core/mixins/LevelMixin.java b/src/main/java/com/gregtechceu/gtceu/core/mixins/LevelMixin.java index 637226a30e0..2562c1b04ae 100644 --- a/src/main/java/com/gregtechceu/gtceu/core/mixins/LevelMixin.java +++ b/src/main/java/com/gregtechceu/gtceu/core/mixins/LevelMixin.java @@ -40,7 +40,7 @@ public abstract class LevelMixin implements LevelAccessor { private void gtceu$getBlockEntityOffThread(BlockPos pos, CallbackInfoReturnable cir) { if (Thread.currentThread() == this.thread) return; if (this.isClientSide) return; - if (!MultiblockWorldSavedData.isThreadService() && !AsyncThreadData.isThreadService()) return; + if (!AsyncThreadData.isThreadService()) return; int chunkX = pos.getX() >> 4, chunkZ = pos.getZ() >> 4; if (!this.getChunkSource().hasChunk(chunkX, chunkZ)) return; @@ -55,7 +55,7 @@ public abstract class LevelMixin implements LevelAccessor { private void gtceu$getBlockStateOffThread(BlockPos pos, CallbackInfoReturnable cir) { if (Thread.currentThread() == this.thread) return; if (this.isClientSide) return; - if (!MultiblockWorldSavedData.isThreadService() && !AsyncThreadData.isThreadService()) return; + if (!AsyncThreadData.isThreadService()) return; int chunkX = pos.getX() >> 4, chunkZ = pos.getZ() >> 4; if (!this.getChunkSource().hasChunk(chunkX, chunkZ)) return; diff --git a/src/main/java/com/gregtechceu/gtceu/core/mixins/ServerChunkProviderMixin.java b/src/main/java/com/gregtechceu/gtceu/core/mixins/ServerChunkProviderMixin.java index 45bb8c85423..391bc161893 100644 --- a/src/main/java/com/gregtechceu/gtceu/core/mixins/ServerChunkProviderMixin.java +++ b/src/main/java/com/gregtechceu/gtceu/core/mixins/ServerChunkProviderMixin.java @@ -1,7 +1,5 @@ package com.gregtechceu.gtceu.core.mixins; -import com.gregtechceu.gtceu.api.pattern.MultiblockWorldSavedData; - import com.lowdragmc.lowdraglib.async.AsyncThreadData; import net.minecraft.server.level.ChunkHolder; @@ -60,8 +58,7 @@ private void injectClearCache(CallbackInfo ci) { @Inject(method = "getChunkNow", at = @At(value = "HEAD"), cancellable = true) private void getTileEntity(int pChunkX, int pChunkZ, CallbackInfoReturnable cir) { - if (Thread.currentThread() != this.mainThread && - (MultiblockWorldSavedData.isThreadService() || AsyncThreadData.isThreadService())) { + if (Thread.currentThread() != this.mainThread && AsyncThreadData.isThreadService()) { long i = ChunkPos.asLong(pChunkX, pChunkZ); for (int j = 0; j < 4; ++j) { diff --git a/src/main/java/com/gregtechceu/gtceu/forge/ForgeCommonEventListener.java b/src/main/java/com/gregtechceu/gtceu/forge/ForgeCommonEventListener.java index 60cc91738ea..73964713e36 100644 --- a/src/main/java/com/gregtechceu/gtceu/forge/ForgeCommonEventListener.java +++ b/src/main/java/com/gregtechceu/gtceu/forge/ForgeCommonEventListener.java @@ -54,6 +54,7 @@ import com.gregtechceu.gtceu.integration.map.cache.server.ServerCache; import com.gregtechceu.gtceu.utils.TaskHandler; +import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; import net.minecraft.nbt.CompoundTag; import net.minecraft.server.level.ServerLevel; @@ -81,6 +82,7 @@ import net.minecraftforge.event.entity.player.PlayerEvent; import net.minecraftforge.event.entity.player.PlayerInteractEvent; import net.minecraftforge.event.level.BlockEvent; +import net.minecraftforge.event.level.ChunkEvent; import net.minecraftforge.event.level.ChunkWatchEvent; import net.minecraftforge.event.level.LevelEvent; import net.minecraftforge.event.server.ServerStartingEvent; @@ -233,6 +235,24 @@ public static void onBreakEvent(BlockEvent.BreakEvent event) { } } + @SubscribeEvent(priority = EventPriority.LOWEST) + public static void wakeMultiblocksAfterBreak(BlockEvent.BreakEvent event) { + if (event.getLevel() instanceof ServerLevel serverLevel) { + BlockPos changedPos = event.getPos().immutable(); + runOnServerThread(serverLevel, + () -> MultiblockWorldSavedData.getOrCreate(serverLevel).onBlockChanged(changedPos)); + } + } + + @SubscribeEvent(priority = EventPriority.LOWEST) + public static void wakeMultiblocksAfterPlace(BlockEvent.EntityPlaceEvent event) { + if (event.getLevel() instanceof ServerLevel serverLevel) { + BlockPos changedPos = event.getPos().immutable(); + runOnServerThread(serverLevel, + () -> MultiblockWorldSavedData.getOrCreate(serverLevel).onBlockChanged(changedPos)); + } + } + @SubscribeEvent public static void registerCommand(RegisterCommandsEvent event) { GTCommands.register(event.getDispatcher(), event.getBuildContext()); @@ -253,6 +273,7 @@ public static void registerReloadListeners(AddReloadListenerEvent event) { public static void levelTick(TickEvent.LevelTickEvent event) { if (event.phase == TickEvent.Phase.END && event.level instanceof ServerLevel serverLevel) { TaskHandler.onTickUpdate(serverLevel); + MultiblockWorldSavedData.getOrCreate(serverLevel).tick(); ComputationNetworkManager.get(serverLevel).tick(); if (ConfigHolder.INSTANCE.gameplay.environmentalHazards) { EnvironmentalHazardSavedData.getOrCreate(serverLevel).tick(); @@ -261,6 +282,34 @@ public static void levelTick(TickEvent.LevelTickEvent event) { } } + @SubscribeEvent + public static void chunkUnload(ChunkEvent.Unload event) { + if (!(event.getLevel() instanceof ServerLevel serverLevel)) { + return; + } + ChunkPos chunkPos = event.getChunk().getPos(); + runOnServerThread(serverLevel, + () -> MultiblockWorldSavedData.getOrCreate(serverLevel).onChunkUnload(chunkPos)); + } + + @SubscribeEvent + public static void chunkLoad(ChunkEvent.Load event) { + if (!(event.getLevel() instanceof ServerLevel serverLevel)) { + return; + } + ChunkPos chunkPos = event.getChunk().getPos(); + runOnServerThread(serverLevel, + () -> MultiblockWorldSavedData.getOrCreate(serverLevel).onChunkLoad(chunkPos)); + } + + private static void runOnServerThread(ServerLevel serverLevel, Runnable action) { + if (serverLevel.getServer().isSameThread()) { + action.run(); + } else { + serverLevel.getServer().execute(action); + } + } + @SubscribeEvent public static void worldLoad(LevelEvent.Load event) { if (event.getLevel().isClientSide()) { @@ -275,6 +324,7 @@ public static void worldUnload(LevelEvent.Unload event) { if (event.getLevel() instanceof ServerLevel serverLevel) { TaskHandler.onWorldUnLoad(serverLevel); MultiblockWorldSavedData.getOrCreate(serverLevel).releaseExecutorService(); + ComputationNetworkManager.release(serverLevel); ServerCache.instance.invalidateWorld(serverLevel); } else if (event.getLevel().isClientSide()) { ClientCacheManager.saveCaches(); diff --git a/src/test/java/com/gregtechceu/gtceu/api/pattern/MultiblockWorldSavedDataTest.java b/src/test/java/com/gregtechceu/gtceu/api/pattern/MultiblockWorldSavedDataTest.java new file mode 100644 index 00000000000..9c19af8dfc7 --- /dev/null +++ b/src/test/java/com/gregtechceu/gtceu/api/pattern/MultiblockWorldSavedDataTest.java @@ -0,0 +1,1335 @@ +package com.gregtechceu.gtceu.api.pattern; + +import com.gregtechceu.gtceu.GTCEu; +import com.gregtechceu.gtceu.api.machine.IMachineBlockEntity; +import com.gregtechceu.gtceu.api.machine.MultiblockMachineDefinition; +import com.gregtechceu.gtceu.api.machine.feature.multiblock.IMultiController; +import com.gregtechceu.gtceu.api.machine.feature.multiblock.IMultiPart; +import com.gregtechceu.gtceu.api.machine.multiblock.MultiblockControllerMachine; +import com.gregtechceu.gtceu.api.machine.multiblock.WorkableMultiblockMachine; +import com.gregtechceu.gtceu.api.machine.multiblock.part.MultiblockPartMachine; +import com.gregtechceu.gtceu.api.machine.trait.WorkLogic; +import com.gregtechceu.gtceu.api.pattern.util.RelativeDirection; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.block.Blocks; +import net.minecraftforge.gametest.GameTestHolder; +import net.minecraftforge.gametest.PrefixGameTestTemplate; + +import it.unimi.dsi.fastutil.longs.LongOpenHashSet; + +import java.lang.reflect.Field; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +@PrefixGameTestTemplate(false) +@GameTestHolder(GTCEu.MOD_ID) +public class MultiblockWorldSavedDataTest { + + private static final BlockPos LCR_CONTROLLER_POS = new BlockPos(1, 2, 0); + + @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData") + public static void pendingCheckMembershipSurvivesRepeatedRequeue(GameTestHelper helper) { + var controller = getController(helper); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + savedData.removeAsyncLogic(controller); + + for (int i = 0; i < 128; i++) { + savedData.addAsyncLogic(controller); + savedData.removeAsyncLogic(controller); + } + savedData.addAsyncLogic(controller); + + helper.assertTrue(savedData.isStructureCheckPending(controller), + "The controller lost its pending check after repeated remove-and-add cycles"); + savedData.removeAsyncLogic(controller); + helper.assertFalse(savedData.isStructureCheckPending(controller), + "Removing the controller left an active pending check"); + helper.succeed(); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", timeoutTicks = 80) + public static void pendingChecksFormOnTheServerTick(GameTestHelper helper) { + var controller = getController(helper); + var state = controller.getMultiblockState(); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + savedData.removeAsyncLogic(controller); + controller.onStructureInvalid(); + state.setError(MultiblockState.UNINIT_ERROR); + savedData.addAsyncLogic(controller); + + helper.succeedWhen(() -> { + helper.assertTrue(controller.isFormed(), "The intact multiblock was not formed by the tick queue"); + helper.assertTrue(savedData.mapping.get(controller.getPos()) == state, + "The formed multiblock was not installed as the current mapping"); + helper.assertFalse(savedData.isStructureCheckPending(controller), + "The formed controller remained in the pending queue"); + helper.assertFalse(controller.isStructureRevalidationPending(), + "The formed controller remained marked for revalidation"); + helper.assertTrue(hasWorkSubscription((WorkableMultiblockMachine) controller), + "A successful revalidation did not restore the work subscription"); + }); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40) + public static void cachedChangeQueuesWithoutReadingUnloadedPosition(GameTestHelper helper) { + var controller = getController(helper); + var state = controller.getMultiblockState(); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + BlockPos unloadedPos = findUnloadedPos(helper, controller.getPos()); + helper.assertTrue(controller.isFormed(), "The intact multiblock was not formed before the cached change"); + BlockPos lastCheckedPos = state.getPos(); + var previousError = state.error; + savedData.removeAsyncLogic(controller); + state.addPosCache(unloadedPos, new TraceabilityPredicate()); + helper.assertTrue(savedData.tryAddMapping(state), "The current controller mapping was not installed"); + var workable = (WorkableMultiblockMachine) controller; + workable.getWorkLogic().updateTickSubscription(); + helper.assertTrue(hasWorkSubscription(workable), + "The work subscription was not active before the cached change"); + state.onBlockStateChanged(unloadedPos, Blocks.AIR.defaultBlockState()); + + helper.assertTrue(state.getPos().equals(lastCheckedPos), + "The block-change callback read the unloaded cached position"); + helper.assertTrue(state.error == previousError, + "The loaded block-change callback synthesized a chunk-unload pattern error"); + helper.assertTrue(controller.isStructureFormedSnapshot(), + "The block-change callback discarded the controller's formation snapshot before its queued check"); + helper.assertFalse(controller.isFormed(), + "The formed facade remained available before the queued check completed"); + helper.assertFalse(workable.isWorkLogicAvailable(), + "Recipe logic remained available while the structure awaited validation"); + helper.assertFalse(hasWorkSubscription(workable), + "The cached change did not immediately cancel the existing work subscription"); + assertMappingRemovedAndCheckPending(helper, controller, savedData, "block-change callback"); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40) + public static void chunkUnloadPausesMappedStructuresWithoutReadingTheChunk(GameTestHelper helper) { + var controller = getController(helper); + var state = controller.getMultiblockState(); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + BlockPos unloadedPos = findUnloadedPos(helper, controller.getPos()); + ChunkPos unloadedChunk = new ChunkPos(unloadedPos); + + savedData.removeAsyncLogic(controller); + state.addPosCache(unloadedPos, new TraceabilityPredicate()); + helper.assertTrue(savedData.tryAddMapping(state), "The mapped structure was not installed before chunk unload"); + savedData.onChunkUnload(unloadedChunk); + + helper.assertTrue(state.error == MultiblockState.UNLOAD_ERROR, + "Chunk unload did not preserve the temporary unload reason"); + helper.assertTrue(controller.isStructureFormedSnapshot(), + "Chunk unload invalidated the persisted formed state"); + helper.assertFalse(controller.isFormed(), + "Chunk unload left the formed facade available before revalidation"); + assertMappingRemovedAndCheckPending(helper, controller, savedData, "chunk-unload callback"); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40) + public static void queuedFullRecheckUnloadKeepsControllerFormedAndQueued(GameTestHelper helper) { + var controller = getController(helper); + var state = controller.getMultiblockState(); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + BlockPos unloadedPos = findUnloadedPos(helper, controller.getPos()); + var definition = (MultiblockMachineDefinition) controller.getDefinition(); + Supplier originalPatternFactory = definition.getPatternFactory(); + helper.assertTrue(controller.isFormed(), "The intact multiblock was not formed before the queued recheck"); + var confirmedCache = new LongOpenHashSet(state.getCache()); + helper.assertFalse(confirmedCache.isEmpty(), + "The confirmed-cache regression requires an existing successful structure cache"); + + savedData.removeAsyncLogic(controller); + savedData.removeMapping(state); + state.setError(null); + definition.setPatternFactory(() -> new UnloadedPattern(unloadedPos)); + try { + savedData.addAsyncLogic(controller); + // Exercise this controller directly while the global definition is replaced. Ticking the level-wide queue + // here would let the temporary pattern factory inspect other GameTests' controllers in the same batch. + controller.asyncCheckPattern(savedData.getPeriodID()); + } finally { + definition.setPatternFactory(originalPatternFactory); + } + + helper.assertTrue(state.error == MultiblockState.UNLOAD_ERROR, + "The queued full recheck did not encounter the unloaded structure position"); + helper.assertTrue(controller.isStructureFormedSnapshot(), + "The queued full recheck invalidated the controller while a structure chunk was unloaded"); + helper.assertFalse(controller.isFormed(), + "An unloaded queued check left the formed facade available"); + helper.assertTrue(state.getCache().equals(confirmedCache), + "An incomplete scan replaced the last confirmed structure cache with its partial attempt"); + assertMappingRemovedAndCheckPending(helper, controller, savedData, "queued full recheck"); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40) + public static void unloadedCandidateCannotBeOverwrittenByLaterMismatch(GameTestHelper helper) { + var controller = getController(helper); + var state = controller.getMultiblockState(); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + var definition = (MultiblockMachineDefinition) controller.getDefinition(); + Supplier originalPatternFactory = definition.getPatternFactory(); + helper.assertTrue(controller.allowFlip(), + "The candidate-order regression test requires a flippable controller"); + helper.assertTrue(controller.isFormed(), "The intact multiblock was not formed before the candidate check"); + + savedData.removeAsyncLogic(controller); + savedData.removeMapping(state); + state.setError(null); + definition.setPatternFactory(UnloadedThenMismatchPattern::new); + try { + savedData.addAsyncLogic(controller); + controller.asyncCheckPattern(savedData.getPeriodID()); + } finally { + definition.setPatternFactory(originalPatternFactory); + } + + helper.assertTrue(state.error == MultiblockState.UNLOAD_ERROR, + "A later flipped mismatch overwrote an earlier unloaded-chunk result"); + helper.assertTrue(controller.isStructureFormedSnapshot(), + "Candidate fallback invalidated the controller despite an uninspectable orientation"); + helper.assertFalse(controller.isFormed(), + "An uninspectable candidate left the formed facade available"); + assertMappingRemovedAndCheckPending(helper, controller, savedData, "candidate fallback"); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40) + public static void persistedFlippedCandidateUnloadDoesNotSwitchOrientation(GameTestHelper helper) { + var controller = getController(helper); + var state = controller.getMultiblockState(); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + var definition = (MultiblockMachineDefinition) controller.getDefinition(); + Supplier originalPatternFactory = definition.getPatternFactory(); + var oppositeFlipChecks = new AtomicInteger(); + helper.assertTrue(controller.allowFlip(), + "The persisted-flip regression test requires a flippable controller"); + helper.assertTrue(controller.isFormed(), "The intact multiblock was not formed before the flip check"); + + controller.setFlipped(true); + savedData.removeAsyncLogic(controller); + savedData.removeMapping(state); + state.setError(null); + definition.setPatternFactory(() -> new PersistedFlipUnloadPattern(oppositeFlipChecks)); + try { + savedData.addAsyncLogic(controller); + controller.asyncCheckPattern(savedData.getPeriodID()); + } finally { + definition.setPatternFactory(originalPatternFactory); + } + + helper.assertTrue(oppositeFlipChecks.get() == 0, + "An unavailable persisted flipped structure fell back to the opposite orientation"); + helper.assertTrue(state.error == MultiblockState.UNLOAD_ERROR, + "The unavailable persisted flipped orientation was not retained as a temporary unload"); + helper.assertTrue(controller.isStructureFormedSnapshot(), + "The unavailable persisted flipped orientation discarded the formation snapshot"); + helper.assertTrue(controller.isStructureFlippedSnapshot(), + "The unavailable persisted flipped orientation discarded its owned flip state"); + assertMappingRemovedAndCheckPending(helper, controller, savedData, "persisted flip"); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40) + public static void unloadedOldPatternCannotSwitchToLoadedFallback(GameTestHelper helper) { + var controller = getController(helper); + var state = controller.getMultiblockState(); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + BlockPos unloadedPos = findUnloadedPos(helper, controller.getPos()); + var definition = (MultiblockMachineDefinition) controller.getDefinition(); + Supplier originalPatternFactory = definition.getPatternFactory(); + var fallbackChecks = new AtomicInteger(); + helper.assertTrue(controller.isFormed(), "The intact multiblock was not formed before the fallback check"); + + savedData.removeAsyncLogic(controller); + savedData.removeMapping(state); + state.addPosCache(unloadedPos, new TraceabilityPredicate()); + helper.assertTrue(savedData.tryAddMapping(state), + "The augmented old pattern cache could not be confirmed for the preflight regression"); + savedData.removeMapping(state); + definition.setPatternFactory(() -> new SuccessfulFallbackPattern(fallbackChecks)); + try { + savedData.addAsyncLogic(controller); + controller.asyncCheckPattern(savedData.getPeriodID()); + } finally { + definition.setPatternFactory(originalPatternFactory); + } + + helper.assertTrue(fallbackChecks.get() == 0, + "The controller tried a new pattern while its previous structure cache was incomplete"); + helper.assertTrue(state.error == MultiblockState.UNLOAD_ERROR, + "An incomplete previous structure was not retained as a temporary unload"); + helper.assertTrue(controller.isStructureFormedSnapshot(), + "Trying a loaded fallback discarded the previous formation snapshot"); + assertMappingRemovedAndCheckPending(helper, controller, savedData, "old-pattern preflight"); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData") + public static void confirmedPatternCacheUsesCopyOnWriteAndConstantTimeRestore(GameTestHelper helper) { + var state = new MultiblockState(helper.getLevel(), helper.absolutePos(LCR_CONTROLLER_POS)); + BlockPos confirmedPos = helper.absolutePos(new BlockPos(2, 2, 0)); + BlockPos attemptedPos = helper.absolutePos(new BlockPos(3, 2, 0)); + + state.clean(); + state.addPosCache(confirmedPos, new TraceabilityPredicate()); + state.confirmPatternCache(); + assertCacheAliases(helper, state, true, "A confirmed cache retained a duplicate working copy"); + + state.addPosCache(attemptedPos, new TraceabilityPredicate()); + assertCacheAliases(helper, state, false, "Writing through addPosCache mutated the confirmed alias"); + helper.assertTrue(state.getPosPredicateMap().containsKey(attemptedPos), + "The detached working cache did not receive the attempted position"); + + state.restoreConfirmedPatternCache(); + assertCacheAliases(helper, state, true, "Restoring a failed attempt copied instead of aliasing the cache"); + helper.assertTrue(state.isPosInCache(confirmedPos), "Failed restoration lost the confirmed position"); + helper.assertFalse(state.getPosPredicateMap().containsKey(attemptedPos), + "Failed restoration retained an unconfirmed attempted position"); + + state.clean(); + state.addPosCache(attemptedPos, new TraceabilityPredicate()); + state.restoreConfirmedPatternCache(); + assertCacheAliases(helper, state, true, "Restoring a clean scan attempt did not reuse the confirmed cache"); + helper.assertFalse(state.getPosPredicateMap().containsKey(attemptedPos), + "A clean failed scan replaced the confirmed predicate cache"); + + state.getPredicateMap().put(attemptedPos.asLong(), new TraceabilityPredicate()); + assertCacheAliases(helper, state, false, "The mutable predicate-map getter exposed the confirmed alias"); + state.restoreConfirmedPatternCache(); + helper.assertFalse(state.getPosPredicateMap().containsKey(attemptedPos), + "Mutating the exposed predicate map corrupted the confirmed cache"); + + state.getCache().clear(); + assertCacheAliases(helper, state, false, "The mutable cache getter exposed the confirmed alias"); + state.restoreConfirmedPatternCache(); + helper.assertTrue(state.isPosInCache(confirmedPos), + "Mutating the exposed cache key set corrupted the confirmed cache"); + + state.clean(); + var retainedMap = state.getPredicateMap(); + BlockPos unloadedPos = findUnloadedPos(helper, confirmedPos); + retainedMap.put(unloadedPos.asLong(), new TraceabilityPredicate()); + state.confirmPatternCache(); + helper.assertTrue(state.hasUnloadedCachedPosition(), + "Committing a directly edited map omitted its unloaded chunk dependency"); + retainedMap.clear(); + helper.assertTrue(state.isPosInCache(unloadedPos), + "A mutable handle retained before commit corrupted the confirmed snapshot afterward"); + assertCacheAliases(helper, state, true, "Committing an exposed map retained two resident cache copies"); + + state.clean(); + state.addPosCache(confirmedPos, new TraceabilityPredicate()); + var retainedKeys = state.getCache(); + state.confirmPatternCache(); + retainedKeys.clear(); + helper.assertTrue(state.isPosInCache(confirmedPos), + "A key-set handle retained before commit corrupted the confirmed snapshot afterward"); + helper.succeed(); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40) + public static void persistedConfirmedChunksGuardFirstRestartValidation(GameTestHelper helper) { + var controller = getController(helper); + var state = controller.getMultiblockState(); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + var definition = (MultiblockMachineDefinition) controller.getDefinition(); + Supplier originalPatternFactory = definition.getPatternFactory(); + var fallbackChecks = new AtomicInteger(); + CompoundTag originalWorldTag = new CompoundTag(); + controller.saveCustomPersistedData(originalWorldTag, false); + long[] originalChunks = originalWorldTag.getLongArray("gtceuConfirmedStructureChunks"); + helper.assertTrue(originalChunks.length > 0, + "A successful structure mapping did not persist its confirmed chunk set"); + helper.assertTrue(Arrays.stream(originalChunks) + .anyMatch(chunk -> chunk == new ChunkPos(controller.getPos()).toLong()), + "The persisted mapping omitted the controller chunk"); + + CompoundTag legacyTag = originalWorldTag.copy(); + legacyTag.remove("gtceuConfirmedStructureChunks"); + controller.loadCustomPersistedData(legacyTag); + CompoundTag legacyRoundTrip = new CompoundTag(); + controller.saveCustomPersistedData(legacyRoundTrip, false); + helper.assertFalse(legacyRoundTrip.contains("gtceuConfirmedStructureChunks"), + "Loading an old save invented a confirmed chunk snapshot"); + + BlockPos unloadedPos = findUnloadedPos(helper, controller.getPos()); + long unloadedChunk = new ChunkPos(unloadedPos).toLong(); + long[] restartChunks = Arrays.copyOf(originalChunks, originalChunks.length + 2); + restartChunks[originalChunks.length] = unloadedChunk; + restartChunks[originalChunks.length + 1] = unloadedChunk; + CompoundTag restartTag = originalWorldTag.copy(); + restartTag.putLongArray("gtceuConfirmedStructureChunks", restartChunks); + controller.loadCustomPersistedData(restartTag); + CompoundTag restartRoundTrip = new CompoundTag(); + controller.saveCustomPersistedData(restartRoundTrip, false); + helper.assertTrue(Arrays.stream(restartRoundTrip.getLongArray("gtceuConfirmedStructureChunks")) + .filter(chunk -> chunk == unloadedChunk) + .count() == 1, + "The persisted chunk snapshot was not normalized during restart loading"); + + savedData.removeAsyncLogic(controller); + savedData.removeMapping(state); + state.setError(null); + definition.setPatternFactory(() -> new SuccessfulFallbackPattern(fallbackChecks)); + try { + savedData.addAsyncLogic(controller); + controller.asyncCheckPattern(savedData.getPeriodID()); + + helper.assertTrue(fallbackChecks.get() == 0, + "The first restart validation scanned a fallback while an old structure chunk was unavailable"); + helper.assertTrue(state.error == MultiblockState.UNLOAD_ERROR, + "The persisted restart preflight did not report a temporary unload"); + helper.assertTrue(controller.isStructureFormedSnapshot(), + "The persisted restart preflight discarded the formation snapshot"); + helper.assertTrue(controller.isStructureRevalidationPending(), + "The persisted restart preflight did not keep the controller pending"); + } finally { + definition.setPatternFactory(originalPatternFactory); + controller.loadCustomPersistedData(originalWorldTag); + savedData.removeAsyncLogic(controller); + } + helper.succeed(); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData") + public static void positionsOutsideBuildHeightAreDefinitiveFailures(GameTestHelper helper) { + var level = helper.getLevel(); + var state = new MultiblockState(level, helper.absolutePos(LCR_CONTROLLER_POS)); + var predicate = new TraceabilityPredicate(); + BlockPos belowWorld = new BlockPos(0, level.getMinBuildHeight() - 1, 0); + BlockPos aboveWorld = new BlockPos(0, level.getMaxBuildHeight(), 0); + + helper.assertFalse(state.update(belowWorld, predicate), + "A position below the build height was accepted by the pattern state"); + helper.assertTrue(state.error == MultiblockState.UNINIT_ERROR, + "A position below the build height was mistaken for a temporarily unloaded chunk"); + helper.assertFalse(state.update(aboveWorld, predicate), + "A position above the build height was accepted by the pattern state"); + helper.assertTrue(state.error == MultiblockState.UNINIT_ERROR, + "A position above the build height was mistaken for a temporarily unloaded chunk"); + helper.succeed(); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40) + public static void successfulReformationUnlinksPartsMissingFromNewPattern(GameTestHelper helper) { + var controller = getController(helper); + var removals = new AtomicInteger(); + IMultiPart stalePart = removalTrackingPart(removals); + controller.getParts().add(stalePart); + + controller.onStructureFormed(); + + helper.assertTrue(removals.get() == 1, + "A part missing from the refreshed pattern was not definitively unlinked"); + helper.assertTrue(controller.getParts().stream().noneMatch(part -> part == stalePart), + "A part missing from the refreshed pattern remained in the controller's part list"); + helper.succeed(); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40) + public static void activeRegistryIdentityRepairsLaggingControllerNbt(GameTestHelper helper) { + var controller = getController(helper); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + long activeInstance = savedData.ensureControllerActive(controller); + helper.assertTrue(activeInstance > 0, "The loaded controller did not receive a persistent instance id"); + + controller.setStructureInstanceId(0); + long adoptedInstance = savedData.ensureControllerActive(controller); + + helper.assertTrue(adoptedInstance == activeInstance, + "A lagging controller chunk replaced its still-active registry identity"); + helper.assertTrue(controller.getStructureInstanceId() == activeInstance, + "The active registry identity was not written back to the lagging controller"); + helper.succeed(); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40) + public static void retiredAndOlderControllerIdentitiesCannotBeReactivated(GameTestHelper helper) { + var controller = getController(helper); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + BlockPos controllerPos = controller.getPos(); + long originalInstance = savedData.ensureControllerActive(controller); + savedData.retireController(controller); + controller.setStructureInstanceId(0); + + long replacementInstance = savedData.ensureControllerActive(controller); + helper.assertTrue(replacementInstance > originalInstance, + "A controller without item identity reactivated a tombstoned incarnation"); + helper.assertTrue(savedData.isControllerBindingRetired(controllerPos, originalInstance), + "The replaced controller incarnation was not reported retired"); + helper.assertTrue(savedData.getControllerBindingStatus(controllerPos, replacementInstance + 1) == + MultiblockWorldSavedData.ControllerBindingStatus.UNKNOWN, + "A registry identity newer than the saved world record was treated as retired"); + + controller.setStructureInstanceId(originalInstance); + long afterStaleChunk = savedData.ensureControllerActive(controller); + helper.assertTrue(afterStaleChunk > replacementInstance, + "An older controller chunk reactivated an already replaced incarnation"); + controller.onStructureFormed(); + helper.succeed(); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40) + public static void newerCrashTombstoneAdvancesTheOwnerRegistry(GameTestHelper helper) { + var controller = getController(helper); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + long activeInstance = savedData.ensureControllerActive(controller); + long newerRemovedInstance = activeInstance + 2; + + savedData.retireControllerBinding(controller.getPos(), newerRemovedInstance); + + helper.assertTrue(savedData.getControllerBindingStatus(controller.getPos(), newerRemovedInstance) == + MultiblockWorldSavedData.ControllerBindingStatus.RETIRED, + "A controller epoch newer than crash-lagging SavedData lost its tombstone"); + helper.assertTrue(savedData.getControllerBindingStatus(controller.getPos(), activeInstance) == + MultiblockWorldSavedData.ControllerBindingStatus.RETIRED, + "Advancing to a newer tombstone did not retire the older registry epoch"); + controller.setStructureInstanceId(0); + long replacementInstance = savedData.ensureControllerActive(controller); + helper.assertTrue(replacementInstance > newerRemovedInstance, + "A replacement controller reused an identity at or below a newer crash tombstone"); + controller.onStructureFormed(); + helper.succeed(); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40) + public static void definitiveInvalidationAdvancesOwnershipEpochOnReformation(GameTestHelper helper) { + var controller = getController(helper); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + long oldEpoch = savedData.ensureControllerActive(controller); + var offlinePart = getPersistentPart(controller); + savedData.removeAsyncLogic(controller); + + offlinePart.unloadedFromController(controller); + controller.getParts().removeIf(candidate -> candidate == offlinePart); + controller.onStructureInvalid(); + + helper.assertTrue(savedData.getControllerBindingStatus(controller.getPos(), oldEpoch) == + MultiblockWorldSavedData.ControllerBindingStatus.RETIRED, + "A definitive structure invalidation did not retire its ownership epoch"); + helper.assertTrue(offlinePart.getControllerBindingInstanceId(controller.getPos()) == oldEpoch, + "Invalidation could not preserve the offline part's old claim for tombstone reconciliation"); + + controller.onStructureFormed(); + long newEpoch = controller.getStructureInstanceId(); + helper.assertTrue(newEpoch > oldEpoch, + "Reforming a definitively invalidated structure reused its retired ownership epoch"); + helper.assertTrue(savedData.getControllerBindingStatus(controller.getPos(), oldEpoch) == + MultiblockWorldSavedData.ControllerBindingStatus.RETIRED, + "Activating the new formation epoch revived the old epoch"); + helper.assertTrue(offlinePart.getControllerBindingInstanceId(controller.getPos()) == newEpoch, + "The reformed structure did not migrate its offline part to the new ownership epoch"); + helper.succeed(); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40) + public static void formedControllerLoadRepairsATombstoneFirstSave(GameTestHelper helper) { + var controller = getController(helper); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + long staleFormedEpoch = savedData.ensureControllerActive(controller); + helper.assertTrue(controller.isStructureFormedSnapshot(), + "The tombstone-first regression requires persisted formed state"); + savedData.retireController(controller); + + controller.onLoad(); + + long repairedEpoch = controller.getStructureInstanceId(); + helper.assertTrue(repairedEpoch > staleFormedEpoch, + "Loading a persisted formation reactivated its tombstoned ownership epoch"); + helper.assertTrue(savedData.getControllerBindingStatus(controller.getPos(), repairedEpoch) == + MultiblockWorldSavedData.ControllerBindingStatus.ACTIVE, + "Loading a persisted formation did not publish its repaired ownership epoch"); + helper.assertTrue(savedData.getControllerBindingStatus(controller.getPos(), staleFormedEpoch) == + MultiblockWorldSavedData.ControllerBindingStatus.RETIRED, + "Repairing a tombstone-first save revived the stale formed epoch"); + savedData.removeAsyncLogic(controller); + helper.succeed(); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40) + public static void unformedControllerLoadKeepsNbtFirstEpochRetired(GameTestHelper helper) { + var controller = getController(helper); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + savedData.ensureControllerActive(controller); + controller.onStructureInvalid(); + long nbtFirstEpoch = savedData.ensureControllerActive(controller); + helper.assertFalse(controller.isStructureFormedSnapshot(), + "The NBT-first regression requires an unformed controller snapshot"); + helper.assertTrue(savedData.getControllerBindingStatus(controller.getPos(), nbtFirstEpoch) == + MultiblockWorldSavedData.ControllerBindingStatus.ACTIVE, + "The NBT-first regression did not create the simulated premature ACTIVE epoch"); + + controller.onLoad(); + + helper.assertTrue(savedData.getControllerBindingStatus(controller.getPos(), nbtFirstEpoch) == + MultiblockWorldSavedData.ControllerBindingStatus.RETIRED, + "Loading unformed controller NBT published an empty ACTIVE ownership epoch"); + controller.onStructureFormed(); + long formedEpoch = controller.getStructureInstanceId(); + helper.assertTrue(formedEpoch > nbtFirstEpoch, + "The next successful formation reused the unformed NBT epoch"); + helper.assertTrue(savedData.getControllerBindingStatus(controller.getPos(), formedEpoch) == + MultiblockWorldSavedData.ControllerBindingStatus.ACTIVE, + "The successful formation did not publish its new ownership epoch"); + savedData.removeAsyncLogic(controller); + helper.succeed(); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40) + public static void unformedZeroNbtTombstonesActiveSavedData(GameTestHelper helper) { + var controller = getController(helper); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + savedData.ensureControllerActive(controller); + controller.onStructureInvalid(); + long savedDataFirstEpoch = savedData.ensureControllerActive(controller); + controller.setStructureInstanceId(0); + helper.assertTrue(savedData.getControllerBindingStatus(controller.getPos(), savedDataFirstEpoch) == + MultiblockWorldSavedData.ControllerBindingStatus.ACTIVE, + "The rollback regression did not create ACTIVE SavedData ahead of controller NBT"); + + controller.onLoad(); + + helper.assertTrue(controller.getStructureInstanceId() == 0, + "Loading an unformed controller adopted an ACTIVE epoch missing from its NBT"); + helper.assertTrue(savedData.getControllerBindingStatus(controller.getPos(), savedDataFirstEpoch) == + MultiblockWorldSavedData.ControllerBindingStatus.RETIRED, + "Unformed zero-id NBT did not tombstone the higher ACTIVE SavedData epoch"); + controller.onStructureFormed(); + helper.assertTrue(controller.getStructureInstanceId() > savedDataFirstEpoch, + "A successful formation did not advance beyond the rolled-back ACTIVE epoch"); + savedData.removeAsyncLogic(controller); + helper.succeed(); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40) + public static void controllerAndPartOwnerIdentitiesAreNotSavedIntoDrops(GameTestHelper helper) { + var controller = getController(helper); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + long controllerInstance = savedData.ensureControllerActive(controller); + var part = (MultiblockPartMachine) controller.getParts().stream() + .filter(MultiblockPartMachine.class::isInstance) + .findFirst() + .orElseThrow(); + helper.assertTrue(part.getControllerBindingInstanceId(controller.getPos()) == controllerInstance, + "A formed part did not persist the exact controller incarnation"); + + part.unloadedFromController(controller); + helper.assertTrue(part.isFormed(), + "A transient runtime unlink made an owned exclusive part available to another controller"); + helper.assertTrue(part.hasController(controller), + "A transient runtime unlink lost the exact persisted owner pair"); + + CompoundTag controllerWorldTag = new CompoundTag(); + CompoundTag controllerDropTag = new CompoundTag(); + CompoundTag partWorldTag = new CompoundTag(); + CompoundTag partDropTag = new CompoundTag(); + controller.saveCustomPersistedData(controllerWorldTag, false); + controller.saveCustomPersistedData(controllerDropTag, true); + part.saveCustomPersistedData(partWorldTag, false); + part.saveCustomPersistedData(partDropTag, true); + + helper.assertTrue(controllerWorldTag.getLong("gtceuStructureInstanceId") == controllerInstance, + "A world save omitted the controller incarnation"); + helper.assertFalse(controllerDropTag.contains("gtceuStructureInstanceId"), + "A controller item inherited the old physical incarnation"); + helper.assertTrue(controllerWorldTag.contains("gtceuConfirmedStructureChunks"), + "A world save omitted the controller's confirmed structure chunks"); + helper.assertFalse(controllerDropTag.contains("gtceuConfirmedStructureChunks"), + "A controller item inherited the old structure geometry"); + helper.assertTrue(partWorldTag.contains("gtceuMultiblockControllerBindings"), + "A world save omitted the part owner pair"); + helper.assertFalse(partDropTag.contains("gtceuMultiblockControllerBindings"), + "A part item inherited a structure owner pair"); + + part.addedToController(controller); + helper.succeed(); + } + + @GameTest(template = "lcr_input_separation", + batch = "MultiblockWorldSavedData", + setupTicks = 40, + timeoutTicks = 40) + public static void retiredControllerBindingReleasesAStillLoadedPart(GameTestHelper helper) { + var controller = getController(helper); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + long controllerInstance = savedData.ensureControllerActive(controller); + var part = getPersistentPart(controller); + savedData.removeAsyncLogic(controller); + helper.assertTrue(part.getControllerBindingInstanceId(controller.getPos()) == controllerInstance, + "The retirement regression requires an exact persisted owner pair"); + + // This is the part-side state after its controller unloads while the part chunk remains loaded. Destruction of + // the controller later supplies the positive tombstone; no owner chunk lookup is needed to release the part. + part.unloadedFromController(controller); + savedData.retireController(controller); + + helper.succeedWhen(() -> { + helper.assertTrue(part.getControllerBindingInstanceId(controller.getPos()) == 0, + "A controller tombstone did not release its still-loaded part on the periodic reconciliation"); + helper.assertFalse(part.isFormed(), + "A part stayed exclusively locked after its exact controller incarnation was retired"); + }); + } + + @GameTest(template = "lcr_input_separation", + batch = "MultiblockWorldSavedData", + setupTicks = 40, + timeoutTicks = 40) + public static void loadedPositionWithoutExactControllerReleasesActiveClaim(GameTestHelper helper) { + var controller = getController(helper); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + long claimedInstance = savedData.ensureControllerActive(controller); + var part = getPersistentPart(controller); + savedData.removeAsyncLogic(controller); + + part.unloadedFromController(controller); + controller.setStructureInstanceId(claimedInstance + 1); + helper.assertTrue(savedData.getControllerBindingStatus(controller.getPos(), claimedInstance) == + MultiblockWorldSavedData.ControllerBindingStatus.ACTIVE, + "The crash-order regression requires a stale ACTIVE registry entry"); + helper.assertTrue(savedData.isControllerPositionLoadedNoChunkRequest(controller.getPos()), + "The controller position was not available for a non-loading absence check"); + + helper.succeedWhen(() -> { + helper.assertTrue(part.getControllerBindingInstanceId(controller.getPos()) == 0, + "A loaded position holding a different controller generation kept an ACTIVE claim forever"); + helper.assertFalse(part.isFormed(), + "A part stayed exclusive after its claimed controller generation was positively absent"); + }); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40) + public static void firstBindingReconciliationRunsImmediatelyAfterTheOnLoadGraceTick(GameTestHelper helper) { + var controller = getController(helper); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + long claimedInstance = savedData.ensureControllerActive(controller); + var part = getPersistentPart(controller); + savedData.removeAsyncLogic(controller); + + part.unloadedFromController(controller); + controller.setStructureInstanceId(claimedInstance + 1); + resetBindingReconciliationDelay(part); + + tickControllerBindings(part); + helper.assertTrue(part.getControllerBindingInstanceId(controller.getPos()) == claimedInstance, + "The first reconciliation tick did not preserve the same-chunk block-entity load grace period"); + tickControllerBindings(part); + helper.assertTrue(part.getControllerBindingInstanceId(controller.getPos()) == 0, + "The second reconciliation tick waited for the 20-tick cadence before releasing an absent owner"); + + controller.setStructureInstanceId(claimedInstance); + part.addedToController(controller); + helper.succeed(); + } + + @GameTest(template = "lcr_input_separation", + batch = "MultiblockWorldSavedData", + setupTicks = 40, + timeoutTicks = 40) + public static void loadedPositionWithoutExactControllerReleasesUnknownNewerClaim(GameTestHelper helper) { + var controller = getController(helper); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + long registryInstance = savedData.ensureControllerActive(controller); + long newerClaim = registryInstance + 1; + var part = getPersistentPart(controller); + savedData.removeAsyncLogic(controller); + + part.unloadedFromController(controller); + CompoundTag persistedPart = new CompoundTag(); + ListTag bindings = new ListTag(); + CompoundTag binding = new CompoundTag(); + binding.putLong("pos", controller.getPos().asLong()); + binding.putLong("instanceId", newerClaim); + bindings.add(binding); + persistedPart.put("gtceuMultiblockControllerBindings", bindings); + part.loadCustomPersistedData(persistedPart); + controller.setStructureInstanceId(newerClaim + 1); + + helper.assertTrue(savedData.getControllerBindingStatus(controller.getPos(), newerClaim) == + MultiblockWorldSavedData.ControllerBindingStatus.UNKNOWN, + "A claim newer than the crash-lagging registry was not kept UNKNOWN"); + helper.assertTrue(savedData.isControllerPositionLoadedNoChunkRequest(controller.getPos()), + "The controller position was not entity-ticking for a positive absence check"); + + helper.succeedWhen(() -> { + helper.assertTrue(part.getControllerBindingInstanceId(controller.getPos()) == 0, + "An UNKNOWN newer claim survived even though its exact owner was positively absent"); + helper.assertFalse(part.isFormed(), + "An UNKNOWN orphan kept a part exclusive after the owner position was fully ticking"); + }); + } + + @GameTest(template = "lcr_input_separation", + batch = "MultiblockWorldSavedData", + setupTicks = 40, + timeoutTicks = 40) + public static void activeButDefinitivelyInvalidControllerReleasesAnOfflinePart(GameTestHelper helper) { + var controller = getController(helper); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + long controllerInstance = savedData.ensureControllerActive(controller); + var part = getPersistentPart(controller); + savedData.removeAsyncLogic(controller); + + part.unloadedFromController(controller); + controller.getParts().removeIf(candidate -> candidate == part); + controller.onStructureInvalid(); + helper.assertTrue(savedData.getControllerBindingStatus(controller.getPos(), controllerInstance) == + MultiblockWorldSavedData.ControllerBindingStatus.RETIRED, + "A definitive invalidation left the controller ownership epoch active"); + + helper.succeedWhen(() -> { + helper.assertTrue(part.getControllerBindingInstanceId(controller.getPos()) == 0, + "A loaded, definitively unformed controller kept an offline part locked"); + helper.assertFalse(part.isFormed(), + "A part stayed exclusive after its loaded owner completed an invalid structure check"); + }); + } + + @GameTest(template = "lcr_input_separation", + batch = "MultiblockWorldSavedData", + setupTicks = 40, + timeoutTicks = 40) + public static void operationalControllerCannotKeepAnUnattachedPartClaim(GameTestHelper helper) { + var controller = getController(helper); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + savedData.ensureControllerActive(controller); + var part = getPersistentPart(controller); + savedData.removeAsyncLogic(controller); + + part.unloadedFromController(controller); + controller.getParts().removeIf(candidate -> candidate == part); + helper.assertTrue(controller.isStructureOperational(), + "The membership regression requires a fully validated controller"); + + helper.succeedWhen(() -> { + helper.assertTrue(part.getControllerBindingInstanceId(controller.getPos()) == 0, + "An operational controller retained a part absent from its runtime identity membership"); + helper.assertFalse(part.isFormed(), + "A part stayed exclusive after its operational owner stopped attaching it"); + }); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40) + public static void definitiveMismatchInvalidatesButKeepsRetryQueued(GameTestHelper helper) { + var controller = getController(helper); + var state = controller.getMultiblockState(); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + var definition = (MultiblockMachineDefinition) controller.getDefinition(); + Supplier originalPatternFactory = definition.getPatternFactory(); + helper.assertTrue(controller.isFormed(), "The intact multiblock was not formed before the mismatch check"); + CompoundTag formedTag = new CompoundTag(); + controller.saveCustomPersistedData(formedTag, false); + helper.assertTrue(formedTag.contains("gtceuConfirmedStructureChunks"), + "The invalidation regression requires a persisted confirmed chunk snapshot"); + + savedData.removeAsyncLogic(controller); + savedData.removeMapping(state); + state.setError(null); + definition.setPatternFactory(DefinitiveMismatchPattern::new); + try { + savedData.addAsyncLogic(controller); + controller.asyncCheckPattern(savedData.getPeriodID()); + } finally { + definition.setPatternFactory(originalPatternFactory); + } + + helper.assertFalse(controller.isStructureFormedSnapshot(), + "A definitive mismatch retained the previous formation snapshot"); + helper.assertFalse(controller.isFormed(), "A definitive mismatch left the formed facade available"); + helper.assertTrue(state.error != MultiblockState.UNLOAD_ERROR, + "A definitive mismatch was misclassified as a temporary unload"); + helper.assertFalse(savedData.mapping.containsKey(controller.getPos()), + "The definitive mismatch left an unverified structure mapping installed"); + helper.assertTrue(savedData.isStructureCheckPending(controller), + "The definitive mismatch did not leave the controller queued for a retry"); + helper.assertFalse(controller.isStructureRevalidationPending(), + "A definitive mismatch remained marked as transiently awaiting revalidation"); + CompoundTag invalidTag = new CompoundTag(); + controller.saveCustomPersistedData(invalidTag, false); + helper.assertFalse(invalidTag.contains("gtceuConfirmedStructureChunks"), + "A definitive structure invalidation retained the old confirmed chunk snapshot"); + savedData.removeAsyncLogic(controller); + helper.succeed(); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData") + public static void staleMappingCannotRemoveItsReplacement(GameTestHelper helper) { + var controller = getController(helper); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + BlockPos controllerPos = controller.getPos(); + var stale = new MultiblockState(helper.getLevel(), controllerPos); + var current = controller.getMultiblockState(); + stale.clean(); + current.clean(); + BlockPos indexedPos = helper.absolutePos(new BlockPos(48, 2, 0)); + current.addPosCache(indexedPos, new TraceabilityPredicate()); + + helper.assertTrue(savedData.tryAddMapping(current), "The current controller mapping was not installed"); + helper.assertFalse(savedData.tryAddMapping(stale), "A stale controller mapping was accepted"); + savedData.removeMapping(stale); + + helper.assertTrue(savedData.mapping.get(controllerPos) == current, + "A stale state replaced or removed the current controller's mapping"); + helper.assertTrue(savedData.getControllersInChunk(new ChunkPos(indexedPos)).contains(current), + "Removing a stale state also removed the current state's chunk index"); + savedData.removeMapping(current); + helper.succeed(); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData") + public static void remappingRefreshesDynamicChunkIndexes(GameTestHelper helper) { + var controller = getController(helper); + var state = controller.getMultiblockState(); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + BlockPos oldPos = helper.absolutePos(new BlockPos(48, 2, 0)); + BlockPos newPos = helper.absolutePos(new BlockPos(80, 2, 0)); + ChunkPos oldChunk = new ChunkPos(oldPos); + ChunkPos newChunk = new ChunkPos(newPos); + + state.clean(); + state.addPosCache(oldPos, new TraceabilityPredicate()); + helper.assertTrue(savedData.tryAddMapping(state), "The original controller mapping was not installed"); + state.clean(); + state.addPosCache(newPos, new TraceabilityPredicate()); + helper.assertTrue(savedData.tryAddMapping(state), "The refreshed controller mapping was not installed"); + + helper.assertFalse(savedData.getControllersInChunk(oldChunk).contains(state), + "Refreshing a dynamic pattern left its old chunk index behind"); + helper.assertTrue(savedData.getControllersInChunk(newChunk).contains(state), + "Refreshing a dynamic pattern did not install its new chunk index"); + savedData.removeMapping(state); + helper.succeed(); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData") + public static void cancelledQueueTokensRemainBoundedWithAnActiveSentinel(GameTestHelper helper) { + var controller = getController(helper); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + int baselinePending = savedData.getPendingStructureCheckCount(); + IMultiController sentinel = countingController(controller, new AtomicInteger(), + Collections.newSetFromMap(new IdentityHashMap<>())); + IMultiController churn = countingController(controller, new AtomicInteger(), + Collections.newSetFromMap(new IdentityHashMap<>())); + savedData.addAsyncLogic(sentinel); + + for (int i = 0; i < 256; i++) { + savedData.addAsyncLogic(churn); + savedData.removeAsyncLogic(churn); + } + + helper.assertTrue(savedData.getPendingStructureCheckCount() == baselinePending + 1, + "Cancelled controllers leaked into the active pending set"); + helper.assertTrue(savedData.getQueuedStructureCheckTokenCount() <= + Math.max(64, savedData.getPendingStructureCheckCount() * 2), + "Cancelled controller tokens grew the queue beyond its rebuild bound"); + savedData.removeAsyncLogic(sentinel); + helper.succeed(); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData") + public static void queueCompactionDoesNotDuplicateInFlightToken(GameTestHelper helper) { + var controller = getController(helper); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + var actorCalls = new AtomicInteger(); + IMultiController churn = countingController(controller, new AtomicInteger(), + Collections.newSetFromMap(new IdentityHashMap<>())); + IMultiController actor = callbackController(controller, actorCalls, () -> { + // Force removeAsyncLogic() to rebuild the ordered queue while actor's token is out but still active. + // Copying that in-flight token here would make tick() append the same mutable token twice. + for (int i = 0; i < 128; i++) { + savedData.addAsyncLogic(churn); + savedData.removeAsyncLogic(churn); + } + }); + savedData.addAsyncLogic(actor); + + for (int tick = 0; tick < 128 && actorCalls.get() == 0; tick++) { + savedData.tick(); + } + + helper.assertTrue(actorCalls.get() == 1, "The in-flight controller was not checked exactly once"); + helper.assertTrue(savedData.getQueuedStructureCheckTokenCount(actor) == 1, + "Heap compaction duplicated the in-flight controller token"); + savedData.removeAsyncLogic(actor); + savedData.removeAsyncLogic(churn); + helper.succeed(); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockScheduler", setupTicks = 40) + public static void invalidRetriesBackOffAndBlockChangesWakeImmediately(GameTestHelper helper) { + var machine = getController(helper); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + var calls = new AtomicInteger(); + var state = new MultiblockState(helper.getLevel(), machine.getPos()); + IMultiController candidate = statefulCallbackController(machine, state, calls, + () -> state.setError(MultiblockState.UNINIT_ERROR)); + savedData.addAsyncLogic(candidate); + + tickUntilCalls(helper, savedData, calls, 1, 64); + helper.assertTrue(savedData.getStructureCheckInvalidRetryCount(candidate) == 1, + "The first definitive mismatch did not enter exponential backoff"); + helper.assertTrue(savedData.getStructureCheckDelay(candidate) == 20, + "The first definitive mismatch did not use the 20-tick base delay"); + + tickUntilCalls(helper, savedData, calls, 2, 64); + helper.assertTrue(savedData.getStructureCheckInvalidRetryCount(candidate) == 2, + "A repeated definitive mismatch did not advance the backoff exponent"); + helper.assertTrue(savedData.getStructureCheckDelay(candidate) == 40, + "The second definitive mismatch did not double the retry delay"); + + savedData.onBlockChanged(machine.getPos()); + helper.assertTrue(savedData.getStructureCheckInvalidRetryCount(candidate) == 0, + "A relevant block change did not reset the mismatch backoff"); + helper.assertTrue(savedData.getStructureCheckDelay(candidate) == 1, + "A relevant block change did not expedite validation to the next scheduler tick"); + tickUntilCalls(helper, savedData, calls, 3, 64); + helper.assertTrue(savedData.getStructureCheckDelay(candidate) == 20, + "The retry after a block-change wake did not restart at the base delay"); + + savedData.removeAsyncLogic(candidate); + helper.succeed(); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockScheduler", setupTicks = 40) + public static void unloadedChecksWaitOnTheirConfirmedChunksWithoutScanning(GameTestHelper helper) { + var machine = getController(helper); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + var calls = new AtomicInteger(); + var state = new MultiblockState(helper.getLevel(), machine.getPos()); + long missingChunk = new ChunkPos(findUnloadedPos(helper, machine.getPos())).toLong(); + IMultiController candidate = statefulCallbackController(machine, state, calls, new long[] { missingChunk }, + () -> state.setError(MultiblockState.UNLOAD_ERROR)); + savedData.addAsyncLogic(candidate); + + tickUntilCalls(helper, savedData, calls, 1, 64); + helper.assertTrue(savedData.isStructureCheckParked(candidate), + "An unloaded confirmed structure was not parked after its first scan"); + for (int tick = 0; tick < 80; tick++) { + savedData.tick(); + } + helper.assertTrue(calls.get() == 1, + "Cheap unloaded-chunk readiness probes unexpectedly ran another full pattern scan"); + savedData.onChunkLoad(new ChunkPos(machine.getPos())); + helper.assertTrue(savedData.isStructureCheckParked(candidate), + "An unrelated chunk-load event woke a precisely parked structure"); + + savedData.removeAsyncLogic(candidate); + helper.succeed(); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockScheduler", setupTicks = 40) + public static void unloadWithoutChunkMetadataHasEventAndTimerFallbacks(GameTestHelper helper) { + var machine = getController(helper); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + var calls = new AtomicInteger(); + var state = new MultiblockState(helper.getLevel(), machine.getPos()); + IMultiController candidate = statefulCallbackController(machine, state, calls, + () -> state.setError(MultiblockState.UNLOAD_ERROR)); + savedData.addAsyncLogic(candidate); + + tickUntilCalls(helper, savedData, calls, 1, 64); + helper.assertTrue(savedData.isStructureCheckParked(candidate), + "A compatibility controller with an unloaded result was not parked"); + helper.assertTrue(savedData.getStructureCheckDelay(candidate) == 20, + "A parked compatibility controller did not retain a bounded readiness fallback"); + tickUntilCalls(helper, savedData, calls, 2, 64); + helper.assertTrue(savedData.isStructureCheckParked(candidate), + "The readiness fallback lost the parked state after another unloaded result"); + helper.assertTrue(savedData.getStructureCheckDelay(candidate) == 40, + "Repeated non-chunk UNLOAD results did not back off their full-scan fallback"); + + savedData.onChunkLoad(new ChunkPos(machine.getPos())); + helper.assertTrue(savedData.getStructureCheckDelay(candidate) == 2, + "A chunk-load event did not expedite a compatibility controller's fallback"); + tickUntilCalls(helper, savedData, calls, 3, 64); + + savedData.removeAsyncLogic(candidate); + helper.succeed(); + } + + @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData") + public static void queuedChecksAreBoundedAndEventuallyReachEveryController(GameTestHelper helper) { + var controller = getController(helper); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + var calls = new AtomicInteger(); + Set checked = Collections.newSetFromMap(new IdentityHashMap<>()); + List queued = new ArrayList<>(); + for (int i = 0; i < 12; i++) { + IMultiController candidate = countingController(controller, calls, checked); + queued.add(candidate); + savedData.addAsyncLogic(candidate); + } + + for (int tick = 0; tick < 128 && checked.size() < queued.size(); tick++) { + int before = calls.get(); + savedData.tick(); + helper.assertTrue(calls.get() - before <= 1, + "One level tick performed more than one full structure check"); + } + + helper.assertTrue(checked.size() == queued.size(), + "The bounded scheduler starved one or more queued controllers"); + queued.forEach(savedData::removeAsyncLogic); + helper.succeed(); + } + + private static void tickUntilCalls(GameTestHelper helper, MultiblockWorldSavedData savedData, + AtomicInteger calls, int expectedCalls, int maxTicks) { + for (int tick = 0; tick < maxTicks && calls.get() < expectedCalls; tick++) { + savedData.tick(); + } + helper.assertTrue(calls.get() == expectedCalls, + "The scheduler did not perform the expected number of full checks within its bounded test window"); + } + + private static MultiblockControllerMachine getController(GameTestHelper helper) { + return (MultiblockControllerMachine) ((IMachineBlockEntity) helper.getBlockEntity(LCR_CONTROLLER_POS)) + .getMetaMachine(); + } + + private static MultiblockPartMachine getPersistentPart(MultiblockControllerMachine controller) { + return (MultiblockPartMachine) controller.getParts().stream() + .filter(MultiblockPartMachine.class::isInstance) + .findFirst() + .orElseThrow(); + } + + private static BlockPos findUnloadedPos(GameTestHelper helper, BlockPos origin) { + for (int chunkOffset = 32; chunkOffset <= 2048; chunkOffset += 32) { + BlockPos candidate = origin.offset(chunkOffset * 16, 0, 0); + if (!helper.getLevel().isLoaded(candidate)) { + return candidate; + } + } + throw new IllegalStateException("Could not find an unloaded chunk for the multiblock regression test"); + } + + private static void assertMappingRemovedAndCheckPending(GameTestHelper helper, + MultiblockControllerMachine controller, + MultiblockWorldSavedData savedData, String action) { + helper.assertFalse(savedData.mapping.containsKey(controller.getPos()), + "The " + action + " left an unverified structure mapping installed"); + helper.assertTrue(savedData.isStructureCheckPending(controller), + "The " + action + " did not leave the controller queued for a retry"); + helper.assertTrue(controller.isStructureRevalidationPending(), + "The " + action + " did not mark the controller as awaiting revalidation"); + savedData.removeAsyncLogic(controller); + helper.succeed(); + } + + private static boolean hasWorkSubscription(WorkableMultiblockMachine controller) { + try { + Field subscription = WorkLogic.class.getDeclaredField("subscription"); + subscription.setAccessible(true); + return subscription.get(controller.getWorkLogic()) != null; + } catch (ReflectiveOperationException e) { + throw new AssertionError("Could not inspect the work subscription", e); + } + } + + private static void resetBindingReconciliationDelay(MultiblockPartMachine part) { + setBooleanField(part, "controllerBindingReconciliationArmed", false); + setBooleanField(part, "controllerBindingInitialReconciliationComplete", false); + } + + private static void assertCacheAliases(GameTestHelper helper, MultiblockState state, boolean expected, + String message) { + boolean aliases = getFieldValue(state, "predicateMap") == getFieldValue(state, "confirmedPredicateMap") && + getFieldValue(state, "cachedChunks") == getFieldValue(state, "confirmedCachedChunks"); + helper.assertTrue(aliases == expected, message); + } + + private static Object getFieldValue(MultiblockState state, String fieldName) { + try { + Field field = MultiblockState.class.getDeclaredField(fieldName); + field.setAccessible(true); + return field.get(state); + } catch (ReflectiveOperationException exception) { + throw new AssertionError("Could not inspect multiblock pattern cache", exception); + } + } + + private static void setBooleanField(MultiblockPartMachine part, String fieldName, boolean value) { + try { + Field field = MultiblockPartMachine.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.setBoolean(part, value); + } catch (ReflectiveOperationException exception) { + throw new AssertionError("Could not configure controller-binding reconciliation", exception); + } + } + + private static void tickControllerBindings(MultiblockPartMachine part) { + try { + var method = MultiblockPartMachine.class.getDeclaredMethod("tickControllerBindings"); + method.setAccessible(true); + method.invoke(part); + } catch (ReflectiveOperationException exception) { + throw new AssertionError("Could not invoke controller-binding reconciliation", exception); + } + } + + private static IMultiController countingController(MultiblockControllerMachine machine, AtomicInteger calls, + Set checked) { + MultiblockState state = new MultiblockState(machine.getLevel(), machine.getPos()); + return statefulCallbackController(machine, state, calls, + () -> checked.add(state.lastController)); + } + + private static IMultiController statefulCallbackController(MultiblockControllerMachine machine, + MultiblockState state, AtomicInteger calls, + Runnable callback) { + return statefulCallbackController(machine, state, calls, new long[0], callback); + } + + private static IMultiController statefulCallbackController(MultiblockControllerMachine machine, + MultiblockState state, AtomicInteger calls, + long[] confirmedChunks, Runnable callback) { + return (IMultiController) Proxy.newProxyInstance(IMultiController.class.getClassLoader(), + new Class[] { IMultiController.class }, (proxy, method, args) -> { + return switch (method.getName()) { + case "self" -> machine; + case "getMultiblockState" -> state; + case "getConfirmedStructureChunks" -> Arrays.copyOf(confirmedChunks, confirmedChunks.length); + case "asyncCheckPattern" -> { + calls.incrementAndGet(); + state.lastController = (IMultiController) proxy; + callback.run(); + yield null; + } + case "isStructureRevalidationPending", "isFormed", "isStructureFormedSnapshot", "isStructureOperational" -> false; + case "setStructureRevalidationPending" -> null; + case "toString" -> "queued-test-controller"; + default -> defaultValue(method.getReturnType()); + }; + }); + } + + private static IMultiController callbackController(MultiblockControllerMachine machine, AtomicInteger calls, + Runnable callback) { + return statefulCallbackController(machine, new MultiblockState(machine.getLevel(), machine.getPos()), calls, + callback); + } + + private static IMultiPart removalTrackingPart(AtomicInteger removals) { + return (IMultiPart) Proxy.newProxyInstance(IMultiPart.class.getClassLoader(), + new Class[] { IMultiPart.class }, + (proxy, method, args) -> { + return switch (method.getName()) { + case "removedFromController" -> { + removals.incrementAndGet(); + yield null; + } + case "toString" -> "stale-test-part"; + default -> defaultValue(method.getReturnType()); + }; + }); + } + + private static Object defaultValue(Class type) { + if (!type.isPrimitive() || type == void.class) return null; + if (type == boolean.class) return false; + if (type == char.class) return '\0'; + if (type == byte.class) return (byte) 0; + if (type == short.class) return (short) 0; + if (type == int.class) return 0; + if (type == long.class) return 0L; + if (type == float.class) return 0F; + return 0D; + } + + private static final class UnloadedPattern extends BlockPattern { + + private final BlockPos unloadedPos; + + private UnloadedPattern(BlockPos unloadedPos) { + super(new TraceabilityPredicate[0][][], new RelativeDirection[0], new int[0][], new int[5]); + this.unloadedPos = unloadedPos; + } + + @Override + public boolean checkPatternAt(MultiblockState state, boolean savePredicate) { + state.clean(); + state.addPosCache(state.controllerPos, new TraceabilityPredicate()); + return state.update(unloadedPos, new TraceabilityPredicate()); + } + } + + private static final class UnloadedThenMismatchPattern extends BlockPattern { + + private UnloadedThenMismatchPattern() { + super(new TraceabilityPredicate[0][][], new RelativeDirection[0], new int[0][], new int[5]); + } + + @Override + public boolean checkPatternAt(MultiblockState state, BlockPos centerPos, Direction frontFacing, + Direction upwardsFacing, boolean isFlipped, boolean savePredicate) { + state.setError(isFlipped ? MultiblockState.UNINIT_ERROR : MultiblockState.UNLOAD_ERROR); + return false; + } + } + + private static final class DefinitiveMismatchPattern extends BlockPattern { + + private DefinitiveMismatchPattern() { + super(new TraceabilityPredicate[0][][], new RelativeDirection[0], new int[0][], new int[5]); + } + + @Override + public boolean checkPatternAt(MultiblockState state, boolean savePredicate) { + state.setError(MultiblockState.UNINIT_ERROR); + return false; + } + } + + private static final class PersistedFlipUnloadPattern extends BlockPattern { + + private final AtomicInteger oppositeFlipChecks; + + private PersistedFlipUnloadPattern(AtomicInteger oppositeFlipChecks) { + super(new TraceabilityPredicate[0][][], new RelativeDirection[0], new int[0][], new int[5]); + this.oppositeFlipChecks = oppositeFlipChecks; + } + + @Override + public boolean checkPatternAt(MultiblockState state, BlockPos centerPos, Direction frontFacing, + Direction upwardsFacing, boolean isFlipped, boolean savePredicate) { + if (isFlipped) { + state.setError(MultiblockState.UNLOAD_ERROR); + return false; + } + oppositeFlipChecks.incrementAndGet(); + state.setError(null); + return true; + } + } + + private static final class SuccessfulFallbackPattern extends BlockPattern { + + private final AtomicInteger checks; + + private SuccessfulFallbackPattern(AtomicInteger checks) { + super(new TraceabilityPredicate[0][][], new RelativeDirection[0], new int[0][], new int[5]); + this.checks = checks; + } + + @Override + public boolean checkPatternAt(MultiblockState state, boolean savePredicate) { + checks.incrementAndGet(); + state.setError(null); + return true; + } + } +}