Skip to content

Implement basic fluid storage#14

Merged
turtton merged 11 commits intomainfrom
resolve/2
Mar 6, 2026
Merged

Implement basic fluid storage#14
turtton merged 11 commits intomainfrom
resolve/2

Conversation

@turtton
Copy link
Owner

@turtton turtton commented Oct 4, 2025

resolve: #2

turtton added 3 commits March 6, 2026 10:15
- 隣接タンクの自動結合ロジックを addStorage に実装
- removeStorage を結合ストレージ対応に修正(容量削減・液体量の切り詰め)
- TankFluidStorage に onChanged コールバックを追加し、液体変更時に PersistentState の dirty フラグを自動通知
- クライアント側フォールバックとして空の TankFluidStorage を返すように修正
- logger を公開に変更
開発時の動作確認用に REI (レシピビューア) と Jade (ブロック情報表示) を追加
Fabric GameTest API を使用したサーバーサイドテストを追加
- タンク配置・隣接結合・分離・液体操作・異種液体拒否の 7 テストケース
- useStackOnBlock による正しい onPlaced 発火を検証
@turtton turtton requested a review from Copilot March 6, 2026 05:20
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements server-persistent fluid storage for the Connected Tank block, enabling adjacent tanks to share capacity and supporting fluid transfer via Fabric Transfer API, with GameTests added to validate basic behaviors.

Changes:

  • Added FluidStoragePersistentState to persist and manage per-position tank storage groups (UUID → storage).
  • Introduced TankFluidStorage (SingleVariantStorage) with Codec serialization and dirty-marking hooks.
  • Registered the block as a sided fluid storage provider and added a gametest source set with multiple placement/merge tests.

Reviewed changes

Copilot reviewed 9 out of 10 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/main/kotlin/net/turtton/connectedtank/world/FluidStoragePersistentState.kt New persistent state storing tank position→UUID and UUID→storage maps, with add/remove/merge logic and codecs.
src/main/kotlin/net/turtton/connectedtank/block/TankFluidStorage.kt New single-variant fluid storage with capacity in buckets, commit hook, and codec.
src/main/kotlin/net/turtton/connectedtank/block/ConnectedTankBlock.kt New block integrating persistent state on placement/break and item-based fluid interaction.
src/main/kotlin/net/turtton/connectedtank/block/CTBlocks.kt Registers the connected tank block and exposes it as a Fabric Transfer sided fluid storage.
src/main/kotlin/net/turtton/connectedtank/ConnectedTank.kt Exposes logger publicly (was private).
src/gametest/resources/fabric.mod.json Adds a separate gametest mod entrypoint (connectedtank-test).
src/gametest/kotlin/net/turtton/connectedtank/test/ConnectedTankGameTest.kt Adds GameTests for placement, adjacency sharing, removal capacity reduction, and non-merge cases.
gradle/libs.versions.toml Adds REI and Jade version/catalog entries.
build.gradle.kts Adds repositories, enables Loom gametests source set, and adds runtime-only REI/Jade dependencies.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +18 to +24
override fun onBreak(world: World?, pos: BlockPos?, state: BlockState?, player: PlayerEntity?): BlockState? {
if (world is ServerWorld && pos != null) {
val storage = world.persistentStateManager.getOrCreate(FluidStoragePersistentState.TYPE)
storage.removeStorage(pos)
}
return super.onBreak(world, pos, state, player)
}
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Storage cleanup is only hooked into onBreak, so tanks removed via other pathways (explosions, /setblock, structure generation, block replacement in onStateReplaced, etc.) can leave stale entries in FluidStoragePersistentState (wrong capacity + unbounded growth of positionalStorageMap). Consider also removing storage when the block is actually removed/replaced (e.g., in onStateReplaced when newState.block != this, and/or onDestroyedByExplosion).

