diff --git a/build.gradle b/build.gradle index 62db0b8..18878da 100644 --- a/build.gradle +++ b/build.gradle @@ -1 +1,35 @@ apply from: "$projectDir/dependencies.gradle" + +// Exercise Create's real network accounting in the owning module, independently of the aggregate modpack. +sourceSets.test { + compileClasspath += sourceSets.main.output + sourceSets.main.compileClasspath + runtimeClasspath += sourceSets.main.output + sourceSets.main.runtimeClasspath +} + +legacyForge { + addModdingDependenciesTo(sourceSets.test) + mods.named(project.mod_id) { + sourceSet(sourceSets.test) + } + runs { + gameTestServer { + type = "gameTestServer" + sourceSet = sourceSets.test + gameDirectory.set(file('run/gametest')) + systemProperty('forge.enabledGameTestNamespaces', project.mod_id) + logLevel = org.slf4j.event.Level.INFO + } + } +} + +tasks.named('test', Test).configure { + // These tests run in Forge's GameTestServer rather than the JUnit runner. + failOnNoDiscoveredTests = false +} + +tasks.named('processTestResources').configure { + // Reuse the existing empty GameTest template; no GTM test classes are loaded by this run. + from(project(':modules:GregTech-Modern').file('src/test/resources/data/gtceu/structures/empty.nbt')) { + into 'data/ctpp/structures' + } +} diff --git a/src/main/java/com/mo_guang/ctpp/api/pattern/StaticBlockPattern.java b/src/main/java/com/mo_guang/ctpp/api/pattern/StaticBlockPattern.java index 590832d..f1977a3 100644 --- a/src/main/java/com/mo_guang/ctpp/api/pattern/StaticBlockPattern.java +++ b/src/main/java/com/mo_guang/ctpp/api/pattern/StaticBlockPattern.java @@ -18,15 +18,24 @@ import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; +import com.mo_guang.ctpp.dynamicPart.rotation.IContraptionMultiblock; +import com.simibubi.create.content.kinetics.base.KineticBlock; +import com.simibubi.create.content.processing.burner.BlazeBurnerBlock; +import it.unimi.dsi.fastutil.ints.IntOpenHashSet; import it.unimi.dsi.fastutil.longs.LongOpenHashSet; +import it.unimi.dsi.fastutil.longs.LongSet; import it.unimi.dsi.fastutil.objects.Object2IntMap; +import org.jetbrains.annotations.Nullable; import java.util.*; public class StaticBlockPattern extends BlockPattern { + private static final String DYNAMIC_POSITIONS = "ctppDynamicPositions"; + protected final boolean[][][] staticBlockMatches; protected final int[][][] dynamicBlockMatches; + private final int dynamicGroupCount; public StaticBlockPattern(TraceabilityPredicate[][][] predicatesIn, RelativeDirection[] structureDir, int[][] aisleRepetitions, int[] centerOffset, @@ -34,13 +43,28 @@ public StaticBlockPattern(TraceabilityPredicate[][][] predicatesIn, RelativeDire super(predicatesIn, structureDir, aisleRepetitions, centerOffset); this.staticBlockMatches = staticPredicates; this.dynamicBlockMatches = dynamicPredicates; + var dynamicGroups = new IntOpenHashSet(); + for (int c = 0; c < staticPredicates.length; c++) { + for (int b = 0; b < staticPredicates[c].length; b++) { + for (int a = 0; a < staticPredicates[c][b].length; a++) { + if (!staticPredicates[c][b][a]) { + dynamicGroups.add(dynamicPredicates[c][b][a]); + } + } + } + } + this.dynamicGroupCount = dynamicGroups.size(); } @Override 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(); @@ -49,14 +73,32 @@ 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) { + 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); + } + return false; } } + if (sawUnloadedPosition) { + worldState.setError(MultiblockState.UNLOAD_ERROR); + } return false; } @@ -86,6 +128,31 @@ public Map> getDynamicPart(MultiblockState worldState) { return parts; } + /** + * Tests the geometry cached by the last pattern scan. Contraption assembly produces many block updates in a + * burst, so rebuilding every dynamic group and linearly searching every list for each update makes large static + * patterns quadratic. The fallback only serves legacy callers whose context predates this cache. + */ + public boolean isDynamicPosition(MultiblockState worldState, BlockPos pos) { + LongSet positions = getCachedDynamicPositions(worldState); + if (positions == null) { + positions = new LongOpenHashSet(); + for (List group : getDynamicPart(worldState).values()) { + for (BlockPos groupPos : group) { + positions.add(groupPos.asLong()); + } + } + worldState.getMatchContext().set(DYNAMIC_POSITIONS, positions); + } + return positions.contains(pos.asLong()); + } + + /** The scan-owned lookup; callers must not mutate it. Null means no static pattern has populated this context. */ + @Nullable + public static LongSet getCachedDynamicPositions(MultiblockState worldState) { + return worldState.getMatchContext().get(DYNAMIC_POSITIONS); + } + @Override public boolean checkPatternAt(MultiblockState worldState, BlockPos centerPos, Direction frontFacing, Direction upwardsFacing, boolean isFlipped, boolean savePredicate) { @@ -93,7 +160,22 @@ public boolean checkPatternAt(MultiblockState worldState, BlockPos centerPos, Di boolean findFirstAisle = false; int minZ = -centerOffset[4]; worldState.clean(); + boolean skipAssembledDynamic = false; + if (controller.isStructureFormedSnapshot() && dynamicGroupCount > 0 && + controller instanceof IContraptionMultiblock contraptionController) { + if (contraptionController.hasCompleteAttachedContraption(dynamicGroupCount)) { + skipAssembledDynamic = true; + } else if (!contraptionController.isAssemblyPivotEntityTicking()) { + // Absence is not evidence while the chunk which owns the contraption entity is unavailable or has not + // reached entity-ticking status. Keep the formed snapshot pending and retry after loading settles. + worldState.setError(MultiblockState.UNLOAD_ERROR); + return false; + } + // If the pivot is fully ticking and no complete entity set exists, validate the original blocks. A + // disassembled structure can then reform; missing entities plus missing source blocks is definitive. + } PatternMatchContext matchContext = worldState.getMatchContext(); + matchContext.getOrCreate(DYNAMIC_POSITIONS, LongOpenHashSet::new); Object2IntMap globalCount = worldState.getGlobalCount(); Object2IntMap layerCount = worldState.getLayerCount(); // Checking aisles @@ -108,22 +190,25 @@ public boolean checkPatternAt(MultiblockState worldState, BlockPos centerPos, Di for (int b = 0, y = -centerOffset[1]; b < this.thumbLength; b++, y++) { for (int a = 0, x = -centerOffset[0]; a < this.palmLength; a++, x++) { worldState.setError(null); - TraceabilityPredicate predicate; - if (!controller.isFormed()) { - predicate = blockMatches[c][b][a]; - } else { - predicate = this.staticBlockMatches[c][b][a] ? this.blockMatches[c][b][a] : - Predicates.any(); - } + TraceabilityPredicate declaredPredicate = blockMatches[c][b][a]; + boolean dynamicPosition = !staticBlockMatches[c][b][a]; + TraceabilityPredicate predicate = skipAssembledDynamic && dynamicPosition ? + Predicates.any() : declaredPredicate; BlockPos pos = setActualRelativeOffset(x, y, z, frontFacing, upwardsFacing, isFlipped) .offset(centerPos.getX(), centerPos.getY(), centerPos.getZ()); if (!worldState.update(pos, predicate)) { return false; } - if (predicate.addCache()) { - worldState.addPosCache(pos, predicate); + if (dynamicPosition) { + matchContext.getOrCreate(DYNAMIC_POSITIONS, LongOpenHashSet::new).add(pos.asLong()); + } + // Dynamic blocks are replaced by contraption entities after formation. Continue validating + // them as ANY, but retain their declared predicate in the confirmed geometry so chunk-unload + // preflight and reverse chunk indexing still cover the complete owned structure. + if (declaredPredicate.addCache() || dynamicPosition) { + worldState.addPosCache(pos, declaredPredicate); if (savePredicate) { - matchContext.getOrCreate("predicates", HashMap::new).put(pos, predicate); + matchContext.getOrCreate("predicates", HashMap::new).put(pos, declaredPredicate); } } boolean canPartShared = true; @@ -131,7 +216,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 { @@ -143,6 +228,16 @@ public boolean checkPatternAt(MultiblockState worldState, BlockPos centerPos, Di matchContext.getOrCreate("vaBlocks", LongOpenHashSet::new) .add(worldState.getPos().asLong()); } + // StaticBlockPattern overrides BlockPattern's scan method, so CTPP's BlockPattern mixin does + // not run here. Collect these ownership surfaces explicitly before the predicate test. + if (!dynamicPosition && worldState.getBlockState().getBlock() instanceof KineticBlock) { + matchContext.getOrCreate("roBlocks", LongOpenHashSet::new) + .add(worldState.getPos().asLong()); + } + if (!dynamicPosition && worldState.getBlockState().getBlock() instanceof BlazeBurnerBlock) { + matchContext.getOrCreate("bbBlocks", LongOpenHashSet::new) + .add(worldState.getPos().asLong()); + } if (!predicate.test(worldState) || !canPartShared) { // matching failed if (findFirstAisle) { if (r < aisleRepetitions[c][0]) {// retreat to see if the first aisle can start later diff --git a/src/main/java/com/mo_guang/ctpp/common/blockentity/IKineticBlockEntityExtension.java b/src/main/java/com/mo_guang/ctpp/common/blockentity/IKineticBlockEntityExtension.java index b840928..b481073 100644 --- a/src/main/java/com/mo_guang/ctpp/common/blockentity/IKineticBlockEntityExtension.java +++ b/src/main/java/com/mo_guang/ctpp/common/blockentity/IKineticBlockEntityExtension.java @@ -1,9 +1,10 @@ package com.mo_guang.ctpp.common.blockentity; +import net.minecraft.core.BlockPos; + public interface IKineticBlockEntityExtension { - // 设置是否在多方块中的状态 - void setCTNHInMultiblock(boolean inMultiblock); + void ctpp$claimMultiblockOwner(BlockPos controllerPos, long instanceId, float visualSpeed); - void setCTNHVisualSpeed(float speed); + void ctpp$releaseMultiblockOwner(BlockPos controllerPos, long instanceId); } diff --git a/src/main/java/com/mo_guang/ctpp/common/blockentity/KineticMachineBlockEntity.java b/src/main/java/com/mo_guang/ctpp/common/blockentity/KineticMachineBlockEntity.java index ddfd50b..fef9a14 100644 --- a/src/main/java/com/mo_guang/ctpp/common/blockentity/KineticMachineBlockEntity.java +++ b/src/main/java/com/mo_guang/ctpp/common/blockentity/KineticMachineBlockEntity.java @@ -20,6 +20,7 @@ import net.minecraft.client.renderer.blockentity.BlockEntityRenderers; import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; +import net.minecraft.nbt.CompoundTag; import net.minecraft.network.chat.Component; import net.minecraft.world.entity.player.Player; import net.minecraft.world.item.ItemStack; @@ -69,19 +70,51 @@ public class KineticMachineBlockEntity extends KineticBlockEntity implements IMa @Getter public final MetaMachine metaMachine; + private static final String APPLIED_SPEED_NBT_KEY = "appliedGeneratedSpeed"; + private static final String WORKING_SPEED_NBT_KEY = "workingSpeed"; + private static final String SOURCE_SUSPENDED_NBT_KEY = "generatedSourceSuspended"; + private final long offset = GTValues.RNG.nextInt(20); + + /** + * The speed requested by the GT recipe/multiblock logic. + */ @Persisted @DescSynced public float workingSpeed; - public boolean reActivateSource; + /** - * 重载宽限期:GTCEu 不持久化多方块成型状态,重载后需要异步重检(约 1~1.5s)才能恢复。 - * 在此期间保留持久化的 workingSpeed 重新挂接 Create 网络,避免应力网络失去源而整体崩溃。 + * The speed that is currently registered as this block entity's Create network source. Unlike + * {@link #workingSpeed}, this value must not depend on the transient GT multiblock binding during world or chunk + * load. */ - private static final int GRACE_TICKS = 100; + @Getter + @Persisted @DescSynced - private boolean graceActive; - private int graceTicks; + private float appliedGeneratedSpeed; + + /** + * Keeps a persisted output request and ownership binding while withdrawing only its live Create source. A newly + * loaded source retains its applied speed long enough for {@link KineticBlockEntity#initialize()} to consume its + * unloaded-network contribution exactly once; the owning part then suspends it if its controller remains + * unavailable after the bounded reload grace period. + */ + @Persisted + private boolean generatedSourceSuspended; + + /** Requests one server-thread reconciliation after Create has initialized its persisted network state. */ + public boolean reActivateSource; + + /** + * Guards the mandatory first-tick reconciliation from Create's propagation callbacks. In particular, + * {@link #setSource(BlockPos)} may clear {@link #reActivateSource} while {@code super.tick()} restores a saved + * network, but it must not cancel stale-network cleanup for this newly loaded block entity. + */ + private boolean sourceReconcilePending; + /** Keeps corrupt/out-of-range saved source state out of propagation until post-initialize repair. */ + private boolean preAttachReconciliationPending; + /** Marks a non-finite persisted source identity that must not enter KineticNetwork.addSilently(). */ + private boolean corruptKineticStatePending; protected KineticMachineBlockEntity(BlockEntityType typeIn, BlockPos pos, BlockState state) { super(typeIn, pos, state); @@ -178,15 +211,32 @@ public long getOffset() { public void onLoad() { super.onLoad(); metaMachine.onLoad(); - if (!level.isClientSide && getDefinition().isSource() && workingSpeed != 0 && - metaMachine instanceof KineticPartMachine) { - // 方案 B:保留 NBT 里持久化的 Create 动能状态(speed/source/network), - // 由 Create 的 initialize()/attachKinetics() 自行恢复原网络, - // 避免进游戏后应力网络失去源而整体崩溃。 - // 宽限期只负责在 GTCEu 异步重检成型(约 1~1.5s)期间对源保持乐观判定。 - graceActive = true; - graceTicks = GRACE_TICKS; - reActivateSource = true; + if (!level.isClientSide) { + float maxRotationSpeed = AllConfigs.server().kinetics.maxRotationSpeed.get(); + boolean corruptNetworkState = corruptKineticStatePending || !Float.isFinite(this.speed) || + !Float.isFinite(capacity) || !Float.isFinite(stress) || !Float.isFinite(lastCapacityProvided) || + !Float.isFinite(lastStressApplied); + boolean restoredSpeedOutOfRange = Math.abs(this.speed) > maxRotationSpeed || + Math.abs(appliedGeneratedSpeed) > maxRotationSpeed; + if (getDefinition().isSource() && (corruptNetworkState || restoredSpeedOutOfRange)) { + // Invalid saved source state must not enter propagation or KineticNetwork's unloaded aggregate. + // Discard only this block entity's membership, then rebuild it from the clamped desired speed after + // initialize. This also prevents a neighbour that ticks first from seeing an over-speed source. + clearKineticInformation(); + appliedGeneratedSpeed = 0.0F; + preAttachReconciliationPending = true; + reActivateSource = true; + sourceReconcilePending = true; + } + corruptKineticStatePending = false; + if (getDefinition().isSource() && + (hasNetwork() || hasSource() || getTheoreticalSpeed() != 0.0F || workingSpeed != 0.0F || + appliedGeneratedSpeed != 0.0F)) { + // tick() reconciles only after KineticBlockEntity.initialize() has restored this member and the + // aggregate contribution of still-unloaded members from NBT. + reActivateSource = true; + sourceReconcilePending = true; + } } } @@ -236,9 +286,11 @@ public float scheduleWorking(float su, boolean simulate) { if (getDefinition().isSource() && isValidOutputSource()) { float speed = Math.min(AllConfigs.server().kinetics.maxRotationSpeed.get(), su / getDefinition().getTorque()); - if (!simulate) { + if (!simulate && Float.compare(workingSpeed, speed) != 0) { workingSpeed = speed; reActivateSource = true; + sourceReconcilePending = true; + setChanged(); } return speed * getDefinition().getTorque(); } @@ -250,30 +302,40 @@ public void scheduleWorking(float su) { } public void stopWorking() { - if (getDefinition().isSource() && workingSpeed != 0) { + if (getDefinition().isSource() && + (workingSpeed != 0 || appliedGeneratedSpeed != 0 || generatedSourceSuspended)) { workingSpeed = 0; + generatedSourceSuspended = false; reActivateSource = true; + sourceReconcilePending = true; + setChanged(); } } - @Override - public float getGeneratedSpeed() { - return isValidOutputSource() ? workingSpeed : 0; + /** + * Pause or resume the applied Create source without discarding the recipe's desired speed. This transition is + * reconciled after the current MetaMachine tick, preserving Create's initialize/addSilently accounting order. + */ + public void setGeneratedSourceSuspended(boolean suspended) { + if (!getDefinition().isSource() || generatedSourceSuspended == suspended) { + return; + } + generatedSourceSuspended = suspended; + reActivateSource = true; + sourceReconcilePending = true; + setChanged(); } - public boolean isGraceActive() { - return graceActive; + public boolean isGeneratedSourceSuspended() { + return generatedSourceSuspended; + } + + @Override + public float getGeneratedSpeed() { + return appliedGeneratedSpeed; } - /** - * 宽限期内乐观信任持久化的 workingSpeed,避免重载后 GTCEu 异步重检成型完成前 - * 应力网络短暂失去源;重检结束后立即恢复严格判定。 - */ private boolean isValidOutputSource() { - if (graceActive) { - return true; - } - // 只有应力输出部件需要按成型状态门控:未成型时不得继续充当网络源。 return !(metaMachine instanceof KineticPartMachine) || isKineticPartFormed(); } @@ -282,10 +344,6 @@ private boolean isKineticPartFormed() { kineticPartMachine.isValidOutputBinding(); } - protected void notifyStressCapacityChange(float capacity) { - this.getOrCreateNetwork().updateCapacityFor(this, capacity); - } - public void removeSource() { if (getDefinition().isSource() && this.hasSource() && this.isSource()) { this.reActivateSource = true; @@ -305,30 +363,25 @@ public void setSource(BlockPos source) { } public void tick() { - if (!level.isClientSide && getDefinition().isSource()) { - tickGracePeriod(); - // workingSpeed 是 @DescSynced 服务器权威字段,客户端不允许本地写: - // 客户端写会污染同步值,且服务端值不变时不会重发,可能把转速卡在 0。 - if (!isValidOutputSource()) { - stopWorking(); - } + if (!level.isClientSide && (preAttachReconciliationPending || sourceReconcilePending)) { + // A finite old source contribution must reach initialize()/addSilently() before it is replaced; corrupt + // membership was scrubbed in onLoad(). A normal restored source is also held until Create has accounted + // its persisted membership through initialize()/addSilently(). + updateSpeed = false; } super.tick(); - if (getDefinition().isSource() && reActivateSource) { - updateGeneratedRotation(); - reActivateSource = false; - } - } - - /** - * 重载宽限期倒计时:异步重检已成型则提前结束,超时则按未成型处理(由 tick 停机)。 - */ - private void tickGracePeriod() { - if (!graceActive) { + if (level.isClientSide) { return; } - if (--graceTicks <= 0 || isKineticPartFormed()) { - graceActive = false; + if (getDefinition().isSource() && (reActivateSource || sourceReconcilePending)) { + // MetaMachine ticks before its holder, so all requests made during the current GT tick are coalesced into + // one topology update after Create's first-tick network restoration. A stable restored source stays in its + // existing network so KineticNetwork's aggregate state for still-unloaded members is preserved. + reActivateSource = false; + sourceReconcilePending = false; + boolean forceTopologyRebuild = preAttachReconciliationPending; + preAttachReconciliationPending = false; + updateGeneratedRotation(forceTopologyRebuild); } } @@ -363,37 +416,139 @@ public boolean addToGoggleTooltip(List tooltip, boolean isPlayerSneak * 只允许服务器侧更新转速与应力网络:客户端由 @DescSynced 同步显示,不做本地写。 */ public void updateGeneratedRotation() { + updateGeneratedRotation(false); + } + + private void updateGeneratedRotation(boolean forceTopologyRebuild) { if (!getDefinition().isSource() || level == null || level.isClientSide) { return; } - float speed = this.getGeneratedSpeed(); - float prevSpeed = this.speed; - if (prevSpeed != speed) { - if (!this.hasSource()) { - IRotate.SpeedLevel levelBefore = IRotate.SpeedLevel.of(this.speed); - IRotate.SpeedLevel levelAfter = IRotate.SpeedLevel.of(speed); - if (levelBefore != levelAfter) { - this.effects.queueRotationIndicators(); - } - } - this.applyNewSpeed(prevSpeed, speed); + float maxRotationSpeed = AllConfigs.server().kinetics.maxRotationSpeed.get(); + float desiredSpeed = Float.isFinite(workingSpeed) ? + Math.max(-maxRotationSpeed, Math.min(maxRotationSpeed, workingSpeed)) : 0.0F; + if (desiredSpeed != workingSpeed) { + workingSpeed = desiredSpeed; } - if (this.hasNetwork() && speed != 0.0F) { - KineticNetwork network = this.getOrCreateNetwork(); - this.notifyStressCapacityChange(this.calculateAddedStressCapacity()); - this.getOrCreateNetwork().updateStressFor(this, this.calculateStressApplied()); - network.updateStress(); + float targetSpeed = generatedSourceSuspended ? 0.0F : desiredSpeed; + + float previousActualSpeed = this.speed; + boolean remainsExternallyDriven = targetSpeed != 0.0F && hasSource() && + Math.abs(previousActualSpeed) >= Math.abs(targetSpeed) && + Math.signum(previousActualSpeed) == Math.signum(targetSpeed); + boolean stableGeneratedTopology = targetSpeed != 0.0F && !hasSource() && hasNetwork() && + Float.compare(previousActualSpeed, targetSpeed) == 0; + boolean canRetuneGeneratedTopology = targetSpeed != 0.0F && !hasSource() && hasNetwork() && + previousActualSpeed != 0.0F; + if (!forceTopologyRebuild && targetSpeed == 0.0F && hasSource()) { + // Keep the externally driven member in place, but remove its generator contribution while the old applied + // speed still identifies it as a source. Removing/re-adding the whole member would transiently orphan a + // partially loaded network, so only the source entry changes here. + replaceSourceContribution(0.0F); + } else if (!forceTopologyRebuild && (remainsExternallyDriven || stableGeneratedTopology)) { + replaceSourceContribution(targetSpeed); + } else if (canRetuneGeneratedTopology) { + retuneGeneratedTopology(targetSpeed); + } else { + rebuildGeneratedTopology(targetSpeed, forceTopologyRebuild); } - this.onSpeedChanged(prevSpeed); + + refreshNetworkContributions(); + if (Float.compare(previousActualSpeed, this.speed) != 0) { + this.onSpeedChanged(previousActualSpeed); + } + this.setChanged(); this.sendData(); } + /** + * Atomically replaces only this member's source contribution while preserving its current external source and + * network membership. The old source entry is removed before {@link #appliedGeneratedSpeed} changes; the new value + * is visible before the entry is added again. + */ + private void replaceSourceContribution(float targetSpeed) { + KineticNetwork network = hasNetwork() ? getOrCreateNetwork() : null; + if (network != null) { + network.sources.remove(this); + } + appliedGeneratedSpeed = targetSpeed; + if (network == null) { + return; + } + if (targetSpeed != 0.0F) { + network.updateCapacityFor(this, calculateAddedStressCapacity()); + } else { + lastCapacityProvided = 0.0F; + network.updateCapacity(); + } + } + + /** + * Changes a running independent source without replacing its network object. Create stores the contributions of + * unloaded members only in that object, so clearing the network id here would discard their aggregate stress and + * capacity until every chunk happened to load again. + */ + private void retuneGeneratedTopology(float targetSpeed) { + KineticNetwork network = getOrCreateNetwork(); + detachKinetics(); + network.sources.remove(this); + appliedGeneratedSpeed = targetSpeed; + setSpeed(targetSpeed); + network.updateCapacityFor(this, calculateAddedStressCapacity()); + attachKinetics(); + } + + /** + * Rebuilds this block entity as an independent generator (or stops it). Detachment and old-network removal happen + * while the old applied speed is still visible. The target applied speed is published before joining a new network. + */ + private void rebuildGeneratedTopology(float targetSpeed, boolean attachStoppedSource) { + KineticNetwork previousNetwork = hasNetwork() ? getOrCreateNetwork() : null; + if (previousNetwork != null) { + // Chunk unload deliberately leaves the old block-entity identity in Create's network. A newly loaded + // instance is added alongside it; force one identity cleanup before removing this instance, otherwise the + // target-zero path can strand a phantom source/member in an otherwise dead network. + previousNetwork.updateNetwork(); + } + if (previousNetwork != null && appliedGeneratedSpeed == 0.0F) { + // Clean up a zero-valued stale source entry too; KineticNetwork.remove() only removes sources when + // isSource() is true. + previousNetwork.sources.remove(this); + } + if (this.speed != 0.0F) { + detachKinetics(); + } + setNetwork(null); + source = null; + setSpeed(0.0F); + + appliedGeneratedSpeed = targetSpeed; + if (targetSpeed == 0.0F) { + lastCapacityProvided = 0.0F; + if (attachStoppedSource) { + attachKinetics(); + } + return; + } + + setSpeed(targetSpeed); + setNetwork(createNetworkId()); + attachKinetics(); + } + + private void refreshNetworkContributions() { + if (!hasNetwork()) { + return; + } + KineticNetwork network = getOrCreateNetwork(); + if (appliedGeneratedSpeed != 0.0F) { + network.updateCapacityFor(this, calculateAddedStressCapacity()); + } + network.updateStressFor(this, calculateStressApplied()); + network.updateStress(); + } + @Override public float calculateStressApplied() { - if (!isValidOutputSource()) { - this.lastStressApplied = 0; - return 0; - } float impact = (float) IBlockStressValues.getImpact(this.getStressConfigKey()); this.lastStressApplied = impact; return impact; @@ -401,66 +556,35 @@ public float calculateStressApplied() { @Override public float calculateAddedStressCapacity() { - if (!isValidOutputSource()) { - this.lastCapacityProvided = 0; - return 0; - } float capacity = (float) IBlockStressValues.getCapacity(this.getStressConfigKey()); this.lastCapacityProvided = capacity; return capacity; } - public void applyNewSpeed(float prevSpeed, float speed) { - if (speed == 0.0F) { - if (this.hasSource()) { - this.notifyStressCapacityChange(0.0F); - this.getOrCreateNetwork().updateStressFor(this, this.calculateStressApplied()); - } else { - this.detachKinetics(); - this.setSpeed(0.0F); - this.setNetwork(null); - } - } else if (prevSpeed == 0.0F) { - this.setSpeed(speed); - this.setNetwork(this.createNetworkId()); - this.attachKinetics(); - } else if (this.hasSource()) { - if (Math.abs(prevSpeed) >= Math.abs(speed) && Math.signum(prevSpeed) == Math.signum(speed)) { - return; - } - // 应力箱是 GT 驱动的多方块部件,方向冲突只会来自重载/未成型竞态, - // 不触发原版“反向即销毁”的保护,改为重新挂载以同步到新目标速度。 - if (Math.abs(prevSpeed) >= Math.abs(speed) && !(metaMachine instanceof KineticPartMachine)) { - this.level.destroyBlock(this.worldPosition, true); - return; - } - this.detachKinetics(); - this.setSpeed(speed); - this.source = null; - this.setNetwork(this.createNetworkId()); - this.attachKinetics(); - } else { - this.detachKinetics(); - this.setSpeed(speed); - this.attachKinetics(); - } - } - public Long createNetworkId() { return this.worldPosition.asLong(); } - // @Override - // protected void write(CompoundTag compound, boolean clientPacket) { - // super.write(compound, clientPacket); - // compound.putFloat("workingSpeed", workingSpeed); - // } - // - // @Override - // protected void read(CompoundTag compound, boolean clientPacket) { - // super.read(compound, clientPacket); - // workingSpeed = compound.contains("workingSpeed") ? compound.getFloat("workingSpeed") : 0; - // } + @Override + public void saveCustomPersistedData(CompoundTag tag, boolean forDrop) { + IMachineBlockEntity.super.saveCustomPersistedData(tag, forDrop); + if (forDrop) { + // A picked-up or cloned output may retain inventory fields, but never a live network request/source state. + tag.remove(WORKING_SPEED_NBT_KEY); + tag.remove(APPLIED_SPEED_NBT_KEY); + tag.remove(SOURCE_SUSPENDED_NBT_KEY); + } + } + + @Override + public void loadCustomPersistedData(CompoundTag tag) { + if (!Float.isFinite(workingSpeed)) workingSpeed = 0.0F; + if (!Float.isFinite(appliedGeneratedSpeed)) { + appliedGeneratedSpeed = 0.0F; + corruptKineticStatePending = true; + } + IMachineBlockEntity.super.loadCustomPersistedData(tag); + } @Override public ManagedFieldHolder getFieldHolder() { diff --git a/src/main/java/com/mo_guang/ctpp/common/blockentity/MultiblockOwner.java b/src/main/java/com/mo_guang/ctpp/common/blockentity/MultiblockOwner.java new file mode 100644 index 0000000..ff8df1e --- /dev/null +++ b/src/main/java/com/mo_guang/ctpp/common/blockentity/MultiblockOwner.java @@ -0,0 +1,40 @@ +package com.mo_guang.ctpp.common.blockentity; + +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.Tag; + +/** A persistent identity for one validated multiblock-controller ownership epoch. */ +public record MultiblockOwner(BlockPos controllerPos, long instanceId) implements Comparable { + + private static final String CONTROLLER_POS = "ControllerPos"; + private static final String INSTANCE_ID = "InstanceId"; + + public MultiblockOwner { + controllerPos = controllerPos.immutable(); + } + + public boolean isValid() { + return instanceId > 0; + } + + public CompoundTag save() { + CompoundTag tag = new CompoundTag(); + tag.putLong(CONTROLLER_POS, controllerPos.asLong()); + tag.putLong(INSTANCE_ID, instanceId); + return tag; + } + + public static MultiblockOwner load(CompoundTag tag) { + if (!tag.contains(CONTROLLER_POS, Tag.TAG_LONG)) { + return new MultiblockOwner(BlockPos.ZERO, 0); + } + return new MultiblockOwner(BlockPos.of(tag.getLong(CONTROLLER_POS)), tag.getLong(INSTANCE_ID)); + } + + @Override + public int compareTo(MultiblockOwner other) { + int positionComparison = Long.compare(controllerPos.asLong(), other.controllerPos.asLong()); + return positionComparison != 0 ? positionComparison : Long.compare(instanceId, other.instanceId); + } +} diff --git a/src/main/java/com/mo_guang/ctpp/common/machine/NotifiableStressTrait.java b/src/main/java/com/mo_guang/ctpp/common/machine/NotifiableStressTrait.java index 0a25617..e40ce98 100644 --- a/src/main/java/com/mo_guang/ctpp/common/machine/NotifiableStressTrait.java +++ b/src/main/java/com/mo_guang/ctpp/common/machine/NotifiableStressTrait.java @@ -7,12 +7,9 @@ import com.gregtechceu.gtceu.api.machine.trait.NotifiableRecipeHandlerTrait; import com.gregtechceu.gtceu.api.recipe.GTRecipe; -import com.lowdragmc.lowdraglib.syncdata.annotation.Persisted; - import net.minecraft.util.Mth; import com.mo_guang.ctpp.api.StressRecipeCapability; -import com.mo_guang.ctpp.common.machine.multiblock.part.KineticPartMachine; import lombok.Getter; import lombok.Setter; @@ -27,9 +24,6 @@ public class NotifiableStressTrait extends NotifiableRecipeHandlerTrait i public final IO handlerIO; @Getter public final IO capabilityIO; - @Getter - @Persisted - private int remainingOutputTicks; private float available, lastSpeed; public NotifiableStressTrait(MetaMachine machine, IO handlerIO, IO capabilityIO) { @@ -44,12 +38,6 @@ public void onMachineLoad() { super.onMachineLoad(); if (machine instanceof IKineticMachine kineticMachine) { machine.subscribeServerTick(() -> { - if (remainingOutputTicks > 0 && machine instanceof KineticPartMachine kineticPart && - !kineticPart.isValidOutputBinding() && !kineticPart.getKineticHolder().isGraceActive() && - --remainingOutputTicks == 0) { - stopWorking(); - } - var speed = kineticMachine.getKineticHolder().getSpeed(); if (speed != lastSpeed) { lastSpeed = speed; @@ -84,9 +72,6 @@ public boolean handleRecipe(IO io, GTRecipe recipe, List left, if (!simulate) { available = handled; - if (io == IO.OUT && kineticDefinition.isSource() && handled > 0) { - remainingOutputTicks = Math.max(recipe.duration + 20, 1); - } } stress -= handled; @@ -130,7 +115,6 @@ public float getStressAvailable() { } public void stopWorking() { - remainingOutputTicks = 0; available = 0; if (machine instanceof IKineticMachine kineticMachine) { var kineticDefinition = kineticMachine.getKineticDefinition(); diff --git a/src/main/java/com/mo_guang/ctpp/common/machine/multiblock/ComplexRotatingMachine.java b/src/main/java/com/mo_guang/ctpp/common/machine/multiblock/ComplexRotatingMachine.java index 851157f..6f05a03 100644 --- a/src/main/java/com/mo_guang/ctpp/common/machine/multiblock/ComplexRotatingMachine.java +++ b/src/main/java/com/mo_guang/ctpp/common/machine/multiblock/ComplexRotatingMachine.java @@ -47,7 +47,7 @@ public void onStructureFormed() { this.contraptionEntity.addAll(rotatingEntities.values()); } } - this.rotatingSubs = this.subscribeServerTick(this::rotatingTick); + this.rotatingSubs = this.subscribeServerTick(rotatingSubs, this::rotatingTick); } @Override @@ -55,8 +55,27 @@ public void onStructureInvalid() { super.onStructureInvalid(); // disassemble and clear using interface helper clearAndDisassembleRotatingEntities(); + unsubscribeRotatingTick(); + } + + @Override + protected void onStructureRevalidationChanged(boolean pending) { + super.onStructureRevalidationChanged(pending); + if (pending) { + unsubscribeRotatingTick(); + } + } + + @Override + public void onUnload() { + unsubscribeRotatingTick(); + super.onUnload(); + } + + private void unsubscribeRotatingTick() { if (rotatingSubs != null) { unsubscribe(rotatingSubs); + rotatingSubs = null; } } @@ -83,7 +102,7 @@ public void handleDisplayClick(String componentData, ClickData clickData) { } public void rotatingTick() { - if (!isFormed || contraptionEntity == null || contraptionEntity.isEmpty()) { + if (!isStructureOperational() || contraptionEntity == null || contraptionEntity.isEmpty()) { return; } diff --git a/src/main/java/com/mo_guang/ctpp/common/machine/multiblock/IMultiblockKineticOwner.java b/src/main/java/com/mo_guang/ctpp/common/machine/multiblock/IMultiblockKineticOwner.java new file mode 100644 index 0000000..edecc4f --- /dev/null +++ b/src/main/java/com/mo_guang/ctpp/common/machine/multiblock/IMultiblockKineticOwner.java @@ -0,0 +1,12 @@ +package com.mo_guang.ctpp.common.machine.multiblock; + +import net.minecraft.core.BlockPos; + +/** Loaded-controller membership used to retire persisted Create-side claims without loading owner chunks. */ +public interface IMultiblockKineticOwner { + + boolean ownsKineticVisual(BlockPos pos); + + /** Current per-owner visual request used by loaded-member reconciliation. */ + float getKineticVisualSpeed(BlockPos pos); +} diff --git a/src/main/java/com/mo_guang/ctpp/common/machine/multiblock/KineticGeneratorMachine.java b/src/main/java/com/mo_guang/ctpp/common/machine/multiblock/KineticGeneratorMachine.java index 02cf7a9..38fba8a 100644 --- a/src/main/java/com/mo_guang/ctpp/common/machine/multiblock/KineticGeneratorMachine.java +++ b/src/main/java/com/mo_guang/ctpp/common/machine/multiblock/KineticGeneratorMachine.java @@ -110,7 +110,7 @@ public void onStructureInvalid() { @Override public void updateMachineSpeed() { super.updateMachineSpeed(); - if (!contraptionEntity.isEmpty() && isFormed) { + if (!contraptionEntity.isEmpty() && isStructureOperational()) { contraptionEntity.forEach(entity -> { var facing = getFrontFacing().getNormal(); Vec3 newF = new Vec3(facing.getX(), facing.getY(), facing.getZ()); diff --git a/src/main/java/com/mo_guang/ctpp/common/machine/multiblock/KineticMultiblockMachine.java b/src/main/java/com/mo_guang/ctpp/common/machine/multiblock/KineticMultiblockMachine.java index 6850bad..f64a291 100644 --- a/src/main/java/com/mo_guang/ctpp/common/machine/multiblock/KineticMultiblockMachine.java +++ b/src/main/java/com/mo_guang/ctpp/common/machine/multiblock/KineticMultiblockMachine.java @@ -49,7 +49,8 @@ import java.util.Objects; public abstract class KineticMultiblockMachine extends RecipeMultiblockMachine - implements IFancyUIMachine, IDisplayUIMachine { + implements IFancyUIMachine, IDisplayUIMachine, + IMultiblockKineticOwner { @Getter public LongSet rotateBlocks; @@ -70,17 +71,16 @@ public KineticMultiblockMachine(IMachineBlockEntity holder) { @Override public void onStructureFormed() { + LongSet oldRotateBlocks = rotateBlocks; super.onStructureFormed(); checkTier(); - rotateBlocks = getMultiblockState().getMatchContext().getOrDefault("roBlocks", LongSets.emptySet()); - blazeBlocks = getMultiblockState().getMatchContext().getOrDefault("bbBlocks", LongSets.emptySet()); - for (var pos : rotateBlocks) { - var blockEntity = getLevel().getBlockEntity(BlockPos.of(pos)); - if (blockEntity instanceof KineticBlockEntity kineticBlockEntity) { - IKineticBlockEntityExtension mixin = ((IKineticBlockEntityExtension) kineticBlockEntity); - mixin.setCTNHInMultiblock(true); - } - } + LongSet newRotateBlocks = getMultiblockState().getMatchContext().getOrDefault("roBlocks", + LongSets.emptySet()); + LongSet newBlazeBlocks = getMultiblockState().getMatchContext().getOrDefault("bbBlocks", + LongSets.emptySet()); + releaseRotateBlocks(oldRotateBlocks, newRotateBlocks); + rotateBlocks = newRotateBlocks; + blazeBlocks = newBlazeBlocks; updateActiveBlocks(getRecipeLogic().isWorking()); } @@ -88,16 +88,17 @@ public void onStructureFormed() { public void onStructureInvalid() { stopWorking(); super.onStructureInvalid(); - if (rotateBlocks == null || getLevel() == null) { - return; - } - for (var pos : rotateBlocks) { - var blockEntity = getLevel().getBlockEntity(BlockPos.of(pos)); - if (blockEntity instanceof KineticBlockEntity kineticBlockEntity) { - IKineticBlockEntityExtension mixin = ((IKineticBlockEntityExtension) kineticBlockEntity); - mixin.setCTNHVisualSpeed(0); - mixin.setCTNHInMultiblock(false); - } + releaseRotateBlocks(rotateBlocks, null); + rotateBlocks = null; + blazeBlocks = null; + } + + @Override + protected void onStructureRevalidationChanged(boolean pending) { + super.onStructureRevalidationChanged(pending); + if (pending) { + // Keep the exact claims across a chunk unload, but withdraw only this controller's transient outputs. + updateActiveBlocks(false); } } @@ -136,10 +137,11 @@ public void notifyWorkStatusChanged(WorkLogic.Status oldStatus, @Override public void updateActiveBlocks(boolean active) { - super.updateActiveBlocks(active); - updateRotateBlocks(active); + boolean operationalActive = active && isStructureOperational(); + super.updateActiveBlocks(operationalActive); + updateRotateBlocks(operationalActive); try { - updateBlazeBlocks(active); + updateBlazeBlocks(operationalActive); } catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException e) { throw new RuntimeException(e); } @@ -155,12 +157,13 @@ public void checkTier() { } public void updateRotateBlocks(boolean active) { - if (rotateBlocks != null) { - for (Long pos : rotateBlocks) { - var blockPos = BlockPos.of(pos); - var blockEntity = Objects.requireNonNull(getLevel()).getBlockEntity(blockPos); - updateRotateBlock(active, blockEntity); - } + if (rotateBlocks == null || getLevel() == null) { + return; + } + for (long pos : rotateBlocks) { + var blockPos = BlockPos.of(pos); + if (!getLevel().isLoaded(blockPos)) continue; + updateRotateBlock(active, getLevel().getBlockEntity(blockPos)); } } @@ -168,37 +171,58 @@ public void updateRotateBlock(boolean active, BlockEntity blockEntity) { if (blockEntity instanceof KineticBlockEntity kineticBlockEntity && kineticBlockEntity instanceof IKineticBlockEntityExtension extension && !(kineticBlockEntity instanceof KineticMachineBlockEntity)) { - extension.setCTNHVisualSpeed(active ? speed : 0); + extension.ctpp$claimMultiblockOwner(getPos(), getStructureInstanceId(), active ? speed : 0.0F); } } public void updateBlazeBlocks(boolean active) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { - if (blazeBlocks != null) { - for (Long pos : blazeBlocks) { - var blockPos = BlockPos.of(pos); - if (getLevel().getBlockEntity(blockPos) != null) { - var blockEntity = Objects.requireNonNull(getLevel()).getBlockEntity(blockPos); - BlazeBurnerBlock.HeatLevel heat = BlazeBurnerBlock.HeatLevel.SMOULDERING; - if (active) { - if (speed >= 256) { - heat = BlazeBurnerBlock.HeatLevel.SEETHING; - } else if (speed >= 128) { - heat = BlazeBurnerBlock.HeatLevel.KINDLED; - } else { - heat = BlazeBurnerBlock.HeatLevel.FADING; - } - } - if (blockEntity instanceof BlazeBurnerBlockEntity blazeBurnerBlockEntity) { - Method method = BlazeBurnerBlockEntity.class.getDeclaredMethod("setBlockHeat", - BlazeBurnerBlock.HeatLevel.class); - method.setAccessible(true); - method.invoke(blazeBurnerBlockEntity, heat); - } - } + if (blazeBlocks == null || getLevel() == null) { + return; + } + BlazeBurnerBlock.HeatLevel heat = getRequestedBlazeHeat(active); + for (long pos : blazeBlocks) { + var blockPos = BlockPos.of(pos); + if (!getLevel().isLoaded(blockPos)) continue; + if (getLevel().getBlockEntity(blockPos) instanceof BlazeBurnerBlockEntity blazeBurner) { + Method method = BlazeBurnerBlockEntity.class.getDeclaredMethod("setBlockHeat", + BlazeBurnerBlock.HeatLevel.class); + method.setAccessible(true); + method.invoke(blazeBurner, heat); + } + } + } + + private BlazeBurnerBlock.HeatLevel getRequestedBlazeHeat(boolean active) { + if (!active) return BlazeBurnerBlock.HeatLevel.SMOULDERING; + if (speed >= 256) return BlazeBurnerBlock.HeatLevel.SEETHING; + if (speed >= 128) return BlazeBurnerBlock.HeatLevel.KINDLED; + return BlazeBurnerBlock.HeatLevel.FADING; + } + + private void releaseRotateBlocks(LongSet oldBlocks, LongSet retainedBlocks) { + if (oldBlocks == null || getLevel() == null) return; + for (long pos : oldBlocks) { + if (retainedBlocks != null && retainedBlocks.contains(pos)) continue; + BlockPos blockPos = BlockPos.of(pos); + if (!getLevel().isLoaded(blockPos)) continue; + if (getLevel().getBlockEntity(blockPos) instanceof KineticBlockEntity kineticBlockEntity && + kineticBlockEntity instanceof IKineticBlockEntityExtension extension) { + extension.ctpp$releaseMultiblockOwner(getPos(), getStructureInstanceId()); } } } + + @Override + public boolean ownsKineticVisual(BlockPos pos) { + return rotateBlocks != null && rotateBlocks.contains(pos.asLong()); + } + + @Override + public float getKineticVisualSpeed(BlockPos pos) { + return ownsKineticVisual(pos) && isStructureOperational() && getRecipeLogic().isWorking() ? speed : 0.0F; + } + ////////////////////////////////////// // ********** GUI ***********// ////////////////////////////////////// diff --git a/src/main/java/com/mo_guang/ctpp/common/machine/multiblock/KineticWorkableMultiblockMachine.java b/src/main/java/com/mo_guang/ctpp/common/machine/multiblock/KineticWorkableMultiblockMachine.java index 5cfe016..399071d 100644 --- a/src/main/java/com/mo_guang/ctpp/common/machine/multiblock/KineticWorkableMultiblockMachine.java +++ b/src/main/java/com/mo_guang/ctpp/common/machine/multiblock/KineticWorkableMultiblockMachine.java @@ -45,6 +45,7 @@ public KineticWorkableMultiblockMachine(IMachineBlockEntity holder) { @Override public void onStructureFormed() { super.onStructureFormed(); + refreshInputSpeed(); } @Override @@ -96,9 +97,16 @@ public boolean onWorking() { @Override public void onChanged() { super.onChanged(); + refreshInputSpeed(); + } + + private void refreshInputSpeed() { speedConsistent = checkInputSpeedConsistent(); if (speedConsistent) { updateMachineSpeed(); + } else if (speed != 0) { + speed = 0; + updateRotateBlocks(true); } } @@ -119,7 +127,7 @@ public void updateMachineSpeed() { @Override public void addDisplayText(List textList) { super.addDisplayText(textList); - if (isFormed) { + if (isStructureOperational()) { textList.add(inputStress.translate(getTotalInputStress())); var lastRecipe = getRecipeLogic().getLastRecipe(); if (lastRecipe != null) diff --git a/src/main/java/com/mo_guang/ctpp/common/machine/multiblock/part/KineticPartMachine.java b/src/main/java/com/mo_guang/ctpp/common/machine/multiblock/part/KineticPartMachine.java index 6f50e54..21758f6 100644 --- a/src/main/java/com/mo_guang/ctpp/common/machine/multiblock/part/KineticPartMachine.java +++ b/src/main/java/com/mo_guang/ctpp/common/machine/multiblock/part/KineticPartMachine.java @@ -1,14 +1,19 @@ package com.mo_guang.ctpp.common.machine.multiblock.part; -import com.gregtechceu.gtceu.GTCEu; import com.gregtechceu.gtceu.api.capability.recipe.IO; import com.gregtechceu.gtceu.api.machine.IMachineBlockEntity; import com.gregtechceu.gtceu.api.machine.TickableSubscription; import com.gregtechceu.gtceu.api.machine.feature.multiblock.IMultiController; import com.gregtechceu.gtceu.api.machine.multiblock.part.TieredIOPartMachine; +import com.gregtechceu.gtceu.api.pattern.MultiblockWorldSavedData; + +import com.lowdragmc.lowdraglib.syncdata.annotation.Persisted; import net.minecraft.MethodsReturnNonnullByDefault; +import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.server.level.ServerLevel; import net.minecraft.world.InteractionHand; import net.minecraft.world.entity.player.Player; import net.minecraft.world.phys.BlockHitResult; @@ -26,15 +31,21 @@ @MethodsReturnNonnullByDefault public class KineticPartMachine extends TieredIOPartMachine implements IKineticMachine { + private static final int INITIAL_BINDING_GRACE_TICKS = 40; + private static final String LAST_CONTROLLER_POS_NBT_KEY = "lastControllerPos"; + private static final String BINDING_RELOAD_GRACE_DEADLINE_NBT_KEY = "bindingReloadGraceDeadline"; + @Getter protected final NotifiableStressTrait stressTrait; @Nullable - protected TickableSubscription selfCheckSubs; - /** - * 上一次检查时的输出绑定有效性,用于检测重载后异步重检成型的“无效→有效”跃迁。 - */ - private boolean wasValidBinding = true; + private TickableSubscription bindingCheckSubscription; + @Persisted + @Nullable + private BlockPos lastControllerPos; + @Persisted + private long bindingReloadGraceDeadline; + private boolean bindingInconclusive = true; public KineticPartMachine(IMachineBlockEntity holder, int tier, IO io, Object... args) { super(holder, tier, io); @@ -49,14 +60,21 @@ public boolean isValidOutputBinding() { if (io != IO.OUT || !getKineticDefinition().isSource()) { return true; } - if (!isFormed() || getControllers().isEmpty()) { + if (!isFormed() || getControllers().isEmpty() || + !(getLevel() instanceof ServerLevel serverLevel)) { return false; } var controller = getControllers().first(); - return controller instanceof KineticOutputMachine outputMachine && - outputMachine.isFormed() && - outputMachine.isActive() && - outputMachine.getParts().contains(this); + if (!(controller instanceof KineticOutputMachine outputMachine)) { + return false; + } + BlockPos controllerPos = outputMachine.getPos(); + long instanceId = getControllerBindingInstanceId(controllerPos); + return lastControllerPos != null && lastControllerPos.equals(controllerPos) && instanceId > 0 && + MultiblockWorldSavedData.getOrCreate(serverLevel) + .isLoadedValidatedControllerMember(controllerPos, instanceId, getPos()) && + outputMachine.getStructureInstanceId() == instanceId && outputMachine.isStructureOperational() && + outputMachine.isActive() && outputMachine.hasRuntimePart(this); } ////////////////////////////////////// @@ -84,62 +102,219 @@ public void onRotated(Direction oldFacing, Direction newFacing) { @Override public void removedFromController(IMultiController controller) { super.removedFromController(controller); - stressTrait.stopWorking(); + lastControllerPos = null; + bindingInconclusive = false; + clearBindingReloadGrace(); + // A part unloading from its chunk is not a structure failure. Its holder is already invalid in that path, so + // retain the persisted output; a definitive controller invalidation still stops every loaded output part. + if (!isInValid()) { + stressTrait.stopWorking(); + } } @Override - public void onChanged() { - super.onChanged(); - if (!getControllers().isEmpty() && - getControllers().first() instanceof KineticMultiblockMachine kineticMultiblockMachine) { - kineticMultiblockMachine.onChanged(); - } + public void unloadedFromController(IMultiController controller) { + // Preserve the applied Create source while either side of the multiblock is temporarily unloaded. Calling the + // superclass unload implementation clears only the runtime controller association; lastControllerPos remains + // as the non-loading proof needed to distinguish an unloaded controller from a removed one. + super.unloadedFromController(controller); + bindingInconclusive = true; + startBindingReloadGrace(); } @Override - public void setWorkingEnabled(boolean workingEnabled) { - if (!workingEnabled) { - stressTrait.stopWorking(); + public void onControllerBindingRetired(BlockPos controllerPos, long instanceId) { + if (lastControllerPos != null && lastControllerPos.equals(controllerPos)) { + lastControllerPos = null; + bindingInconclusive = false; + clearBindingReloadGrace(); + if (!isInValid()) { + stressTrait.stopWorking(); + } } - super.setWorkingEnabled(workingEnabled); } - void checkWorking() { - if (getOffsetTimer() % 100 == 0 && !GTCEu.isClientSide()) { - boolean valid = isValidOutputBinding(); - if (valid) { - if (!wasValidBinding) { - // 重载后异步重检成型恢复:之前未成型导致的停机已结束, - // 重新通知 holder 同步 Create 网络源(速度由配方逻辑恢复)。 - wasValidBinding = true; - getKineticHolder().reActivateSource = true; - } - } else { - wasValidBinding = false; - if (!getKineticHolder().isGraceActive()) { - stressTrait.stopWorking(); - } + void checkOutputBinding() { + if (io != IO.OUT || !getKineticDefinition().isSource()) { + return; + } + if (isValidOutputBinding()) { + bindingInconclusive = false; + clearBindingReloadGrace(); + getKineticHolder().setGeneratedSourceSuspended(false); + return; + } + + if (!(getLevel() instanceof ServerLevel serverLevel)) { + return; + } + + var savedData = MultiblockWorldSavedData.getOrCreate(serverLevel); + long instanceId = lastControllerPos == null ? 0 : getControllerBindingInstanceId(lastControllerPos); + if (lastControllerPos != null && instanceId > 0 && + savedData.getControllerBindingStatus(lastControllerPos, instanceId) == + MultiblockWorldSavedData.ControllerBindingStatus.RETIRED) { + // A tombstone is conclusive even while the owner chunk is unavailable. Retire before granting reload + // grace; the callback queues source withdrawal after Create restores its saved contribution. + reconcileControllerBindings(savedData); + return; + } + IMultiController exactController = lastControllerPos == null || instanceId <= 0 ? null : + savedData.getLoadedControllerInstance(lastControllerPos, instanceId); + boolean controllerPositionTicking = lastControllerPos != null && + savedData.isControllerPositionLoadedNoChunkRequest(lastControllerPos); + boolean ownerUnavailable = lastControllerPos != null && instanceId > 0 && + exactController == null && !controllerPositionTicking; + if (ownerUnavailable && !bindingInconclusive) { + // Runtime ownership was just lost because its chunk became unobservable. Give the same bounded window as a + // disk reload before withdrawing only the applied source. + bindingInconclusive = true; + startBindingReloadGrace(); + } else if (!ownerUnavailable) { + // A loaded exact controller (including one explicitly pending revalidation), or an entity-ticking owner + // position where the exact epoch is absent, is conclusive enough to fail closed immediately. The grace is + // only for an owner chunk whose state cannot currently be observed. + bindingInconclusive = false; + clearBindingReloadGrace(); + } + if (ownerUnavailable && serverLevel.getGameTime() < bindingReloadGraceDeadline) { + return; + } + + // Past the bounded reload window, anything short of an exact ACTIVE epoch, operational controller, validated + // world mapping, runtime membership, and active output fails closed. Ownership and the requested speed remain + // persisted; only the Create source contribution is withdrawn and can be restored on the next exact match. + getKineticHolder().setGeneratedSourceSuspended(true); + if (lastControllerPos == null || instanceId <= 0) { + // An output without an exact persisted owner cannot resume. This also covers a save between owner + // invalidation and the holder's end-of-tick source reconciliation. + lastControllerPos = null; + bindingInconclusive = false; + getKineticHolder().setChanged(); + stressTrait.stopWorking(); + return; + } + if (getOffsetTimer() % 20 != 0) { + return; + } + + if (exactController == null) { + if (savedData.isControllerPositionLoadedNoChunkRequest(lastControllerPos)) { + // An entity-ticking position with no exact epoch is positive deletion/replacement evidence. Let the + // base owner protocol retire the binding and invoke onControllerBindingRetired(). + reconcileControllerBindings(savedData); } + // Missing/FULL-but-not-entity-ticking chunks remain inconclusive. Preserve ownership and desired speed; + // the fail-closed transition above has withdrawn only the applied source. + return; + } + + if (exactController.isStructureRevalidationPending()) { + return; + } + + if (!(exactController instanceof KineticOutputMachine outputMachine)) { + removedFromController(exactController); + return; + } + if (!outputMachine.isStructureOperational() || !outputMachine.hasRuntimePart(this)) { + // The exact controller is fully available and no longer owns this part. This is definitive, not a timeout. + reconcileControllerBindings(savedData); + return; + } + + if (savedData.mapping.get(lastControllerPos) != outputMachine.getMultiblockState()) { + // A direct/addon formation path can briefly expose runtime membership before the world reverse index is + // installed. Retain the exact owner, but never publish power until the validated mapping is visible. + return; + } + + bindingInconclusive = false; + if (outputMachine.isActive()) { + getKineticHolder().setGeneratedSourceSuspended(false); + } + } + + @Override + public void addedToController(IMultiController controller) { + super.addedToController(controller); + if (io == IO.OUT && getKineticDefinition().isSource()) { + lastControllerPos = controller.self().getPos().immutable(); + bindingInconclusive = false; + clearBindingReloadGrace(); + getKineticHolder().setGeneratedSourceSuspended(!isValidOutputBinding()); } } @Override public void onLoad() { super.onLoad(); - if (selfCheckSubs == null) { - selfCheckSubs = subscribeServerTick(this::checkWorking); + bindingInconclusive = true; + if (getLevel() instanceof ServerLevel serverLevel && + lastControllerPos != null && getControllerBindingInstanceId(lastControllerPos) > 0 && + !getKineticHolder().isGeneratedSourceSuspended() && + getKineticHolder().getAppliedGeneratedSpeed() != 0.0F) { + long latestSafeDeadline = serverLevel.getGameTime() + INITIAL_BINDING_GRACE_TICKS; + if (bindingReloadGraceDeadline <= 0 || bindingReloadGraceDeadline > latestSafeDeadline) { + bindingReloadGraceDeadline = latestSafeDeadline; + getKineticHolder().setChanged(); + } + } + if (bindingCheckSubscription == null) { + bindingCheckSubscription = subscribeServerTick(this::checkOutputBinding); + } + } + + private void startBindingReloadGrace() { + if (bindingReloadGraceDeadline == 0 && getLevel() instanceof ServerLevel serverLevel) { + bindingReloadGraceDeadline = serverLevel.getGameTime() + INITIAL_BINDING_GRACE_TICKS; + getKineticHolder().setChanged(); + } + } + + private void clearBindingReloadGrace() { + if (bindingReloadGraceDeadline != 0) { + bindingReloadGraceDeadline = 0; + getKineticHolder().setChanged(); } } @Override public void onUnload() { + if (bindingCheckSubscription != null) { + bindingCheckSubscription.unsubscribe(); + } + bindingCheckSubscription = null; super.onUnload(); - if (selfCheckSubs != null) { - selfCheckSubs.unsubscribe(); - selfCheckSubs = null; + } + + @Override + public void saveCustomPersistedData(CompoundTag tag, boolean forDrop) { + super.saveCustomPersistedData(tag, forDrop); + if (forDrop) { + // Item NBT must not carry a controller claim or a reload grace into a new placement. + tag.remove(LAST_CONTROLLER_POS_NBT_KEY); + tag.remove(BINDING_RELOAD_GRACE_DEADLINE_NBT_KEY); } } + @Override + public void onChanged() { + super.onChanged(); + if (!getControllers().isEmpty() && + getControllers().first() instanceof KineticMultiblockMachine kineticMultiblockMachine) { + kineticMultiblockMachine.onChanged(); + } + } + + @Override + public void setWorkingEnabled(boolean workingEnabled) { + if (!workingEnabled) { + stressTrait.stopWorking(); + } + super.setWorkingEnabled(workingEnabled); + } + ////////////////////////////////////// // ********* GUI *********// ////////////////////////////////////// diff --git a/src/main/java/com/mo_guang/ctpp/dynamicPart/rotation/IContraptionMultiblock.java b/src/main/java/com/mo_guang/ctpp/dynamicPart/rotation/IContraptionMultiblock.java index 55b9b3e..9e7413c 100644 --- a/src/main/java/com/mo_guang/ctpp/dynamicPart/rotation/IContraptionMultiblock.java +++ b/src/main/java/com/mo_guang/ctpp/dynamicPart/rotation/IContraptionMultiblock.java @@ -6,6 +6,7 @@ import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerLevel; import net.minecraft.world.entity.Entity; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.phys.AABB; @@ -58,9 +59,13 @@ default void onDebugAssembled() {} @SuppressWarnings("unchecked") default void findAndReattachEntities() { if (self().getLevel() == null || self().getLevel().isClientSide) return; - if (getContraptionEntity() == null) setContraptionEntity(new ArrayList<>()); - // Only search if the list is empty — otherwise entities are already tracked - if (!getContraptionEntity().isEmpty()) return; + // A partial entity set is possible when several group chunks load in different ticks. Prune stale handles and + // search for every missing sibling instead of treating a non-empty list as complete. + List attached = getContraptionEntity() == null ? new ArrayList<>() : + new ArrayList<>(getContraptionEntity()); + attached.removeIf(entity -> entity == null || entity.isRemoved() || + entity.controllerPos == null || !entity.controllerPos.equals(self().getPos())); + setContraptionEntity(attached); BlockPos pos = self().getPos(); // Search in a 32-block radius for entities that reference this controller @@ -69,7 +74,9 @@ default void findAndReattachEntities() { T srEntity = (T) entity; if (srEntity.controllerPos != null && srEntity.controllerPos.equals(pos)) { // Found an entity that belongs to us — reattach - getContraptionEntity().add(srEntity); + if (!attached.contains(srEntity)) { + attached.add(srEntity); + } if (!srEntity.isRunning()) { srEntity.setRunning(true); } @@ -77,6 +84,33 @@ default void findAndReattachEntities() { } } + /** Exact evidence that every dynamic pattern group still has its controller-owned contraption entity. */ + default boolean hasCompleteAttachedContraption(int expectedGroups) { + List current = getContraptionEntity(); + if (current != null && current.size() == expectedGroups && + current.stream() + .allMatch(entity -> entity != null && !entity.isRemoved() && entity.controllerPos != null && + entity.controllerPos.equals(self().getPos()))) { + return true; + } + findAndReattachEntities(); + return getContraptionEntity() != null && getContraptionEntity().size() == expectedGroups; + } + + /** + * Whether absence of an entity at the fixed assembly pivot is authoritative. This performs only non-loading + * checks; a FULL chunk which has not reached entity-ticking status remains unavailable. + */ + default boolean isAssemblyPivotEntityTicking() { + BlockPos pivot = getAssemblyPivot(); + if (pivot == null || !(self().getLevel() instanceof ServerLevel serverLevel)) { + return false; + } + return serverLevel.getChunkSource().getChunkNow(pivot.getX() >> 4, pivot.getZ() >> 4) != null && + serverLevel.isPositionEntityTicking(pivot) && + serverLevel.areEntitiesLoaded(net.minecraft.world.level.ChunkPos.asLong(pivot)); + } + @Override default boolean shouldIgnoreChange(BlockPos pos, BlockState state) { return shouldIgnoreContraptionChange(pos, state); @@ -87,13 +121,14 @@ default boolean shouldIgnoreChange(BlockPos pos, BlockState state) { * multiblock while the contraption is being assembled or disassembled. */ default boolean shouldIgnoreContraptionChange(BlockPos pos, BlockState state) { + var dynamicPositions = StaticBlockPattern.getCachedDynamicPositions(getMultiblockState()); + if (dynamicPositions != null) { + // getPattern() invokes a factory that builds the entire pattern, so even obtaining it on every block + // notification would make a large contraption's assembly quadratic. + return dynamicPositions.contains(pos.asLong()); + } if (this.getPattern() instanceof StaticBlockPattern staticBlockPattern) { - var dynamicParts = staticBlockPattern.getDynamicPart(getMultiblockState()).values(); - for (var dynamicPart : dynamicParts) { - if (dynamicPart.contains(pos)) { - return true; - } - } + return staticBlockPattern.isDynamicPosition(getMultiblockState(), pos); } return false; } diff --git a/src/main/java/com/mo_guang/ctpp/mixin/RotationPropagatorMixin.java b/src/main/java/com/mo_guang/ctpp/mixin/RotationPropagatorMixin.java index 87265f9..fb4864b 100644 --- a/src/main/java/com/mo_guang/ctpp/mixin/RotationPropagatorMixin.java +++ b/src/main/java/com/mo_guang/ctpp/mixin/RotationPropagatorMixin.java @@ -1,9 +1,6 @@ package com.mo_guang.ctpp.mixin; -import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; -import net.minecraft.world.level.Level; -import net.minecraft.world.level.block.entity.BlockEntity; import com.mo_guang.ctpp.common.blockentity.KineticMachineBlockEntity; import com.mo_guang.ctpp.common.machine.IKineticMachine; @@ -12,7 +9,6 @@ import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; -import org.spongepowered.asm.mixin.injection.Redirect; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; /** @@ -23,23 +19,6 @@ @Mixin(RotationPropagator.class) public abstract class RotationPropagatorMixin { - /** - * 重载/未成型窗口期的网络重建竞态会让 Create 原版的动能冲突保护 - * (方向相反、同网络速度跳变等)直接 destroyBlock 掉应力输入/输出箱。 - * 这些机器箱的动能状态由 GT 配方驱动,不应被该保护误杀;普通 Create - * 动力节点和纯渲染 roBlocks 均保留原版保护。 - */ - @Redirect(method = "propagateNewSource", - at = @At(value = "INVOKE", - target = "Lnet/minecraft/world/level/Level;destroyBlock(Lnet/minecraft/core/BlockPos;Z)Z")) - private static boolean ctpp$protectKineticMachines(Level level, BlockPos pos, boolean dropBlock) { - BlockEntity blockEntity = level.getBlockEntity(pos); - if (blockEntity instanceof KineticMachineBlockEntity) { - return false; - } - return level.destroyBlock(pos, dropBlock); - } - @Inject(method = "getAxisModifier", at = @At(value = "RETURN"), remap = false, cancellable = true) private static void injectAxisModifier(KineticBlockEntity te, Direction direction, CallbackInfoReturnable cir) { diff --git a/src/main/java/com/mo_guang/ctpp/mixin/SmartBlockEntityMixin.java b/src/main/java/com/mo_guang/ctpp/mixin/SmartBlockEntityMixin.java deleted file mode 100644 index 487081a..0000000 --- a/src/main/java/com/mo_guang/ctpp/mixin/SmartBlockEntityMixin.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.mo_guang.ctpp.mixin; - -import com.mo_guang.ctpp.common.blockentity.KineticMachineBlockEntity; -import com.simibubi.create.foundation.blockEntity.SmartBlockEntity; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.injection.At; -import org.spongepowered.asm.mixin.injection.Inject; -import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; - -@Mixin(value = SmartBlockEntity.class, remap = false) -public class SmartBlockEntityMixin { - - @Inject( - method = "setRemoved", - at = @At("TAIL")) - public void injectSetRemoved(CallbackInfo ci) { - if ((Object) this instanceof KineticMachineBlockEntity kineticMachineBlockEntity) { - kineticMachineBlockEntity.metaMachine.onUnload(); - } - } -} diff --git a/src/main/java/com/mo_guang/ctpp/mixin/create/KineticBlockEntityMixin.java b/src/main/java/com/mo_guang/ctpp/mixin/create/KineticBlockEntityMixin.java index 0a6d556..8acc842 100644 --- a/src/main/java/com/mo_guang/ctpp/mixin/create/KineticBlockEntityMixin.java +++ b/src/main/java/com/mo_guang/ctpp/mixin/create/KineticBlockEntityMixin.java @@ -1,9 +1,18 @@ package com.mo_guang.ctpp.mixin.create; +import com.gregtechceu.gtceu.api.machine.feature.multiblock.IMultiController; +import com.gregtechceu.gtceu.api.pattern.MultiblockWorldSavedData; + +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 com.mo_guang.ctpp.common.blockentity.IKineticBlockEntityExtension; import com.mo_guang.ctpp.common.blockentity.KineticMachineBlockEntity; +import com.mo_guang.ctpp.common.blockentity.MultiblockOwner; +import com.mo_guang.ctpp.common.machine.multiblock.IMultiblockKineticOwner; import com.simibubi.create.content.kinetics.base.KineticBlockEntity; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Unique; @@ -12,75 +21,225 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; +import java.util.Map; +import java.util.TreeMap; + @Mixin(KineticBlockEntity.class) public class KineticBlockEntityMixin implements IKineticBlockEntityExtension { @Unique - private boolean CTNH$inMultiblock; + private static final String CTNH$OWNERS = "CTPPMultiblockOwners"; + @Unique + private static final String CTNH$VISUAL_SPEED = "VisualSpeed"; + @Unique + private static final int CTNH$RECONCILE_INTERVAL = 100; + + @Unique + private final Map CTNH$multiblockOwners = new TreeMap<>(); + + @Unique + private int CTNH$ownerReconcileTicks; @Unique - private float CTNH$visualSpeed; + private boolean CTNH$persistSafeOwnersAfterDiskLoad; @Inject(method = "write", at = @At("TAIL"), remap = false) private void ctpp$writeMultiblockMarker(CompoundTag compound, boolean clientPacket, CallbackInfo ci) { - if (CTNH$inMultiblock) { - compound.putBoolean("CTPPInMultiblock", true); - } - if (clientPacket && CTNH$visualSpeed != 0) { - compound.putFloat("CTNHVisualSpeed", CTNH$visualSpeed); + if (!CTNH$multiblockOwners.isEmpty()) { + ListTag owners = new ListTag(); + for (var entry : CTNH$multiblockOwners.entrySet()) { + CompoundTag owner = entry.getKey().save(); + owner.putFloat(CTNH$VISUAL_SPEED, entry.getValue()); + owners.add(owner); + } + compound.put(CTNH$OWNERS, owners); } } @Inject(method = "read", at = @At("TAIL"), remap = false) private void ctpp$readMultiblockMarker(CompoundTag compound, boolean clientPacket, CallbackInfo ci) { - CTNH$inMultiblock = compound.getBoolean("CTPPInMultiblock"); - if (clientPacket) { - CTNH$visualSpeed = compound.getFloat("CTNHVisualSpeed"); + CTNH$multiblockOwners.clear(); + ListTag owners = compound.getList(CTNH$OWNERS, Tag.TAG_COMPOUND); + for (int i = 0; i < owners.size(); i++) { + CompoundTag ownerTag = owners.getCompound(i); + MultiblockOwner owner = MultiblockOwner.load(ownerTag); + if (owner.isValid()) { + float visualSpeed = ownerTag.getFloat(CTNH$VISUAL_SPEED); + visualSpeed = Float.isFinite(visualSpeed) ? visualSpeed : 0.0F; + // Disk requests are provisional until their controller is loaded and operational again. + CTNH$multiblockOwners.put(owner, clientPacket ? visualSpeed : 0.0F); + } } + // Stable position phases spread a large structure's owner checks across the full interval after bulk loading. + CTNH$ownerReconcileTicks = ctpp$initialReconcileDelay(((KineticBlockEntity) (Object) this).getBlockPos()); + CTNH$persistSafeOwnersAfterDiskLoad = !clientPacket && !CTNH$multiblockOwners.isEmpty(); } @Inject(method = "getSpeed", at = @At("RETURN"), remap = false, cancellable = true) private void ctpp$provideVisualSpeed(CallbackInfoReturnable cir) { KineticBlockEntity blockEntity = (KineticBlockEntity) (Object) this; - if (blockEntity.getLevel() != null && blockEntity.getLevel().isClientSide && CTNH$inMultiblock && + if (blockEntity.getLevel() != null && blockEntity.getLevel().isClientSide && ctpp$isClaimed() && !(blockEntity instanceof KineticMachineBlockEntity)) { - cir.setReturnValue(CTNH$visualSpeed); + cir.setReturnValue(ctpp$getAggregatedVisualSpeed()); } } @Inject(method = "needsSpeedUpdate", at = @At("RETURN"), remap = false, cancellable = true) private void ctpp$disableKineticReattachment(CallbackInfoReturnable cir) { KineticBlockEntity blockEntity = (KineticBlockEntity) (Object) this; - if (CTNH$inMultiblock && !(blockEntity instanceof KineticMachineBlockEntity)) { + if (ctpp$isClaimed() && !(blockEntity instanceof KineticMachineBlockEntity)) { cir.setReturnValue(false); } } + @Inject(method = "initialize", at = @At("TAIL"), remap = false) + private void ctpp$detachRestoredOwnedMemberAfterNativeAccounting(CallbackInfo ci) { + KineticBlockEntity blockEntity = (KineticBlockEntity) (Object) this; + if (ctpp$isClaimed() && !(blockEntity instanceof KineticMachineBlockEntity) && + blockEntity.getLevel() instanceof ServerLevel) { + // Create must first run initFromTE/addSilently so its unloaded network aggregate is consumed exactly once. + // The render-only multiblock member can then leave that live network before the first server tick. + ctpp$detachFromCreateNetwork(blockEntity); + } + } + + @Inject(method = "tick", at = @At("TAIL"), remap = false) + private void ctpp$reconcileMultiblockOwners(CallbackInfo ci) { + KineticBlockEntity blockEntity = (KineticBlockEntity) (Object) this; + if (CTNH$persistSafeOwnersAfterDiskLoad && blockEntity.getLevel() instanceof ServerLevel) { + CTNH$persistSafeOwnersAfterDiskLoad = false; + blockEntity.setChanged(); + } + if (!(blockEntity.getLevel() instanceof ServerLevel serverLevel) || + CTNH$multiblockOwners.isEmpty()) { + return; + } + if (--CTNH$ownerReconcileTicks > 0) { + return; + } + CTNH$ownerReconcileTicks = CTNH$RECONCILE_INTERVAL; + + boolean wasClaimed = ctpp$isClaimed(); + boolean changed = false; + var savedData = MultiblockWorldSavedData.getOrCreate(serverLevel); + var iterator = CTNH$multiblockOwners.entrySet().iterator(); + while (iterator.hasNext()) { + var entry = iterator.next(); + MultiblockOwner owner = entry.getKey(); + var status = savedData.getControllerBindingStatus(owner.controllerPos(), owner.instanceId()); + if (status == MultiblockWorldSavedData.ControllerBindingStatus.RETIRED) { + iterator.remove(); + changed = true; + continue; + } + IMultiController controller = savedData.getLoadedControllerInstance(owner.controllerPos(), + owner.instanceId()); + if (controller == null) { + // A missing owner chunk keeps the exact claim but cannot keep publishing its last transient output. + // A loaded+ticking position with no exact ownership epoch is positive stale-owner evidence. + if (savedData.isControllerPositionLoadedNoChunkRequest(owner.controllerPos())) { + iterator.remove(); + changed = true; + } else if (Float.compare(entry.getValue(), 0.0F) != 0) { + entry.setValue(0.0F); + changed = true; + } + continue; + } + if (controller.isStructureRevalidationPending()) { + if (Float.compare(entry.getValue(), 0.0F) != 0) { + entry.setValue(0.0F); + changed = true; + } + continue; + } + if (!controller.isStructureOperational() || + !(controller instanceof IMultiblockKineticOwner kineticOwner) || + !kineticOwner.ownsKineticVisual(blockEntity.getBlockPos())) { + iterator.remove(); + changed = true; + continue; + } + float requested = kineticOwner.getKineticVisualSpeed(blockEntity.getBlockPos()); + requested = Float.isFinite(requested) ? requested : 0.0F; + if (Float.compare(entry.getValue(), requested) != 0) { + entry.setValue(requested); + changed = true; + } + } + if (changed) { + ctpp$finishOwnershipChange(blockEntity, wasClaimed); + } + } + @Override - public void setCTNHInMultiblock(boolean inMultiblock) { - if (CTNH$inMultiblock == inMultiblock) { + public void ctpp$claimMultiblockOwner(BlockPos controllerPos, long instanceId, float visualSpeed) { + if (instanceId <= 0) { return; } - CTNH$inMultiblock = inMultiblock; KineticBlockEntity blockEntity = (KineticBlockEntity) (Object) this; + boolean hadExactOwner = !CTNH$multiblockOwners.isEmpty(); + if (!hadExactOwner) { + CTNH$ownerReconcileTicks = ctpp$initialReconcileDelay(blockEntity.getBlockPos()); + } + MultiblockOwner owner = new MultiblockOwner(controllerPos, instanceId); + float requested = Float.isFinite(visualSpeed) ? visualSpeed : 0.0F; + Float previous = CTNH$multiblockOwners.put(owner, requested); + if (previous != null && Float.compare(previous, requested) == 0) { + return; + } + ctpp$finishOwnershipChange(blockEntity, hadExactOwner); + } + + @Override + public void ctpp$releaseMultiblockOwner(BlockPos controllerPos, long instanceId) { + if (instanceId <= 0) { + return; + } + KineticBlockEntity blockEntity = (KineticBlockEntity) (Object) this; + boolean wasClaimed = ctpp$isClaimed(); + if (CTNH$multiblockOwners.remove(new MultiblockOwner(controllerPos, instanceId)) != null) { + ctpp$finishOwnershipChange(blockEntity, wasClaimed); + } + } + + @Unique + private boolean ctpp$isClaimed() { + return !CTNH$multiblockOwners.isEmpty(); + } + + @Unique + private float ctpp$getAggregatedVisualSpeed() { + float selected = 0.0F; + float selectedMagnitude = 0.0F; + for (float candidate : CTNH$multiblockOwners.values()) { + float magnitude = Math.abs(candidate); + if (magnitude > selectedMagnitude) { + selected = candidate; + selectedMagnitude = magnitude; + } + } + return selected; + } + + @Unique + private void ctpp$finishOwnershipChange(KineticBlockEntity blockEntity, boolean wasClaimed) { + boolean claimed = ctpp$isClaimed(); if (blockEntity.getLevel() != null && !blockEntity.getLevel().isClientSide) { - if (inMultiblock && !(blockEntity instanceof KineticMachineBlockEntity)) { + if (!wasClaimed && claimed && !(blockEntity instanceof KineticMachineBlockEntity)) { ctpp$detachFromCreateNetwork(blockEntity); - } else if (!inMultiblock) { - // Allow ordinary Create propagation again after the multiblock is gone. + } else if (wasClaimed && !claimed) { + // Allow ordinary Create propagation again only after the final exact owner is gone. blockEntity.updateSpeed = true; } blockEntity.setChanged(); - } - if (blockEntity.getLevel() != null && !blockEntity.getLevel().isClientSide) { blockEntity.sendData(); } } @Unique private void ctpp$detachFromCreateNetwork(KineticBlockEntity blockEntity) { - // Old versions wrote the visual speed into Create's real kinetic fields. Remove - // that state when the block is first claimed by the new render-only path. + // A render-only member must leave its physical network when the first controller claims it. if (blockEntity.hasNetwork() || blockEntity.hasSource() || blockEntity.getTheoreticalSpeed() != 0) { blockEntity.detachKinetics(); blockEntity.setSpeed(0); @@ -91,15 +250,8 @@ public void setCTNHInMultiblock(boolean inMultiblock) { blockEntity.updateSpeed = false; } - @Override - public void setCTNHVisualSpeed(float speed) { - if (Float.compare(CTNH$visualSpeed, speed) == 0) { - return; - } - CTNH$visualSpeed = speed; - KineticBlockEntity blockEntity = (KineticBlockEntity) (Object) this; - if (blockEntity.getLevel() != null && !blockEntity.getLevel().isClientSide) { - blockEntity.sendData(); - } + @Unique + private static int ctpp$initialReconcileDelay(BlockPos pos) { + return Math.floorMod(Long.hashCode(pos.asLong()), CTNH$RECONCILE_INTERVAL) + 1; } } diff --git a/src/main/resources/ctpp.mixins.json b/src/main/resources/ctpp.mixins.json index 8b3bf0f..5276712 100644 --- a/src/main/resources/ctpp.mixins.json +++ b/src/main/resources/ctpp.mixins.json @@ -7,7 +7,6 @@ "BlockPatternMixin", "GTBucketItemMixin", "RotationPropagatorMixin", - "SmartBlockEntityMixin", "create.KineticBlockEntityMixin", "create.Matrix3dMixin", "create.WindmillBearingBlockMixin", diff --git a/src/test/java/com/mo_guang/ctpp/api/pattern/StaticBlockPatternTest.java b/src/test/java/com/mo_guang/ctpp/api/pattern/StaticBlockPatternTest.java new file mode 100644 index 0000000..79e0fbd --- /dev/null +++ b/src/test/java/com/mo_guang/ctpp/api/pattern/StaticBlockPatternTest.java @@ -0,0 +1,116 @@ +package com.mo_guang.ctpp.api.pattern; + +import com.gregtechceu.gtceu.api.machine.feature.multiblock.IMultiController; +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; + +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.world.level.block.Blocks; +import net.minecraftforge.gametest.GameTestHolder; +import net.minecraftforge.gametest.PrefixGameTestTemplate; + +import com.mo_guang.ctpp.CTPP; +import com.mo_guang.ctpp.dynamicPart.rotation.IContraptionMultiblock; +import com.simibubi.create.AllBlocks; +import it.unimi.dsi.fastutil.longs.LongSet; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Proxy; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +@PrefixGameTestTemplate(false) +@GameTestHolder(CTPP.MODID) +public class StaticBlockPatternTest { + + @GameTest(template = "empty", batch = "CTPPStaticPattern") + public static void assembledDynamicBlocksKeepGeometryAndConstantTimeChangeLookup(GameTestHelper helper) { + Fixture fixture = fixture(helper, true, true); + helper.assertTrue(check(fixture), "An attached contraption could not validate its static structure"); + helper.assertTrue(fixture.state.getPredicateMap().get(fixture.center.asLong()) == fixture.dynamic, + "The moved coordinate lost its declared predicate and chunk-unload coverage"); + LongSet rotating = fixture.state.getMatchContext().get("roBlocks"); + helper.assertTrue(rotating != null && rotating.contains(fixture.center.above().asLong()), + "The static pattern omitted its ordinary kinetic ownership surface"); + helper.assertFalse(rotating.contains(fixture.center.asLong()), + "A moved dynamic coordinate was incorrectly registered as an ordinary kinetic block"); + for (int i = 0; i < 4096; i++) { + helper.assertTrue(fixture.controller.shouldIgnoreContraptionChange(fixture.center, + Blocks.AIR.defaultBlockState()), "The cached dynamic position was not ignored"); + helper.assertFalse(fixture.controller.shouldIgnoreContraptionChange(fixture.center.above(), + AllBlocks.SHAFT.getDefaultState()), "A static position was incorrectly ignored"); + } + // The fixture throws if getPattern() is called: block-update bursts must use the cached primitive lookup. + helper.succeed(); + } + + @GameTest(template = "empty", batch = "CTPPStaticPattern") + public static void missingContraptionRemainsPendingUntilEntityAbsenceIsAuthoritative(GameTestHelper helper) { + Fixture fixture = fixture(helper, false, false); + helper.assertFalse(check(fixture), "An unavailable contraption unexpectedly formed"); + helper.assertTrue(fixture.state.error == MultiblockState.UNLOAD_ERROR, + "Entity IO lag was treated as a broken structure"); + + fixture.entityReady.set(true); + helper.assertFalse(check(fixture), "Missing source blocks and a missing entity unexpectedly formed"); + helper.assertTrue(fixture.state.error != MultiblockState.UNLOAD_ERROR, + "Authoritative entity absence remained pending forever"); + + helper.getLevel().setBlockAndUpdate(fixture.center, Blocks.STONE.defaultBlockState()); + helper.assertTrue(check(fixture), "An intact disassembled structure could not reform"); + helper.succeed(); + } + + private static boolean check(Fixture fixture) { + return fixture.pattern.checkPatternAt(fixture.state, fixture.center, Direction.SOUTH, Direction.NORTH, + false, true); + } + + private static Fixture fixture(GameTestHelper helper, boolean attached, boolean ready) { + BlockPos center = helper.absolutePos(new BlockPos(1, 1, 1)); + helper.getLevel().setBlockAndUpdate(center, Blocks.AIR.defaultBlockState()); + helper.getLevel().setBlockAndUpdate(center.above(), AllBlocks.SHAFT.getDefaultState()); + var controllerRef = new AtomicReference(); + var state = new MultiblockState(helper.getLevel(), center) { + + @Override + public IMultiController getController() { + return controllerRef.get(); + } + }; + var entityReady = new AtomicBoolean(ready); + var controller = (IContraptionMultiblock) Proxy.newProxyInstance( + IContraptionMultiblock.class.getClassLoader(), new Class[] { IContraptionMultiblock.class }, + (proxy, method, args) -> { + return switch (method.getName()) { + case "isStructureFormedSnapshot" -> true; + case "hasCompleteAttachedContraption" -> attached; + case "isAssemblyPivotEntityTicking" -> entityReady.get(); + case "getMultiblockState" -> state; + case "getPattern" -> throw new AssertionError( + "A cached block update rebuilt the entire pattern"); + default -> { + if (method.isDefault()) yield InvocationHandler.invokeDefault(proxy, method, args); + throw new AssertionError("Unexpected controller method: " + method.getName()); + } + }; + }); + controllerRef.set(controller); + var dynamic = Predicates.blocks(Blocks.STONE); + var pattern = new StaticBlockPattern( + new TraceabilityPredicate[][][] { { { dynamic }, { Predicates.blocks(AllBlocks.SHAFT.get()) } } }, + new RelativeDirection[] { RelativeDirection.LEFT, RelativeDirection.UP, RelativeDirection.FRONT }, + new int[][] { { 1, 1 } }, new int[5], new boolean[][][] { { { false }, { true } } }, + new int[][][] { { { 0 }, { 0 } } }); + return new Fixture(center, pattern, state, controller, dynamic, entityReady); + } + + private record Fixture(BlockPos center, StaticBlockPattern pattern, MultiblockState state, + IContraptionMultiblock controller, TraceabilityPredicate dynamic, + AtomicBoolean entityReady) {} +} diff --git a/src/test/java/com/mo_guang/ctpp/common/blockentity/KineticVisualOwnershipTest.java b/src/test/java/com/mo_guang/ctpp/common/blockentity/KineticVisualOwnershipTest.java new file mode 100644 index 0000000..46a9c93 --- /dev/null +++ b/src/test/java/com/mo_guang/ctpp/common/blockentity/KineticVisualOwnershipTest.java @@ -0,0 +1,100 @@ +package com.mo_guang.ctpp.common.blockentity; + +import net.minecraft.core.BlockPos; +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.nbt.Tag; +import net.minecraftforge.gametest.GameTestHolder; +import net.minecraftforge.gametest.PrefixGameTestTemplate; + +import com.mo_guang.ctpp.CTPP; +import com.simibubi.create.AllBlocks; +import com.simibubi.create.content.kinetics.base.KineticBlockEntity; + +@PrefixGameTestTemplate(false) +@GameTestHolder(CTPP.MODID) +public class KineticVisualOwnershipTest { + + @GameTest(templateNamespace = CTPP.MODID, template = "empty", batch = "KineticVisualOwnership") + public static void exactOwnersReleaseIndependently(GameTestHelper helper) { + KineticBlockEntity shaft = placeShaft(helper); + IKineticBlockEntityExtension extension = (IKineticBlockEntityExtension) shaft; + BlockPos ownerPos = helper.absolutePos(new BlockPos(2, 1, 0)); + + extension.ctpp$claimMultiblockOwner(ownerPos, 0, 64.0F); + extension.ctpp$claimMultiblockOwner(ownerPos, -1, 64.0F); + helper.assertTrue(owners(shaft).isEmpty(), "An invalid ownership epoch claimed the shaft"); + + extension.ctpp$claimMultiblockOwner(ownerPos, 1, 32.0F); + extension.ctpp$claimMultiblockOwner(ownerPos, 1, 32.0F); + helper.assertTrue(owners(shaft).size() == 1, "Repeated claims duplicated the same owner"); + helper.assertFalse(shaft.updateSpeed, "A claimed shaft still requested Create propagation"); + + // A newer controller at the same position is a distinct owner, not a position-only replacement. + extension.ctpp$claimMultiblockOwner(ownerPos, 2, 64.0F); + helper.assertTrue(owners(shaft).size() == 2, "Distinct ownership epochs were merged"); + extension.ctpp$releaseMultiblockOwner(ownerPos, 1); + ListTag remaining = owners(shaft); + helper.assertTrue(remaining.size() == 1, "Releasing one owner removed the other owner"); + helper.assertTrue(MultiblockOwner.load(remaining.getCompound(0)).equals(new MultiblockOwner(ownerPos, 2)), + "Releasing an old epoch removed the newer epoch"); + helper.assertTrue(remaining.getCompound(0).getFloat("VisualSpeed") == 64.0F, + "Releasing an old epoch changed the remaining owner's visual request"); + helper.assertFalse(shaft.updateSpeed, "Create propagation resumed while another owner remained"); + + extension.ctpp$releaseMultiblockOwner(ownerPos, 1); + helper.assertTrue(owners(shaft).size() == 1, "Repeated release removed an unrelated owner"); + extension.ctpp$releaseMultiblockOwner(ownerPos, 2); + helper.assertTrue(owners(shaft).isEmpty(), "The final owner was not released"); + helper.assertTrue(shaft.updateSpeed, "The final release did not resume Create propagation"); + helper.succeed(); + } + + @GameTest(templateNamespace = CTPP.MODID, template = "empty", batch = "KineticVisualOwnership") + public static void diskLoadPreservesOwnersWithoutRestoringVisualRequests(GameTestHelper helper) { + KineticBlockEntity original = placeShaft(helper); + IKineticBlockEntityExtension extension = (IKineticBlockEntityExtension) original; + MultiblockOwner first = new MultiblockOwner(helper.absolutePos(new BlockPos(2, 1, 0)), 7); + MultiblockOwner second = new MultiblockOwner(helper.absolutePos(new BlockPos(3, 1, 0)), 9); + extension.ctpp$claimMultiblockOwner(first.controllerPos(), first.instanceId(), 32.0F); + extension.ctpp$claimMultiblockOwner(second.controllerPos(), second.instanceId(), -64.0F); + CompoundTag saved = original.saveWithoutMetadata(); + ListTag savedOwners = saved.getList("CTPPMultiblockOwners", Tag.TAG_COMPOUND); + helper.assertTrue(savedOwners.size() == 2, "The fixture did not persist both owners"); + for (int i = 0; i < savedOwners.size(); i++) { + helper.assertTrue(savedOwners.getCompound(i).getFloat("VisualSpeed") != 0.0F, + "The fixture did not persist its active visual request"); + } + + KineticBlockEntity restored = (KineticBlockEntity) original.getType().create( + original.getBlockPos(), original.getBlockState()); + helper.assertTrue(restored != null, "The shaft block entity could not be recreated"); + restored.load(saved); + ListTag restoredOwners = owners(restored); + helper.assertTrue(restoredOwners.size() == 2, "Disk loading discarded persisted owners"); + boolean foundFirst = false; + boolean foundSecond = false; + for (int i = 0; i < restoredOwners.size(); i++) { + CompoundTag ownerTag = restoredOwners.getCompound(i); + MultiblockOwner owner = MultiblockOwner.load(ownerTag); + foundFirst |= owner.equals(first); + foundSecond |= owner.equals(second); + helper.assertTrue(ownerTag.getFloat("VisualSpeed") == 0.0F, + "Disk loading restored a visual request before controller revalidation"); + } + helper.assertTrue(foundFirst && foundSecond, "Disk loading changed an owner's position or epoch"); + helper.succeed(); + } + + private static KineticBlockEntity placeShaft(GameTestHelper helper) { + BlockPos pos = new BlockPos(0, 1, 0); + helper.setBlock(pos, AllBlocks.SHAFT.get()); + return (KineticBlockEntity) helper.getBlockEntity(pos); + } + + private static ListTag owners(KineticBlockEntity shaft) { + return shaft.saveWithoutMetadata().getList("CTPPMultiblockOwners", Tag.TAG_COMPOUND); + } +} diff --git a/src/test/java/com/mo_guang/ctpp/common/machine/multiblock/part/KineticSourceFirstTickTest.java b/src/test/java/com/mo_guang/ctpp/common/machine/multiblock/part/KineticSourceFirstTickTest.java new file mode 100644 index 0000000..6ebd83d --- /dev/null +++ b/src/test/java/com/mo_guang/ctpp/common/machine/multiblock/part/KineticSourceFirstTickTest.java @@ -0,0 +1,127 @@ +package com.mo_guang.ctpp.common.machine.multiblock.part; + +import com.gregtechceu.gtceu.api.GTValues; + +import net.minecraft.core.BlockPos; +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.Tag; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraftforge.gametest.GameTestHolder; +import net.minecraftforge.gametest.PrefixGameTestTemplate; + +import com.mo_guang.ctpp.CTPP; +import com.mo_guang.ctpp.common.blockentity.KineticMachineBlockEntity; +import com.mo_guang.ctpp.registry.CTPPMachines; +import com.simibubi.create.content.kinetics.KineticNetwork; + +@PrefixGameTestTemplate(false) +@GameTestHolder(CTPP.MODID) +public class KineticSourceFirstTickTest { + + private static final float EPSILON = 0.0001F; + + @GameTest(templateNamespace = CTPP.MODID, template = "empty", batch = "KineticSourcePersistence") + public static void firstTickConsumesOldSourceBeforeApplyingDesiredSpeed(GameTestHelper helper) { + BlockPos relativePos = new BlockPos(0, 1, 0); + BlockPos pos = helper.absolutePos(relativePos); + helper.setBlock(relativePos, CTPPMachines.KINETIC_OUTPUT_BOX[GTValues.LV].getBlock()); + KineticMachineBlockEntity original = (KineticMachineBlockEntity) helper.getBlockEntity(relativePos); + + // Seed the current persisted state directly; controller scheduling is covered by the ownership test. + original.workingSpeed = 48.0F; + original.updateGeneratedRotation(); + KineticNetwork restoredNetwork = original.getOrCreateNetwork(); + float loadedSourceCapacity = restoredNetwork.calculateCapacity(); + float loadedSourceStress = restoredNetwork.calculateStress(); + + // Capture a valid crash/restart boundary: Create still has the applied 48 rpm source identity, while GT has + // already requested 64 rpm for the next holder reconciliation. + original.workingSpeed = 64.0F; + CompoundTag saved = original.saveWithFullMetadata(); + assertFloat(helper, saved.getFloat("workingSpeed"), 64.0F, + "The fixture did not save the newer desired speed"); + assertFloat(helper, saved.getFloat("appliedGeneratedSpeed"), 48.0F, + "The fixture did not save the old applied source identity"); + + float unloadedCapacity = 640.0F; + float unloadedStress = 96.0F; + CompoundTag networkTag = saved.getCompound("Network"); + helper.assertTrue(networkTag.contains("AddedCapacity", Tag.TAG_FLOAT), + "The fixture did not save its individual source contribution"); + networkTag.putFloat("Capacity", loadedSourceCapacity + unloadedCapacity); + networkTag.putFloat("Stress", loadedSourceStress + unloadedStress); + networkTag.putInt("Size", 2); + KineticSourcePersistenceTest.addUnavailableOwner(helper, saved); + + // Preserve the network object exactly as a chunk unload would, but replace the holder with a fresh Java + // instance so SmartBlockEntity's first tick invokes initialize() through the production order. + original.onChunkUnloaded(); + helper.getLevel().removeBlockEntity(pos); + restoredNetwork.sources.clear(); + restoredNetwork.members.clear(); + restoredNetwork.initialized = false; + + ObservingKineticMachineBlockEntity restored = new ObservingKineticMachineBlockEntity( + original.getType(), pos, original.getBlockState()); + restored.load(saved); + helper.getLevel().setBlockEntity(restored); + // Forge queues onLoad until the next world tick. Observe that real path, including the machine tick before + // the holder tick, instead of manually invoking either lifecycle method out of order. + helper.runAfterDelay(1, () -> { + helper.assertTrue(restored.onLoadCalled, + "Installing the restored holder did not invoke its production onLoad path"); + + helper.assertTrue(restored.initializeCalls == 1, + "The first holder tick did not initialize the restored Create network exactly once"); + assertFloat(helper, restored.generatedSpeedAtInitialize, 48.0F, + "initialize() did not observe the old applied source identity"); + assertFloat(helper, restored.workingSpeed, 64.0F, + "The first holder tick discarded the newer desired speed"); + assertFloat(helper, restored.getAppliedGeneratedSpeed(), 64.0F, + "The first holder tick did not reconcile to the newer desired speed"); + helper.assertTrue(restored.getOrCreateNetwork() == restoredNetwork, + "The first holder tick replaced the persisted network object"); + assertFloat(helper, restoredNetwork.calculateCapacity(), + unloadedCapacity + loadedSourceCapacity * 64.0F / 48.0F, + "The first holder tick lost or double-counted unloaded capacity"); + assertFloat(helper, restoredNetwork.calculateStress(), + unloadedStress + loadedSourceStress * 64.0F / 48.0F, + "The first holder tick lost or double-counted unloaded stress"); + helper.assertTrue(restoredNetwork.getSize() == 2, + "The first holder tick lost the still-unloaded member count"); + helper.succeed(); + }); + } + + private static void assertFloat(GameTestHelper helper, float actual, float expected, String message) { + helper.assertTrue(Math.abs(actual - expected) <= EPSILON, + "%s (expected %s, got %s)".formatted(message, expected, actual)); + } + + private static final class ObservingKineticMachineBlockEntity extends KineticMachineBlockEntity { + + private boolean onLoadCalled; + private int initializeCalls; + private float generatedSpeedAtInitialize = Float.NaN; + + private ObservingKineticMachineBlockEntity(BlockEntityType type, BlockPos pos, BlockState state) { + super(type, pos, state); + } + + @Override + public void onLoad() { + onLoadCalled = true; + super.onLoad(); + } + + @Override + public void initialize() { + initializeCalls++; + generatedSpeedAtInitialize = getGeneratedSpeed(); + super.initialize(); + } + } +} diff --git a/src/test/java/com/mo_guang/ctpp/common/machine/multiblock/part/KineticSourcePersistenceTest.java b/src/test/java/com/mo_guang/ctpp/common/machine/multiblock/part/KineticSourcePersistenceTest.java new file mode 100644 index 0000000..ee62d62 --- /dev/null +++ b/src/test/java/com/mo_guang/ctpp/common/machine/multiblock/part/KineticSourcePersistenceTest.java @@ -0,0 +1,421 @@ +package com.mo_guang.ctpp.common.machine.multiblock.part; + +import com.gregtechceu.gtceu.api.GTValues; +import com.gregtechceu.gtceu.api.machine.IMachineBlockEntity; +import com.gregtechceu.gtceu.api.machine.trait.WorkLogic; +import com.gregtechceu.gtceu.api.pattern.MultiblockWorldSavedData; +import com.gregtechceu.gtceu.api.pattern.Predicates; + +import net.minecraft.core.BlockPos; +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.nbt.NbtUtils; +import net.minecraft.nbt.Tag; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraftforge.gametest.GameTestHolder; +import net.minecraftforge.gametest.PrefixGameTestTemplate; + +import com.mo_guang.ctpp.CTPP; +import com.mo_guang.ctpp.common.blockentity.KineticMachineBlockEntity; +import com.mo_guang.ctpp.common.machine.multiblock.KineticOutputMachine; +import com.mo_guang.ctpp.registry.CTPPMachines; +import com.mo_guang.ctpp.registry.CTPPMultiblockMachines; +import com.simibubi.create.content.kinetics.KineticNetwork; + +import java.util.Set; + +@PrefixGameTestTemplate(false) +@GameTestHolder(CTPP.MODID) +public class KineticSourcePersistenceTest { + + private static final String TEMPLATE_NAMESPACE = CTPP.MODID; + private static final String TEMPLATE = "empty"; + private static final String BATCH = "KineticSourcePersistence"; + private static final float EPSILON = 0.0001F; + + @GameTest(templateNamespace = TEMPLATE_NAMESPACE, template = TEMPLATE, batch = BATCH) + public static void stableRetuneAndSuspensionPreserveDesiredSource(GameTestHelper helper) { + KineticMachineBlockEntity source = placeOutputBox(helper, new BlockPos(0, 1, 0)); + + setDesiredAndReconcile(source, 32.0F); + KineticNetwork originalNetwork = source.getOrCreateNetwork(); + float originalCapacity = originalNetwork.calculateCapacity(); + + setDesiredAndReconcile(source, 64.0F); + helper.assertTrue(source.getOrCreateNetwork() == originalNetwork, + "Retuning a running independent source replaced its KineticNetwork object"); + assertFloat(helper, source.getAppliedGeneratedSpeed(), 64.0F, + "The retuned source did not publish its requested speed"); + helper.assertTrue(originalNetwork.calculateCapacity() > originalCapacity, + "The stable retune did not update network capacity"); + + source.setGeneratedSourceSuspended(true); + source.updateGeneratedRotation(); + assertFloat(helper, source.workingSpeed, 64.0F, + "Suspending the source discarded the desired speed"); + assertFloat(helper, source.getAppliedGeneratedSpeed(), 0.0F, + "Suspending the source left an applied generator speed"); + assertFloat(helper, source.getGeneratedSpeed(), 0.0F, + "A suspended output still advertised itself as a Create source"); + + source.setGeneratedSourceSuspended(false); + source.updateGeneratedRotation(); + assertFloat(helper, source.workingSpeed, 64.0F, + "Resuming the source changed the desired speed"); + assertFloat(helper, source.getAppliedGeneratedSpeed(), 64.0F, + "Resuming the source did not restore the desired speed"); + helper.assertTrue(source.hasNetwork(), "The resumed source did not rejoin a Create network"); + helper.succeed(); + } + + @GameTest(templateNamespace = TEMPLATE_NAMESPACE, template = TEMPLATE, batch = BATCH) + public static void restoredSourceConsumesItsSavedAggregateExactlyOnce(GameTestHelper helper) { + KineticMachineBlockEntity source = placeOutputBox(helper, new BlockPos(0, 1, 0)); + setDesiredAndReconcile(source, 48.0F); + + KineticNetwork restoredNetwork = source.getOrCreateNetwork(); + float loadedSourceCapacity = restoredNetwork.calculateCapacity(); + float loadedSourceStress = restoredNetwork.calculateStress(); + float unloadedCapacity = 640.0F; + float unloadedStress = 96.0F; + CompoundTag saved = source.saveWithFullMetadata(); + CompoundTag networkTag = saved.getCompound("Network"); + helper.assertTrue(networkTag.contains("AddedCapacity", Tag.TAG_FLOAT), + "The source fixture did not save its individual capacity contribution"); + networkTag.putFloat("Capacity", loadedSourceCapacity + unloadedCapacity); + networkTag.putFloat("Stress", loadedSourceStress + unloadedStress); + networkTag.putInt("Size", 2); + addUnavailableOwner(helper, saved); + + // Loading NBT into the already initialized fixture would retain Create's wasMoved flag and deliberately skip + // kinetic data. A disk reload always creates a fresh holder, so exercise that real lifecycle instead. + ObservingKineticMachineBlockEntity restored = restoreFreshHolder(helper, new BlockPos(0, 1, 0), source, + restoredNetwork, saved); + helper.runAfterDelay(1, () -> { + helper.assertTrue(restored.onLoadCalled, + "The fresh aggregate holder did not run its production onLoad path"); + helper.assertTrue(restored.initializeCalls == 1, + "The fresh aggregate holder did not initialize exactly once on its first tick"); + assertFloat(helper, restored.generatedSpeedAtInitialize, 48.0F, + "initialize() did not consume the saved applied source identity"); + helper.assertTrue(restored.getOrCreateNetwork() == restoredNetwork, + "Loading the source replaced the persisted KineticNetwork identity"); + assertFloat(helper, restoredNetwork.calculateCapacity(), loadedSourceCapacity + unloadedCapacity, + "initialize/addSilently double-counted or lost the saved source contribution"); + assertFloat(helper, restoredNetwork.calculateStress(), loadedSourceStress + unloadedStress, + "initialize/addSilently double-counted or lost saved network stress"); + helper.assertTrue(restoredNetwork.getSize() == 2, + "The restored source did not consume exactly one unloaded member slot"); + + restored.initialize(); + assertFloat(helper, restoredNetwork.calculateCapacity(), loadedSourceCapacity + unloadedCapacity, + "A repeated initialize call consumed or added the source contribution twice"); + assertFloat(helper, restoredNetwork.calculateStress(), loadedSourceStress + unloadedStress, + "A repeated initialize call consumed or added source stress twice"); + helper.assertTrue(restoredNetwork.getSize() == 2, + "A repeated initialize call changed the restored network size"); + + setDesiredAndReconcile(restored, 64.0F); + helper.assertTrue(restored.getOrCreateNetwork() == restoredNetwork, + "Stable retuning replaced the restored network containing an unloaded member"); + assertFloat(helper, restoredNetwork.calculateCapacity(), + unloadedCapacity + loadedSourceCapacity * 64.0F / 48.0F, + "Stable retuning lost or modified the unloaded capacity balance"); + assertFloat(helper, restoredNetwork.calculateStress(), + unloadedStress + loadedSourceStress * 64.0F / 48.0F, + "Stable retuning lost or modified the unloaded stress balance"); + helper.assertTrue(restoredNetwork.getSize() == 2, + "Stable retuning lost the still-unloaded member count"); + helper.succeed(); + }); + } + + @GameTest(templateNamespace = TEMPLATE_NAMESPACE, template = TEMPLATE, batch = BATCH) + public static void unavailableExactOwnerSuspendsAfterBoundedReloadGrace(GameTestHelper helper) { + BlockPos relativePos = new BlockPos(0, 1, 0); + KineticMachineBlockEntity source = placeOutputBox(helper, relativePos); + setDesiredAndReconcile(source, 48.0F); + CompoundTag saved = source.saveWithFullMetadata(); + addUnavailableOwner(helper, saved); + ObservingKineticMachineBlockEntity restored = restoreFreshHolder(helper, relativePos, source, + source.getOrCreateNetwork(), saved); + helper.runAfterDelay(1, () -> { + assertFloat(helper, restored.getAppliedGeneratedSpeed(), 48.0F, + "An unavailable exact owner did not receive the initial reload grace"); + helper.assertFalse(restored.isGeneratedSourceSuspended(), + "The source was suspended before its reload grace elapsed"); + }); + helper.runAfterDelay(45, () -> { + assertFloat(helper, restored.getAppliedGeneratedSpeed(), 0.0F, + "The unavailable owner kept publishing stress after its bounded grace"); + assertFloat(helper, restored.workingSpeed, 48.0F, + "An unavailable owner lost its persisted desired output"); + helper.assertTrue(restored.isGeneratedSourceSuspended(), + "The unavailable source did not persist its suspended state"); + KineticPartMachine part = (KineticPartMachine) restored.getMetaMachine(); + helper.assertTrue(part.getControllerBindingInstanceId( + NbtUtils.readBlockPos(saved.getCompound("lastControllerPos"))) == 1L, + "An unavailable owner lost its exact epoch binding"); + helper.succeed(); + }); + } + + @GameTest(templateNamespace = TEMPLATE_NAMESPACE, template = TEMPLATE, batch = BATCH) + public static void retiredUnavailableOwnerStopsDuringReloadGrace(GameTestHelper helper) { + BlockPos relativePos = new BlockPos(0, 1, 0); + KineticMachineBlockEntity source = placeOutputBox(helper, relativePos); + setDesiredAndReconcile(source, 48.0F); + CompoundTag saved = source.saveWithFullMetadata(); + addUnavailableOwner(helper, saved); + ObservingKineticMachineBlockEntity restored = restoreFreshHolder(helper, relativePos, source, + source.getOrCreateNetwork(), saved); + helper.runAfterDelay(5, () -> { + KineticPartMachine part = (KineticPartMachine) restored.getMetaMachine(); + BlockPos ownerPos = NbtUtils.readBlockPos(saved.getCompound("lastControllerPos")); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + helper.assertFalse(savedData.isControllerPositionLoadedNoChunkRequest(ownerPos), + "The fixture owner became observable before retirement"); + CompoundTag beforeRetirement = new CompoundTag(); + restored.saveManagedPersistentData(beforeRetirement, false); + helper.assertTrue(beforeRetirement.getLong("bindingReloadGraceDeadline") > helper.getLevel().getGameTime(), + "The fixture's reload grace expired before retirement"); + assertFloat(helper, restored.getAppliedGeneratedSpeed(), 48.0F, + "The fixture source stopped before its owner was retired"); + + savedData.retireControllerBinding(ownerPos, 1L); + // Check immediately, without waiting for the base part's periodic binding reconciliation. + part.checkOutputBinding(); + assertFloat(helper, restored.workingSpeed, 0.0F, + "A retired unavailable owner retained its desired output during reload grace"); + helper.assertTrue(part.getControllerBindingInstanceId(ownerPos) == 0, + "A retired unavailable owner retained its exact epoch binding"); + CompoundTag afterRetirement = new CompoundTag(); + restored.saveManagedPersistentData(afterRetirement, false); + helper.assertTrue(afterRetirement.getLong("bindingReloadGraceDeadline") == 0, + "Definitive retirement retained a reload grace deadline"); + helper.assertFalse(afterRetirement.contains("lastControllerPos"), + "Definitive retirement retained the persisted controller position"); + assertFloat(helper, restored.getAppliedGeneratedSpeed(), 48.0F, + "The binding check changed the applied source before holder reconciliation"); + + restored.updateGeneratedRotation(); + assertFloat(helper, restored.getAppliedGeneratedSpeed(), 0.0F, + "Holder reconciliation did not withdraw the retired owner's source"); + helper.succeed(); + }); + } + + @GameTest(templateNamespace = TEMPLATE_NAMESPACE, template = TEMPLATE, batch = BATCH) + public static void diskAndDropNbtKeepSourceStateSeparate(GameTestHelper helper) { + KineticMachineBlockEntity source = placeOutputBox(helper, new BlockPos(0, 1, 0)); + setDesiredAndReconcile(source, 40.0F); + source.setGeneratedSourceSuspended(true); + source.updateGeneratedRotation(); + + CompoundTag worldTag = new CompoundTag(); + source.saveManagedPersistentData(worldTag, false); + assertFloat(helper, worldTag.getFloat("workingSpeed"), 40.0F, + "Normal disk NBT did not retain the desired speed"); + assertFloat(helper, worldTag.getFloat("appliedGeneratedSpeed"), 0.0F, + "Normal disk NBT did not retain the suspended applied speed"); + helper.assertTrue(worldTag.getBoolean("generatedSourceSuspended"), + "Normal disk NBT did not retain the suspension state"); + + CompoundTag dropTag = new CompoundTag(); + source.saveManagedPersistentData(dropTag, true); + assertNoLiveSourceState(helper, dropTag); + helper.succeed(); + } + + @GameTest(templateNamespace = TEMPLATE_NAMESPACE, template = TEMPLATE, batch = BATCH) + public static void orphanedCurrentSourceStopsAfterSavedContributionIsRestored(GameTestHelper helper) { + KineticMachineBlockEntity source = placeOutputBox(helper, new BlockPos(0, 1, 0)); + setDesiredAndReconcile(source, 48.0F); + // A save may happen after ownership is cleared and before the holder reconciles the old applied source. + source.stopWorking(); + CompoundTag saved = source.saveWithFullMetadata(); + + KineticNetwork restoredNetwork = source.getOrCreateNetwork(); + ObservingKineticMachineBlockEntity restored = restoreFreshHolder(helper, new BlockPos(0, 1, 0), source, + restoredNetwork, saved); + assertFloat(helper, restored.workingSpeed, 0.0F, + "The orphan fixture did not restore its zero desired speed"); + assertFloat(helper, restored.getAppliedGeneratedSpeed(), 48.0F, + "Loading discarded the saved source identity before Create could consume it"); + + helper.runAfterDelay(1, () -> { + helper.assertTrue(restored.onLoadCalled, + "The fresh orphan holder did not run its production onLoad path"); + helper.assertTrue(restored.initializeCalls == 1, + "The fresh orphan holder did not initialize exactly once on its first tick"); + assertFloat(helper, restored.generatedSpeedAtInitialize, 48.0F, + "initialize() did not observe the persisted applied source identity"); + assertFloat(helper, restored.getAppliedGeneratedSpeed(), 0.0F, + "First-tick reconciliation did not withdraw the orphaned source"); + assertFloat(helper, restored.workingSpeed, 0.0F, + "First-tick reconciliation revived the orphaned output request"); + helper.succeed(); + }); + } + + @GameTest(templateNamespace = TEMPLATE_NAMESPACE, template = TEMPLATE, batch = BATCH) + public static void pendingExactOwnerSuspendsAndOperationalOwnerRestores(GameTestHelper helper) { + BlockPos controllerPos = new BlockPos(0, 1, 0); + BlockPos partPos = new BlockPos(0, 2, 0); + helper.setBlock(controllerPos, CTPPMultiblockMachines.BOOM_OF_CREATE.getBlock()); + KineticMachineBlockEntity source = placeOutputBox(helper, partPos); + KineticPartMachine part = (KineticPartMachine) source.getMetaMachine(); + KineticOutputMachine controller = (KineticOutputMachine) ((IMachineBlockEntity) helper + .getBlockEntity(controllerPos)).getMetaMachine(); + + var state = controller.getMultiblockState(); + state.clean(); + state.addPosCache(controller.getPos(), Predicates.any()); + state.addPosCache(part.getPos(), Predicates.any()); + state.getMatchContext().set("parts", Set.of(part)); + controller.onStructureFormed(); + var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel()); + helper.assertTrue(savedData.tryAddMapping(state), + "The exact-controller fixture could not install its validated mapping"); + controller.getWorkLogic().setStatus(WorkLogic.Status.WORKING); + + source.scheduleWorking(36.0F * source.getDefinition().getTorque()); + source.updateGeneratedRotation(); + helper.assertTrue(source.isGeneratedSourceSuspended(), + "The source became live before its exact validated binding was reconciled"); + part.checkOutputBinding(); + source.updateGeneratedRotation(); + assertFloat(helper, source.getAppliedGeneratedSpeed(), 36.0F, + "An operational exact owner with runtime membership did not restore the source"); + + CompoundTag partWorldTag = new CompoundTag(); + source.saveManagedPersistentData(partWorldTag, false); + helper.assertTrue(partWorldTag.contains("lastControllerPos"), + "Normal disk NBT did not retain the output's controller position"); + helper.assertTrue(partWorldTag.contains("gtceuMultiblockControllerBindings", Tag.TAG_LIST), + "Normal disk NBT did not retain the output's exact controller epoch"); + CompoundTag partDropTag = new CompoundTag(); + source.saveManagedPersistentData(partDropTag, true); + helper.assertFalse(partDropTag.contains("lastControllerPos"), + "Drop NBT copied the output's controller position"); + helper.assertFalse(partDropTag.contains("gtceuMultiblockControllerBindings"), + "Drop NBT copied the output's exact controller epoch"); + assertNoLiveSourceState(helper, partDropTag); + + controller.setStructureRevalidationPending(true); + part.checkOutputBinding(); + source.updateGeneratedRotation(); + assertFloat(helper, source.workingSpeed, 36.0F, + "A pending exact owner discarded the desired output request"); + assertFloat(helper, source.getAppliedGeneratedSpeed(), 0.0F, + "A known pending exact owner received the unavailable-owner grace period"); + + controller.setStructureRevalidationPending(false); + part.checkOutputBinding(); + source.updateGeneratedRotation(); + assertFloat(helper, source.getAppliedGeneratedSpeed(), 36.0F, + "The exact operational owner did not resume its suspended source"); + + long retiredInstance = controller.getStructureInstanceId(); + savedData.retireController(controller); + part.checkOutputBinding(); + source.updateGeneratedRotation(); + assertFloat(helper, source.getAppliedGeneratedSpeed(), 0.0F, + "A retired exact owner remained an applied source"); + + part.reconcileControllerBindings(savedData); + source.updateGeneratedRotation(); + helper.assertTrue(part.getControllerBindingInstanceId(controller.getPos()) == 0, + "The retired exact controller epoch remained bound to the output"); + assertFloat(helper, source.workingSpeed, 0.0F, + "Definitive controller retirement did not clear the obsolete desired request"); + helper.assertTrue(savedData.isControllerBindingRetired(controller.getPos(), retiredInstance), + "The controller retirement tombstone was lost during output reconciliation"); + helper.succeed(); + } + + private static KineticMachineBlockEntity placeOutputBox(GameTestHelper helper, BlockPos pos) { + helper.setBlock(pos, CTPPMachines.KINETIC_OUTPUT_BOX[GTValues.LV].getBlock()); + return (KineticMachineBlockEntity) helper.getBlockEntity(pos); + } + + private static ObservingKineticMachineBlockEntity restoreFreshHolder(GameTestHelper helper, BlockPos relativePos, + KineticMachineBlockEntity original, + KineticNetwork restoredNetwork, + CompoundTag saved) { + BlockPos absolutePos = helper.absolutePos(relativePos); + original.onChunkUnloaded(); + helper.getLevel().removeBlockEntity(absolutePos); + restoredNetwork.sources.clear(); + restoredNetwork.members.clear(); + restoredNetwork.initialized = false; + + ObservingKineticMachineBlockEntity restored = new ObservingKineticMachineBlockEntity( + original.getType(), absolutePos, original.getBlockState()); + restored.load(saved); + helper.getLevel().setBlockEntity(restored); + return restored; + } + + private static void setDesiredAndReconcile(KineticMachineBlockEntity source, float speed) { + // Isolate Create accounting from controller recipes; the ownership test uses scheduleWorking instead. + source.workingSpeed = speed; + source.updateGeneratedRotation(); + } + + static void addUnavailableOwner(GameTestHelper helper, CompoundTag saved) { + // A current-format source may outlive its controller's loaded chunk. Give the disk fixture an exact owner + // outside the test's loaded area so the real part tick preserves it during the bounded reload grace. + BlockPos ownerPos = helper.absolutePos(new BlockPos(1_000_000, 1, 1_000_000)); + helper.assertFalse(MultiblockWorldSavedData.getOrCreate(helper.getLevel()) + .isControllerPositionLoadedNoChunkRequest(ownerPos), "The fixture owner position is already ticking"); + saved.put("lastControllerPos", NbtUtils.writeBlockPos(ownerPos)); + CompoundTag binding = new CompoundTag(); + binding.putLong("pos", ownerPos.asLong()); + binding.putLong("instanceId", 1L); + ListTag bindings = new ListTag(); + bindings.add(binding); + saved.put("gtceuMultiblockControllerBindings", bindings); + } + + private static void assertNoLiveSourceState(GameTestHelper helper, CompoundTag tag) { + helper.assertFalse(tag.contains("workingSpeed"), "Drop NBT copied the desired source speed"); + helper.assertFalse(tag.contains("appliedGeneratedSpeed"), "Drop NBT copied the applied source speed"); + helper.assertFalse(tag.contains("generatedSourceSuspended"), "Drop NBT copied the source suspension state"); + helper.assertFalse(tag.contains("Speed"), "Drop NBT copied Create's live kinetic speed"); + helper.assertFalse(tag.contains("Source"), "Drop NBT copied Create's external source position"); + helper.assertFalse(tag.contains("Network"), "Drop NBT copied Create's live network aggregate"); + } + + private static void assertFloat(GameTestHelper helper, float actual, float expected, String message) { + helper.assertTrue(Math.abs(actual - expected) <= EPSILON, + "%s (expected %s, got %s)".formatted(message, expected, actual)); + } + + private static final class ObservingKineticMachineBlockEntity extends KineticMachineBlockEntity { + + private boolean onLoadCalled; + private int initializeCalls; + private float generatedSpeedAtInitialize = Float.NaN; + + private ObservingKineticMachineBlockEntity(BlockEntityType type, BlockPos pos, BlockState state) { + super(type, pos, state); + } + + @Override + public void onLoad() { + onLoadCalled = true; + super.onLoad(); + } + + @Override + public void initialize() { + initializeCalls++; + generatedSpeedAtInitialize = getGeneratedSpeed(); + super.initialize(); + } + } +}