From 0c24ac4ed181341b23f066935e803109c98e7322 Mon Sep 17 00:00:00 2001 From: turtton Date: Sun, 10 May 2026 00:02:31 +0900 Subject: [PATCH 1/2] feat: add sneaking placement isolation for tanks When placing a tank by shift+right-clicking on a non-tank block, the new tank is placed as an isolated group instead of connecting to adjacent tanks. Clicking directly on a tank while sneaking still connects to that tank's group via interactedAt. Closes #47 --- .../test/ConnectedTankGameTest.kt | 76 ++++++++++++++++++- .../mixin/BlockItemPlaceMixin.java | 2 + .../connectedtank/block/ConnectedTankBlock.kt | 7 +- .../block/ConnectedTankPlacementContext.kt | 8 ++ 4 files changed, 91 insertions(+), 2 deletions(-) diff --git a/src/gametest/kotlin/net/turtton/connectedtank/test/ConnectedTankGameTest.kt b/src/gametest/kotlin/net/turtton/connectedtank/test/ConnectedTankGameTest.kt index 276a89b..c9c1eab 100644 --- a/src/gametest/kotlin/net/turtton/connectedtank/test/ConnectedTankGameTest.kt +++ b/src/gametest/kotlin/net/turtton/connectedtank/test/ConnectedTankGameTest.kt @@ -36,7 +36,7 @@ object ConnectedTankGameTest { * @param tankPos タンクを置きたい相対座標 (y >= 2) * @param tier 設置するタンクのティア (デフォルト BASE) */ - private fun GameTestHelper.placeTank(tankPos: BlockPos, tier: TankTier = TankTier.BASE) { + private fun GameTestHelper.placeTank(tankPos: BlockPos, tier: TankTier = TankTier.BASE, sneaking: Boolean = false) { val basePos = tankPos.below() setBlock(basePos, Blocks.STONE) val player = makeMockPlayer(GameType.SURVIVAL) @@ -45,6 +45,7 @@ object ConnectedTankGameTest { } val stack = ItemStack(item) player.setItemInHand(net.minecraft.world.InteractionHand.MAIN_HAND, stack) + if (sneaking) player.setShiftKeyDown(true) placeAt(player, stack, basePos.below(), Direction.UP) } @@ -1193,4 +1194,77 @@ object ConnectedTankGameTest { ) context.succeed() } + + // === スニーク設置分離テスト === + + //? if fabric { + @GameTest + //?} + fun sneakingPlacementOnNonTankCreatesIsolatedGroup(context: GameTestHelper) { + val pos1 = BlockPos(0, 2, 0) + val pos2 = BlockPos(1, 2, 0) + context.placeTank(pos1) + context.placeTank(pos2, sneaking = true) + + val state = context.getFluidState() + val storage1 = state.getStorage(context.absolutePos(pos1)) + val storage2 = state.getStorage(context.absolutePos(pos2)) + context.assertTrue(storage1 != null, Component.literal("Storage1 should exist")) + context.assertTrue(storage2 != null, Component.literal("Storage2 should exist")) + context.assertTrue( + storage1 !== storage2, + Component.literal("Sneaking placement should create isolated group"), + ) + context.assertTrue( + storage1!!.bucketCapacity == CTServerConfig.DEFAULT_BUCKET_CAPACITY, + Component.literal("First tank should have single capacity"), + ) + context.assertTrue( + storage2!!.bucketCapacity == CTServerConfig.DEFAULT_BUCKET_CAPACITY, + Component.literal("Second tank should have single capacity"), + ) + context.succeed() + } + + //? if fabric { + @GameTest + //?} + fun normalPlacementNextToTankConnects(context: GameTestHelper) { + val pos1 = BlockPos(0, 2, 0) + val pos2 = BlockPos(1, 2, 0) + context.placeTank(pos1) + context.placeTank(pos2, sneaking = false) + + val state = context.getFluidState() + val storage1 = state.getStorage(context.absolutePos(pos1)) + val storage2 = state.getStorage(context.absolutePos(pos2)) + context.assertTrue(storage1 != null, Component.literal("Storage1 should exist")) + context.assertTrue(storage2 != null, Component.literal("Storage2 should exist")) + context.assertTrue( + storage1 === storage2, + Component.literal("Normal placement should connect to adjacent tank"), + ) + context.succeed() + } + + //? if fabric { + @GameTest + //?} + fun sneakingPlacementBetweenTwoGroupsStaysIsolated(context: GameTestHelper) { + val posA = BlockPos(0, 2, 0) + val posB = BlockPos(2, 2, 0) + val posMid = BlockPos(1, 2, 0) + context.placeTank(posA) + context.placeTank(posB) + context.placeTank(posMid, sneaking = true) + + val state = context.getFluidState() + val sA = state.getStorage(context.absolutePos(posA)) + val sMid = state.getStorage(context.absolutePos(posMid)) + val sB = state.getStorage(context.absolutePos(posB)) + context.assertTrue(sA !== sMid, Component.literal("Sneaking mid should not connect to A")) + context.assertTrue(sB !== sMid, Component.literal("Sneaking mid should not connect to B")) + context.assertTrue(sA !== sB, Component.literal("A and B should remain separate")) + context.succeed() + } } diff --git a/src/main/java/net/turtton/connectedtank/mixin/BlockItemPlaceMixin.java b/src/main/java/net/turtton/connectedtank/mixin/BlockItemPlaceMixin.java index e1340ca..97734bd 100644 --- a/src/main/java/net/turtton/connectedtank/mixin/BlockItemPlaceMixin.java +++ b/src/main/java/net/turtton/connectedtank/mixin/BlockItemPlaceMixin.java @@ -30,6 +30,8 @@ public class BlockItemPlaceMixin { hitPos = context.getClickedPos().relative(context.getClickedFace().getOpposite()); } ConnectedTankPlacementContext.INSTANCE.setInteractedAt(hitPos); + var player = context.getPlayer(); + ConnectedTankPlacementContext.INSTANCE.setSneaking(player != null && player.isSecondaryUseActive()); } /** diff --git a/src/main/kotlin/net/turtton/connectedtank/block/ConnectedTankBlock.kt b/src/main/kotlin/net/turtton/connectedtank/block/ConnectedTankBlock.kt index e47d824..33952e4 100644 --- a/src/main/kotlin/net/turtton/connectedtank/block/ConnectedTankBlock.kt +++ b/src/main/kotlin/net/turtton/connectedtank/block/ConnectedTankBlock.kt @@ -185,7 +185,12 @@ class ConnectedTankBlock(val tier: TankTier, settings: Properties) : val interactedAt = ConnectedTankPlacementContext.consumeInteractedAt()?.takeIf { CTBlocks.isConnectedTank(world.getBlockState(it).block) } - persistentState.addStorage(pos, tankStorage, interactedAt) + val sneaking = ConnectedTankPlacementContext.consumeSneaking() + if (sneaking && interactedAt == null) { + persistentState.addIsolatedStorage(pos, tankStorage) + } else { + persistentState.addStorage(pos, tankStorage, interactedAt) + } CTBlocks.syncGroupBlockEntities(world, pos, persistentState) } } diff --git a/src/main/kotlin/net/turtton/connectedtank/block/ConnectedTankPlacementContext.kt b/src/main/kotlin/net/turtton/connectedtank/block/ConnectedTankPlacementContext.kt index 25b1c86..97d538e 100644 --- a/src/main/kotlin/net/turtton/connectedtank/block/ConnectedTankPlacementContext.kt +++ b/src/main/kotlin/net/turtton/connectedtank/block/ConnectedTankPlacementContext.kt @@ -4,18 +4,26 @@ import net.minecraft.core.BlockPos object ConnectedTankPlacementContext { private val interactedAt = ThreadLocal() + private val sneaking = ThreadLocal() fun setInteractedAt(pos: BlockPos) { interactedAt.set(pos) } + fun setSneaking(value: Boolean) { + sneaking.set(value) + } + /** * interactedAt を取得して ThreadLocal から削除する。 * Mixin の RETURN inject が例外で到達しない場合、この呼び出しが唯一のクリーンアップ手段となる。 */ fun consumeInteractedAt(): BlockPos? = interactedAt.get()?.also { interactedAt.remove() } + fun consumeSneaking(): Boolean = sneaking.get() ?: false + fun clear() { interactedAt.remove() + sneaking.remove() } } From 4405ff439ac9e0e392ef5944a0a7f75d128fcade Mon Sep 17 00:00:00 2001 From: turtton Date: Sun, 10 May 2026 00:40:26 +0900 Subject: [PATCH 2/2] fix: consume sneaking ThreadLocal properly and strengthen tests - consumeSneaking() now calls remove() to prevent stale state leak - Add block state CONNECTED_* assertions to sneaking isolation test - Add regression test: sneaking does not leak to next normal placement --- .../test/ConnectedTankGameTest.kt | 34 +++++++++++++++++++ .../block/ConnectedTankPlacementContext.kt | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/gametest/kotlin/net/turtton/connectedtank/test/ConnectedTankGameTest.kt b/src/gametest/kotlin/net/turtton/connectedtank/test/ConnectedTankGameTest.kt index c9c1eab..0cdc67e 100644 --- a/src/gametest/kotlin/net/turtton/connectedtank/test/ConnectedTankGameTest.kt +++ b/src/gametest/kotlin/net/turtton/connectedtank/test/ConnectedTankGameTest.kt @@ -1223,6 +1223,17 @@ object ConnectedTankGameTest { storage2!!.bucketCapacity == CTServerConfig.DEFAULT_BUCKET_CAPACITY, Component.literal("Second tank should have single capacity"), ) + + val blockState1 = context.level.getBlockState(context.absolutePos(pos1)) + val blockState2 = context.level.getBlockState(context.absolutePos(pos2)) + context.assertTrue( + !blockState1.getValue(ConnectedTankBlock.CONNECTED_EAST), + Component.literal("pos1 CONNECTED_EAST should be false"), + ) + context.assertTrue( + !blockState2.getValue(ConnectedTankBlock.CONNECTED_WEST), + Component.literal("pos2 CONNECTED_WEST should be false"), + ) context.succeed() } @@ -1267,4 +1278,27 @@ object ConnectedTankGameTest { context.assertTrue(sA !== sB, Component.literal("A and B should remain separate")) context.succeed() } + + //? if fabric { + @GameTest + //?} + fun sneakingDoesNotLeakToNextPlacement(context: GameTestHelper) { + val posA = BlockPos(0, 2, 0) + val posB = BlockPos(1, 2, 0) + val posC = BlockPos(2, 2, 0) + context.placeTank(posA) + context.placeTank(posB, sneaking = true) + context.placeTank(posC, sneaking = false) + + val state = context.getFluidState() + val sA = state.getStorage(context.absolutePos(posA)) + val sB = state.getStorage(context.absolutePos(posB)) + val sC = state.getStorage(context.absolutePos(posC)) + context.assertTrue(sA !== sB, Component.literal("B should be isolated from A")) + context.assertTrue( + sB === sC, + Component.literal("C (normal) should connect to B, not stay isolated from stale sneaking"), + ) + context.succeed() + } } diff --git a/src/main/kotlin/net/turtton/connectedtank/block/ConnectedTankPlacementContext.kt b/src/main/kotlin/net/turtton/connectedtank/block/ConnectedTankPlacementContext.kt index 97d538e..eed7a15 100644 --- a/src/main/kotlin/net/turtton/connectedtank/block/ConnectedTankPlacementContext.kt +++ b/src/main/kotlin/net/turtton/connectedtank/block/ConnectedTankPlacementContext.kt @@ -20,7 +20,7 @@ object ConnectedTankPlacementContext { */ fun consumeInteractedAt(): BlockPos? = interactedAt.get()?.also { interactedAt.remove() } - fun consumeSneaking(): Boolean = sneaking.get() ?: false + fun consumeSneaking(): Boolean = (sneaking.get() ?: false).also { sneaking.remove() } fun clear() { interactedAt.remove()