Copilot uses AI. Check for mistakes.
fun init() {
FluidStorage.SIDED.registerForBlocks({ world, pos, _, _, _ ->
// FIXME: Provide tank detail to client
(world as? ServerWorld)?.persistentStateManager?.get(FluidStoragePersistentState.TYPE)?.getStorage(pos) ?: TankFluidStorage()
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

registerForBlocks falls back to TankFluidStorage() when the persistent state is missing. That allows fluid insert/extract calls to succeed against a temporary in-memory storage that is never persisted (and not shared with the actual tank network), causing fluid loss/inconsistent behavior. Prefer always using getOrCreate(FluidStoragePersistentState.TYPE) on the server, and if getStorage(pos) is null for a placed tank, initialize it (e.g., addStorage(pos, TankFluidStorage())) rather than returning a fresh storage instance.

Suggested change
(world as? ServerWorld)?.persistentStateManager?.get(FluidStoragePersistentState.TYPE)?.getStorage(pos) ?: TankFluidStorage()
val serverWorld = world as? ServerWorld ?: return@registerForBlocks null
val state = serverWorld.persistentStateManager.getOrCreate(FluidStoragePersistentState.TYPE)
var storage = state.getStorage(pos)
if (storage == null) {
storage = TankFluidStorage()
state.addStorage(pos, storage)
}
storage

Copilot uses AI. Check for mistakes.
Comment on lines +26 to +42
val storageId = interactedAt?.let(positionalStorageMap::get)
?: listOf(
pos.add(-1, 0, 0),
pos.add(1, 0, 0),
pos.add(0, 0, -1),
pos.add(0, 0, 1),
pos.add(0, -1, 0),
pos.add(0, 1, 0),
).firstNotNullOfOrNull(positionalStorageMap::get)
val existingStorage = storageId?.let(storageMap::get)
if (existingStorage != null && (storage.isResourceBlank || storage.variant == existingStorage.variant)) {
val newBucketCap = storage.bucketCapacity + existingStorage.bucketCapacity
val existingData = TankFluidStorage.ExistingData(existingStorage.variant, existingStorage.amount)
val newStorage = TankFluidStorage(newBucketCap, existingData).also { it.onChanged = ::markDirty }
storageMap[storageId] = newStorage
positionalStorageMap[pos] = storageId
} else {
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addStorage picks only the first adjacent storage id and attaches the new tank to it. If the placed tank touches multiple existing tank groups with different UUIDs, this will leave the world in a partially-connected state (two storages remain separate even though tanks are now connected). Consider detecting all neighboring UUIDs, validating merge compatibility, then merging/remapping all involved positions to a single UUID (e.g., BFS/union-find over connected positions).

Suggested change
val storageId = interactedAt?.let(positionalStorageMap::get)
?: listOf(
pos.add(-1, 0, 0),
pos.add(1, 0, 0),
pos.add(0, 0, -1),
pos.add(0, 0, 1),
pos.add(0, -1, 0),
pos.add(0, 1, 0),
).firstNotNullOfOrNull(positionalStorageMap::get)
val existingStorage = storageId?.let(storageMap::get)
if (existingStorage != null && (storage.isResourceBlank || storage.variant == existingStorage.variant)) {
val newBucketCap = storage.bucketCapacity + existingStorage.bucketCapacity
val existingData = TankFluidStorage.ExistingData(existingStorage.variant, existingStorage.amount)
val newStorage = TankFluidStorage(newBucketCap, existingData).also { it.onChanged = ::markDirty }
storageMap[storageId] = newStorage
positionalStorageMap[pos] = storageId
} else {
// Collect all neighboring storage IDs, including the interacted position (if any).
val neighborPositions = listOf(
pos.add(-1, 0, 0),
pos.add(1, 0, 0),
pos.add(0, 0, -1),
pos.add(0, 0, 1),
pos.add(0, -1, 0),
pos.add(0, 1, 0),
)
val neighborStorageIds = buildSet<UUID> {
interactedAt?.let { interactedPos ->
positionalStorageMap[interactedPos]?.let { add(it) }
}
for (neighborPos in neighborPositions) {
positionalStorageMap[neighborPos]?.let { add(it) }
}
}
if (neighborStorageIds.isEmpty()) {
// No neighboring storage: create a new independent group.
val uuid = UUID.randomUUID()
positionalStorageMap[pos] = uuid
storageMap[uuid] = storage.also { it.onChanged = ::markDirty }
markDirty()
return
}
// There are neighboring storages; determine if they and the new storage are compatible.
val neighborStorages = neighborStorageIds.mapNotNull { storageMap[it] }
// Determine the common non-blank variant (if any) among neighbors.
val nonBlankVariants = neighborStorages
.filterNot { it.isResourceBlank }
.map { it.variant }
.distinct()
val compatible: Boolean = when {
nonBlankVariants.size > 1 -> {
// Neighbors already conflict; do not merge.
false
}
nonBlankVariants.size == 1 -> {
val variant = nonBlankVariants.first()
storage.isResourceBlank || storage.variant == variant
}
else -> {
// All neighbors are blank; always compatible.
true
}
}
if (!compatible) {
// Preserve existing groups; create a new independent storage for this position.
val uuid = UUID.randomUUID()
positionalStorageMap[pos] = uuid
storageMap[uuid] = storage.also { it.onChanged = ::markDirty }
markDirty()
return
}
// Choose a primary storage ID to represent the merged group.
val interactedStorageId = interactedAt?.let(positionalStorageMap::get)
val primaryStorageId = interactedStorageId?.takeIf { neighborStorageIds.contains(it) }
?: neighborStorageIds.first()
val primaryExistingStorage = storageMap[primaryStorageId]
if (primaryExistingStorage != null) {
// Merge capacities of all neighbors plus the new storage.
val totalNeighborCapacity = neighborStorages.sumOf { it.bucketCapacity }
val newBucketCap = storage.bucketCapacity + totalNeighborCapacity
// Preserve the variant and amount from the primary existing storage,
// matching the original behavior where the existing group's contents are kept.
val existingData = TankFluidStorage.ExistingData(
primaryExistingStorage.variant,
primaryExistingStorage.amount
)
val newStorage = TankFluidStorage(newBucketCap, existingData).also { it.onChanged = ::markDirty }
// Update the storage map for the primary ID.
storageMap[primaryStorageId] = newStorage
// Remap all positions from other neighbor IDs to the primary ID and remove old entries.
for (otherId in neighborStorageIds) {
if (otherId == primaryStorageId) continue
val positionsToRemap = positionalStorageMap
.filter { (_, id) -> id == otherId }
.keys
.toList()
for (remapPos in positionsToRemap) {
positionalStorageMap[remapPos] = primaryStorageId
}
storageMap.remove(otherId)
}
// Attach the new position to the merged group.
positionalStorageMap[pos] = primaryStorageId
} else {
// Fallback: no existing storage found for the primary ID; create a fresh group.

Copilot uses AI. Check for mistakes.
val existingStorage = storageId?.let(storageMap::get)
if (existingStorage != null && (storage.isResourceBlank || storage.variant == existingStorage.variant)) {
val newBucketCap = storage.bucketCapacity + existingStorage.bucketCapacity
val existingData = TankFluidStorage.ExistingData(existingStorage.variant, existingStorage.amount)
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When merging into an existing storage, the new storage's amount is not incorporated (only existingStorage.amount is preserved). If addStorage is ever called with a non-empty storage of the same variant (e.g., programmatic placement, future pre-filled tanks, or world upgrade logic), that fluid will be lost. Sum both amounts (and clamp to the new capacity) when creating the merged storage.

Suggested change
val existingData = TankFluidStorage.ExistingData(existingStorage.variant, existingStorage.amount)
val mergedAmount = existingStorage.amount + storage.amount
val maxAmount = newBucketCap.toLong() * FluidConstants.BUCKET
val finalAmount = mergedAmount.coerceAtMost(maxAmount)
val existingData = TankFluidStorage.ExistingData(existingStorage.variant, finalAmount)

Copilot uses AI. Check for mistakes.
Comment on lines +94 to +95
val TYPE: PersistentStateType<FluidStoragePersistentState> = PersistentStateType(MOD_ID, ::FluidStoragePersistentState, CODEC, null)
}
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PersistentStateType is created with MOD_ID as its id. This id is used as the persistent state key/filename, so using the mod id makes collisions likely if you ever add another PersistentState for this mod (or rename/migrate data). Use a state-specific id (e.g., ${MOD_ID}_fluid_storage) to avoid future data corruption/overwrites.

Copilot uses AI. Check for mistakes.
- onBreak → onStateReplaced に移行し、爆発や /setblock でもストレージが正しく削除されるように修正
- クライアント側フォールバックの空 TankFluidStorage を廃止し、サーバー側で getOrCreate → addStorage に変更
- マージ時に新ストレージの液体量も合算するように修正
- PersistentState の ID を MOD_ID から ${MOD_ID}_fluid_storage に変更し将来の衝突を防止
@turtton turtton marked this pull request as ready for review March 6, 2026 07:31
@turtton turtton merged commit 5cdc808 into main Mar 6, 2026
1 check passed
@turtton turtton deleted the resolve/2 branch March 6, 2026 07:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TankFunction

2 participants