Conversation
サーバー設定(タンク容量)とクライアント設定(描画品質)を管理する config system を導入。YACL を GUI ライブラリとして採用し、ModMenu と統合。 - CTServerConfig: Gson ベースのサーバー設定(コメント付き JSON 出力) - CTClientConfig: クライアント設定(描画品質、レンダラー接続は TODO) - CTConfigScreen: YACL GUI + ModMenu 統合(外部サーバー時は読み取り専用) - ConfigSyncPayload: サーバー→クライアント設定同期 - SyncedServerConfig: 同期された設定値の保持 - ハードコードされた容量 32 をコンフィグ参照に変更 - coerceAtMost 削除で容量削減時の液体保全を実現 Closes #8
- 外部サーバー時の binding setter をノーオペに変更 - ConfigSyncPayload の入力値に coerceIn(1, MAX_BUCKET_CAPACITY) を適用 - コメント付き JSON パースを JsonReader.isLenient に変更(行除去方式を廃止) - save() に try-catch を追加 - Gson インスタンスを companion object に共有化 - instance と syncedConfig に @volatile を付与 - MAX_BUCKET_CAPACITY 定数を追加して上限を統一 - YACL 不在時の NoClassDefFoundError をフォールバック処理 - SyncedServerConfig の不要な同一パッケージ import を削除 - ja_jp.json 翻訳ファイルを追加
config/connectedtank-server.json → config/connectedtank/server.json config/connectedtank-client.json → config/connectedtank/client.json
There was a problem hiding this comment.
Pull request overview
This PR introduces a JSON-based config system (server + client), adds a YACL-powered config GUI exposed via ModMenu, and implements initial server→client config synchronization for tankBucketCapacity.
Changes:
- Add
CTServerConfig/CTClientConfigJSON config loading + saving, and a ModMenu/YACL config screen. - Implement server→client sync payload (
ConfigSyncPayload) and client storage for synced server config. - Replace hardcoded tank capacity constants with config-driven values and adjust storage merge/split behavior.
Reviewed changes
Copilot reviewed 14 out of 15 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/main/resources/fabric.mod.json | Adds ModMenu entrypoint and suggests YACL/ModMenu mods. |
| src/main/resources/assets/connectedtank/lang/ja_jp.json | Adds JA translations for config UI. |
| src/main/resources/assets/connectedtank/lang/en_us.json | Adds EN translations for config UI. |
| src/main/kotlin/net/turtton/connectedtank/world/FluidStoragePersistentState.kt | Uses config-driven capacity and changes merge/split capacity/amount behavior. |
| src/main/kotlin/net/turtton/connectedtank/network/ConfigSyncPayload.kt | Adds S2C payload and JOIN-time send. |
| src/main/kotlin/net/turtton/connectedtank/config/CTServerConfig.kt | Adds server config JSON load/save with validation. |
| src/main/kotlin/net/turtton/connectedtank/block/TankFluidStorage.kt | Defaults storage capacity from server config. |
| src/main/kotlin/net/turtton/connectedtank/ConnectedTank.kt | Loads server config at init and registers config sync. |
| src/gametest/kotlin/net/turtton/connectedtank/test/ConnectedTankGameTest.kt | Updates tests to reference default capacity constant. |
| src/client/kotlin/net/turtton/connectedtank/config/SyncedServerConfig.kt | Stores last received server config on the client. |
| src/client/kotlin/net/turtton/connectedtank/config/CTConfigScreen.kt | Adds ModMenu config screen with YACL fallback handling. |
| src/client/kotlin/net/turtton/connectedtank/config/CTClientConfig.kt | Adds client config JSON load/save. |
| src/client/kotlin/net/turtton/connectedtank/ConnectedTankClient.kt | Loads client config and registers payload receiver. |
| gradle/libs.versions.toml | Adds YACL + ModMenu dependency versions. |
| build.gradle.kts | Adds YACL + ModMenu repositories and dependencies. |
Comments suppressed due to low confidence (2)
src/main/kotlin/net/turtton/connectedtank/world/FluidStoragePersistentState.kt:197
- In the split case,
newBucketCapis based on the current config (defaultBucketCapacity), butcomponentAmountis derived from the existing stored amount. If the config was reduced, it's possible forcomponentAmountto exceed the new component capacity, creating aTankFluidStoragewithamount > capacity(which can breakSingleVariantStoragelogic). Consider ensuring each new component's capacity is at least enough to holdcomponentAmount(or store overflow separately) when applying a reduced config.
for ((index, component) in sorted.withIndex()) {
val componentBase = basePerTank * component.size
val componentExtra = minOf(extraTanks, component.size)
val componentAmount = componentBase + componentExtra
extraTanks -= componentExtra
val newBucketCap = component.size * defaultBucketCapacity
val data = if (variant != null && !variant.isBlank && componentAmount > 0) {
TankFluidStorage.ExistingData(variant, componentAmount)
} else {
null
}
val storage = TankFluidStorage(newBucketCap, data).also { it.onChanged = ::markDirty }
src/main/kotlin/net/turtton/connectedtank/world/FluidStoragePersistentState.kt:134
newBucketCapis now computed from the current config (defaultBucketCapacity). If the config is reduced while a tank group already contains more fluid than the new capacity allows,TankFluidStorage(newBucketCap, remainingAmount)can be created withremainingAmount > capacity, which violatesSingleVariantStorageinvariants and can break transfer math. To preserve fluid without breaking storage semantics, consider keeping capacity at least large enough to holdremainingAmount(or track overflow separately) when recalculating after removal/split.
if (components.size == 1) {
// 分断なし
val newBucketCap = groupPositions.size * defaultBucketCapacity
val data = if (variant != null && !variant.isBlank && remainingAmount > 0) {
TankFluidStorage.ExistingData(variant, remainingAmount)
} else {
null
}
storageMap[uuid] = TankFluidStorage(newBucketCap, data).also { it.onChanged = ::markDirty }
} else {
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| fun registerServer() { | ||
| PayloadTypeRegistry.playS2C().register(ID, CODEC) | ||
| ServerPlayConnectionEvents.JOIN.register { handler, _, _ -> | ||
| val payload = ConfigSyncPayload(CTServerConfig.instance.tankBucketCapacity) | ||
| ServerPlayNetworking.send(handler.player, payload) | ||
| } | ||
| } |
There was a problem hiding this comment.
PayloadTypeRegistry.playS2C().register(ID, CODEC) is only executed on the server here. With Fabric's codec-based custom payloads, the receiving side (client) also needs the payload type/codec registered, otherwise the client can disconnect with an unknown/unregistered payload. Consider splitting this into (1) a common registerPayloadType() called from both client + server init and (2) a server-only JOIN hook that sends the payload.
| val primaryId = neighborIds.first() | ||
| val totalBucketCap = storage.bucketCapacity + neighborStorages.sumOf { (_, s) -> s.bucketCapacity } | ||
| val totalAmount = (storage.amount + neighborStorages.sumOf { (_, s) -> s.amount }) | ||
| .coerceAtMost(totalBucketCap.toLong() * FluidConstants.BUCKET) | ||
| val totalAmount = storage.amount + neighborStorages.sumOf { (_, s) -> s.amount } | ||
| val mergedVariant = allVariants.firstOrNull() | ||
| val existingData = mergedVariant?.let { TankFluidStorage.ExistingData(it, totalAmount) } | ||
| val mergedStorage = TankFluidStorage(totalBucketCap, existingData).also { it.onChanged = ::markDirty } |
There was a problem hiding this comment.
totalAmount can now exceed totalBucketCap * BUCKET, but the code constructs TankFluidStorage(totalBucketCap, ExistingData(..., totalAmount)). SingleVariantStorage implementations generally assume amount <= capacity; violating that invariant can break inserts/extracts (e.g., negative remaining capacity calculations). If the goal is to preserve fluid on capacity shrink, keep the storage invariant by adjusting capacity to at least fit the existing amount (or track overflow separately) instead of storing an amount larger than the reported capacity.
| } catch (e: Exception) { | ||
| ConnectedTank.logger.error("Failed to load server config, using defaults", e) | ||
| instance = CTServerConfig() | ||
| } | ||
| instance.save() |
There was a problem hiding this comment.
On any exception during load(), the code falls back to defaults and then unconditionally calls instance.save(), which will overwrite a user's existing (but temporarily malformed) server.json with defaults. To avoid accidental data loss, consider only saving when the file is missing or when you successfully parsed and normalized values; on parse failure, keep the original file and optionally write a separate server.json.bak / server.json.invalid for diagnostics.
| } catch (e: Exception) { | |
| ConnectedTank.logger.error("Failed to load server config, using defaults", e) | |
| instance = CTServerConfig() | |
| } | |
| instance.save() | |
| instance.save() | |
| } catch (e: Exception) { | |
| ConnectedTank.logger.error("Failed to load server config, using defaults", e) | |
| instance = CTServerConfig() | |
| } |
| } catch (e: Exception) { | ||
| ConnectedTank.logger.error("Failed to load client config, using defaults", e) | ||
| instance = CTClientConfig() | ||
| } | ||
| instance.save() |
There was a problem hiding this comment.
On any exception during load(), the code falls back to defaults and then unconditionally calls instance.save(), which will overwrite an existing (but temporarily malformed) client.json with defaults. Consider only saving when the file is missing or when parsing succeeded and you intentionally normalized values; on parse failure, keep the original file and optionally write a backup for troubleshooting.
| } catch (e: Exception) { | |
| ConnectedTank.logger.error("Failed to load client config, using defaults", e) | |
| instance = CTClientConfig() | |
| } | |
| instance.save() | |
| instance.save() | |
| } catch (e: Exception) { | |
| ConnectedTank.logger.error("Failed to load client config, using defaults", e) | |
| instance = CTClientConfig() | |
| } |
| ClientPlayNetworking.registerGlobalReceiver(ConfigSyncPayload.ID) { payload, _ -> | ||
| SyncedServerConfig.syncedConfig = CTServerConfig( | ||
| tankBucketCapacity = payload.tankBucketCapacity.coerceIn( | ||
| 1, | ||
| CTServerConfig.MAX_BUCKET_CAPACITY, | ||
| ), | ||
| ) | ||
| } |
There was a problem hiding this comment.
The client registers a receiver for ConfigSyncPayload, but there is no client-side PayloadTypeRegistry.playS2C().register(ConfigSyncPayload.ID, ConfigSyncPayload.CODEC) call in this diff. With codec-based custom payloads, the client typically must register the payload type/codec to decode it; otherwise the join can fail when the server sends the payload. Add a client init registration (or a shared registration method called from both client/server).
パース失敗時にデフォルト値でファイルを上書きしないよう、 save() を try ブロック内(パース成功時)にのみ呼ぶように変更。
Summary
tankBucketCapacity) とクライアント設定 (renderQuality) を JSON ファイルで管理config/connectedtank/server.json,config/connectedtank/client.jsonConfigSyncPayload) を実装coerceAtMost削除で容量削減時の液体保全を実現JsonReader.isLenientによる堅牢な JSON パース@Volatileによるスレッドセーフなインスタンス管理coerceIn(1, MAX_BUCKET_CAPACITY)) の統一Test plan
./gradlew spotlessCheckパス./gradlew build成功./gradlew runClientで ModMenu からコンフィグ画面が開けるconfig/connectedtank/server.jsonに保存されるRelated