Skip to content

feat: Add config system with YACL GUI and ModMenu integration#20

Merged
turtton merged 4 commits intomainfrom
feat/8
Mar 9, 2026
Merged

feat: Add config system with YACL GUI and ModMenu integration#20
turtton merged 4 commits intomainfrom
feat/8

Conversation

@turtton
Copy link
Owner

@turtton turtton commented Mar 8, 2026

Summary

  • YACL + ModMenu を使った config system を導入 (Closes Config system #8)
  • サーバー設定 (tankBucketCapacity) とクライアント設定 (renderQuality) を JSON ファイルで管理
    • 配置: config/connectedtank/server.json, config/connectedtank/client.json
  • サーバー→クライアントの設定同期 (ConfigSyncPayload) を実装
  • 外部サーバー接続時はサーバー設定を読み取り専用で表示
  • ハードコードされた容量 32 をコンフィグ参照に変更
  • coerceAtMost 削除で容量削減時の液体保全を実現
  • YACL 不在時のフォールバック処理を実装
  • JsonReader.isLenient による堅牢な JSON パース
  • @Volatile によるスレッドセーフなインスタンス管理
  • 入力値バリデーション (coerceIn(1, MAX_BUCKET_CAPACITY)) の統一

Test plan

  • ./gradlew spotlessCheck パス
  • ./gradlew build 成功
  • 全 21 ゲームテストパス
  • ./gradlew runClient で ModMenu からコンフィグ画面が開ける
  • タンク容量スライダーが表示・変更できる
  • 設定変更が config/connectedtank/server.json に保存される

Related

turtton added 3 commits March 8, 2026 22:39
サーバー設定(タンク容量)とクライアント設定(描画品質)を管理する
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
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

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 / CTClientConfig JSON 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, newBucketCap is based on the current config (defaultBucketCapacity), but componentAmount is derived from the existing stored amount. If the config was reduced, it's possible for componentAmount to exceed the new component capacity, creating a TankFluidStorage with amount > capacity (which can break SingleVariantStorage logic). Consider ensuring each new component's capacity is at least enough to hold componentAmount (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

  • newBucketCap is 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 with remainingAmount > capacity, which violates SingleVariantStorage invariants and can break transfer math. To preserve fluid without breaking storage semantics, consider keeping capacity at least large enough to hold remainingAmount (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.

Comment on lines +24 to +30
fun registerServer() {
PayloadTypeRegistry.playS2C().register(ID, CODEC)
ServerPlayConnectionEvents.JOIN.register { handler, _, _ ->
val payload = ConfigSyncPayload(CTServerConfig.instance.tankBucketCapacity)
ServerPlayNetworking.send(handler.player, payload)
}
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines 62 to 67
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 }
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +55 to +59
} catch (e: Exception) {
ConnectedTank.logger.error("Failed to load server config, using defaults", e)
instance = CTServerConfig()
}
instance.save()
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
} 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()
}

Copilot uses AI. Check for mistakes.
Comment on lines +62 to +66
} catch (e: Exception) {
ConnectedTank.logger.error("Failed to load client config, using defaults", e)
instance = CTClientConfig()
}
instance.save()
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
} 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()
}

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +31
ClientPlayNetworking.registerGlobalReceiver(ConfigSyncPayload.ID) { payload, _ ->
SyncedServerConfig.syncedConfig = CTServerConfig(
tankBucketCapacity = payload.tankBucketCapacity.coerceIn(
1,
CTServerConfig.MAX_BUCKET_CAPACITY,
),
)
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
パース失敗時にデフォルト値でファイルを上書きしないよう、
save() を try ブロック内(パース成功時)にのみ呼ぶように変更。
@turtton turtton merged commit 73491da into main Mar 9, 2026
1 of 2 checks passed
@turtton turtton deleted the feat/8 branch March 9, 2026 02:22
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.

Config system

2 participants