Repository: pylonmc/pylon-core
Pull request #56: Implement item settings
Author: login: Seggan
Created: 2025-04-02T16:40:49Z | Merged: 2025-04-09T01:30:43Z

Companion Base PR Coming Soon™


Diff:
diff --git a/pylon-core/src/main/kotlin/io/github/pylonmc/pylon/core/addon/PylonAddon.kt b/pylon-core/src/main/kotlin/io/github/pylonmc/pylon/core/addon/PylonAddon.kt
index b36f5edf0..2c374c0e6 100644
--- a/pylon-core/src/main/kotlin/io/github/pylonmc/pylon/core/addon/PylonAddon.kt
+++ b/pylon-core/src/main/kotlin/io/github/pylonmc/pylon/core/addon/PylonAddon.kt
@@ -1,8 +1,12 @@
 package io.github.pylonmc.pylon.core.addon
 
+import io.github.pylonmc.pylon.core.config.Config
+import io.github.pylonmc.pylon.core.config.ConfigSection
+import io.github.pylonmc.pylon.core.pluginInstance
 import io.github.pylonmc.pylon.core.registry.PylonRegistry
 import org.bukkit.Keyed
 import org.bukkit.NamespacedKey
+import org.bukkit.configuration.file.YamlConfiguration
 import org.bukkit.plugin.java.JavaPlugin
 
 interface PylonAddon : Keyed {
@@ -22,4 +26,32 @@ interface PylonAddon : Keyed {
      * The display name used, for example, at the bottom of items to show which addon an item is from
      */
     fun displayName(): String
+
+    /**
+     * Merges config from resources to the Pylon config directory.
+     * Used for stuff like item settings and language files.
+     * If the resource file does not exist, any existing config in the given path will be used,
+     * or a new one created.
+     *
+     * @param path The path to the config file. Must be a YAML file.
+     * @return The merged config
+     */
+    fun mergeGlobalConfig(path: String): Config {
+        require(path.endsWith(".yml")) { "Config file must be a YAML file" }
+        val globalConfig = pluginInstance.dataFolder.resolve(path)
+        if (!globalConfig.exists()) {
+            globalConfig.parentFile.mkdirs()
+            globalConfig.createNewFile()
+        }
+        val config = Config(globalConfig)
+        val resource = this.javaPlugin.getResource(path)
+        if (resource != null) {
+            val newConfig = resource.reader().use(YamlConfiguration::loadConfiguration)
+            config.internalConfig.setDefaults(newConfig)
+            config.internalConfig.options().copyDefaults(true)
+            config.merge(ConfigSection(newConfig))
+            config.save()
+        }
+        return config
+    }
 }
\ No newline at end of file
diff --git a/pylon-core/src/main/kotlin/io/github/pylonmc/pylon/core/config/ConfigSection.kt b/pylon-core/src/main/kotlin/io/github/pylonmc/pylon/core/config/ConfigSection.kt
index ee5179f41..f51682012 100644
--- a/pylon-core/src/main/kotlin/io/github/pylonmc/pylon/core/config/ConfigSection.kt
+++ b/pylon-core/src/main/kotlin/io/github/pylonmc/pylon/core/config/ConfigSection.kt
@@ -1,11 +1,14 @@
 package io.github.pylonmc.pylon.core.config
 
+import com.google.common.base.CaseFormat
 import org.bukkit.configuration.ConfigurationSection
+import kotlin.properties.ReadWriteProperty
+import kotlin.reflect.KProperty
 
 open class ConfigSection(val internalSection: ConfigurationSection) {
 
-    class KeyNotFoundException(path: String?, key: String)
-        : Exception(if (path != null) "Config key not found: $path.$key" else "Config key not found: $key")
+    val keys: Set<String>
+        get() = internalSection.getKeys(false)
 
     fun getSections(): Set<ConfigSection> {
         val configSections: MutableSet<ConfigSection> = mutableSetOf()
@@ -15,29 +18,67 @@ open class ConfigSection(val internalSection: ConfigurationSection) {
         return configSections
     }
 
-    fun getSectionNames(): Set<String>
-        = internalSection.getKeys(false)
-
     fun getSection(key: String): ConfigSection? {
         val newConfig = internalSection.getConfigurationSection(key) ?: return null
         return ConfigSection(newConfig)
     }
 
-    fun getSectionOrThrow(key: String): ConfigSection
-        = getSection(key) ?: throw KeyNotFoundException(internalSection.currentPath, key)
+    fun getSectionOrThrow(key: String): ConfigSection =
+        getSection(key) ?: throw KeyNotFoundException(internalSection.currentPath, key)
 
-    inline fun <reified T> get(key: String): T? {
+    fun <T> get(key: String, type: Class<out T>): T? {
         val value = internalSection.get(key) ?: return null
-        return value as T
+        return type.cast(value)
     }
 
-    inline fun <reified T> getOrThrow(key: String): T
-        = get(key) ?: throw KeyNotFoundException(internalSection.currentPath, key)
+    inline fun <reified T> get(key: String): T? = get(key, T::class.java)
+
+    fun <T> getOrThrow(key: String, type: Class<out T>): T =
+        get(key, type) ?: throw KeyNotFoundException(internalSection.currentPath, key)
+
+    inline fun <reified T> getOrThrow(key: String): T = getOrThrow(key, T::class.java)
 
-    inline fun <reified T> get(key: String, default: T): T
-        = get(key) ?: default
+    fun <T> get(key: String, type: Class<out T>, default: T): T = get(key, type) ?: default
+
+    inline fun <reified T> get(key: String, default: T): T = get(key, T::class.java, default)
 
     fun <T> set(key: String, value: T) {
         internalSection.set(key, value)
     }
+
+    fun merge(other: ConfigSection) {
+        for (key in other.keys) {
+            val otherSection = other.getSection(key)
+            if (otherSection != null) {
+                val thisSection = this.getSection(key)
+                if (thisSection != null) {
+                    thisSection.merge(otherSection)
+                } else {
+                    internalSection.set(key, otherSection.internalSection)
+                }
+            } else if (key !in this.keys) {
+                internalSection.set(key, other.internalSection.get(key))
+            }
+        }
+    }
+
+    @JvmSynthetic
+    inline operator fun <reified T> provideDelegate(
+        @Suppress("unused") thisRef: Any,
+        property: KProperty<*>,
+    ): ReadWriteProperty<Any, T> {
+        val key = CaseFormat.LOWER_CAMEL.to(CaseFormat.LOWER_HYPHEN, property.name)
+        return object : ReadWriteProperty<Any, T> {
+            override fun getValue(thisRef: Any, property: KProperty<*>): T {
+                return getOrThrow(key)
+            }
+
+            override fun setValue(thisRef: Any, property: KProperty<*>, value: T) {
+                set(key, value)
+            }
+        }
+    }
+
+    class KeyNotFoundException(path: String?, key: String) :
+        Exception(if (!path.isNullOrEmpty()) "Config key not found: $path.$key" else "Config key not found: $key")
 }
\ No newline at end of file
diff --git a/pylon-core/src/main/kotlin/io/github/pylonmc/pylon/core/config/PylonConfig.kt b/pylon-core/src/main/kotlin/io/github/pylonmc/pylon/core/config/PylonConfig.kt
index d452adc46..0defe5b19 100644
--- a/pylon-core/src/main/kotlin/io/github/pylonmc/pylon/core/config/PylonConfig.kt
+++ b/pylon-core/src/main/kotlin/io/github/pylonmc/pylon/core/config/PylonConfig.kt
@@ -6,9 +6,9 @@ object PylonConfig {
 
     private val config = Config(pluginInstance, "config.yml")
 
-    @JvmField
-    val tickDelay = config.get("tick-delay", 10)
+    @JvmStatic
+    val tickDelay: Int by config
 
-    @JvmField
-    val allowedBlockErrors = config.get("tick-delay", 10)
+    @JvmStatic
+    val allowedBlockErrors: Int by config
 }
\ No newline at end of file
diff --git a/pylon-core/src/main/kotlin/io/github/pylonmc/pylon/core/item/PylonItemSchema.kt b/pylon-core/src/main/kotlin/io/github/pylonmc/pylon/core/item/PylonItemSchema.kt
index e2c4c0e93..287a53c8a 100644
--- a/pylon-core/src/main/kotlin/io/github/pylonmc/pylon/core/item/PylonItemSchema.kt
+++ b/pylon-core/src/main/kotlin/io/github/pylonmc/pylon/core/item/PylonItemSchema.kt
@@ -2,6 +2,7 @@ package io.github.pylonmc.pylon.core.item
 
 import io.github.pylonmc.pylon.core.item.PylonItem.Companion.idKey
 import io.github.pylonmc.pylon.core.persistence.datatypes.PylonSerializers
+import io.github.pylonmc.pylon.core.pluginInstance
 import io.github.pylonmc.pylon.core.registry.PylonRegistry
 import io.github.pylonmc.pylon.core.registry.RegistryHandler
 import io.github.pylonmc.pylon.core.util.findConstructorMatching
@@ -9,6 +10,8 @@ import org.bukkit.Keyed
 import org.bukkit.NamespacedKey
 import org.bukkit.inventory.ItemStack
 import java.lang.invoke.MethodHandle
+import java.nio.file.Path
+import kotlin.io.path.createDirectories
 
 open class PylonItemSchema(
     private val key: NamespacedKey,
@@ -16,15 +19,8 @@ open class PylonItemSchema(
     private val template: ItemStack
 ) : Keyed, RegistryHandler {
 
-    init {
-        val addon = PylonRegistry.ADDONS.find { addon -> addon.key.namespace == key.namespace }
-        if (addon == null) {
-            throw IllegalStateException("Item does not have a corresponding addon; does your plugin call registerWithPylon()?")
-        }
-        ItemStackBuilder(template) // Modifies the template directly
-            .editMeta { meta -> meta.persistentDataContainer.set(idKey, PylonSerializers.NAMESPACED_KEY, key) }
-            .lore(LoreBuilder().addon(addon))
-    }
+    val addon = PylonRegistry.ADDONS.find { addon -> addon.key.namespace == key.namespace }
+        ?: error("Item does not have a corresponding addon; does your plugin call registerWithPylon()?")
 
     val itemStack: ItemStack
         get() = template.clone()
@@ -36,7 +32,12 @@ open class PylonItemSchema(
     )
         ?: throw NoSuchMethodException("Item '$key' ($itemClass) is missing a load constructor (PylonItemSchema, ItemStack)")
 
+    val settings = addon.mergeGlobalConfig("settings/item/${key.namespace}/${key.key}.yml")
+
     fun register() = apply {
+        ItemStackBuilder(template) // Modifies the template directly
+            .editMeta { meta -> meta.persistentDataContainer.set(idKey, PylonSerializers.NAMESPACED_KEY, key) }
+            .lore(LoreBuilder().addon(addon))
         PylonRegistry.ITEMS.register(this)
     }
 


Reviews:
user: login: LordIdra
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T19:24:57Z
user: login: Seggan
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T19:40:47Z
user: login: LordIdra
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T21:53:11Z
user: login: OhmV-IR
body: I think file paths are broken on windows again, and the fix will likely have to be implemented here so hold on merging this
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-05T02:16:07Z
user: login: LordIdra
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T01:21:25Z

Review comments:
diff_hunk: @@ -1,11 +1,14 @@
 package io.github.pylonmc.pylon.core.config
 
+import com.google.common.base.CaseFormat
 import org.bukkit.configuration.ConfigurationSection
+import kotlin.properties.ReadWriteProperty
+import kotlin.reflect.KProperty
 
-open class ConfigSection(val internalSection: ConfigurationSection) {
path: pylon-core/src/main/kotlin/io/github/pylonmc/pylon/core/config/ConfigSection.kt
user: login: LordIdra
body: I think this is something we should expose in case someone wants to do something that requires a ConfigurationSection - I've had this issue before when I implemented a similar system, which is why I didn't make it private
created_at: 2025-04-02T19:12:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -36,7 +37,27 @@ open class PylonItemSchema(
     )
         ?: throw NoSuchMethodException("Item '$key' ($itemClass) is missing a load constructor (PylonItemSchema, ItemStack)")
 
+    val settings: Config
+
+    init {
+        val settingsFile = getItemFile(key)
+        if (!settingsFile.exists()) {
+            settingsFile.parent.createDirectories()
+            settingsFile.createFile()
+        }
+        settings = Config(settingsFile.toFile())
+        val resource = addon.javaPlugin.getResource("settings/item/${keyPath(key)}")
+        if (resource != null) {
+            val newConfig = resource.reader().use { ConfigSection(YamlConfiguration.loadConfiguration(it)) }
+            settings.merge(newConfig)
+            settings.save()
+        }
+    }
+
     fun register() = apply {
+        ItemStackBuilder(template) // Modifies the template directly
+            .editMeta { meta -> meta.persistentDataContainer.set(idKey, PylonSerializers.NAMESPACED_KEY, key) }
+            .lore(LoreBuilder().addon(addon))
path: pylon-core/src/main/kotlin/io/github/pylonmc/pylon/core/item/PylonItemSchema.kt
user: login: LordIdra
body: What's the reason for this change?
created_at: 2025-04-02T19:18:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -36,7 +37,27 @@ open class PylonItemSchema(
     )
         ?: throw NoSuchMethodException("Item '$key' ($itemClass) is missing a load constructor (PylonItemSchema, ItemStack)")
 
+    val settings: Config
+
+    init {
+        val settingsFile = getItemFile(key)
+        if (!settingsFile.exists()) {
+            settingsFile.parent.createDirectories()
+            settingsFile.createFile()
+        }
+        settings = Config(settingsFile.toFile())
+        val resource = addon.javaPlugin.getResource("settings/item/${keyPath(key)}")
path: pylon-core/src/main/kotlin/io/github/pylonmc/pylon/core/item/PylonItemSchema.kt
user: login: LordIdra
body: The combination of settingsFile, itemsDir, keyPath, and a hardcoded string here is really confusing. Couldn't `itemsDir` and `keyPath` just be turned into a single `itemPath(key: NamespacedKey): String`? I don't think we need the whole `itemsDir.createDirectories()` thing given getItemFile already does `path.parent.createDirectories()` - or if it's still needed, that should be merged together IMO
created_at: 2025-04-02T19:24:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -36,7 +37,27 @@ open class PylonItemSchema(
     )
         ?: throw NoSuchMethodException("Item '$key' ($itemClass) is missing a load constructor (PylonItemSchema, ItemStack)")
 
+    val settings: Config
+
+    init {
+        val settingsFile = getItemFile(key)
+        if (!settingsFile.exists()) {
+            settingsFile.parent.createDirectories()
+            settingsFile.createFile()
+        }
+        settings = Config(settingsFile.toFile())
+        val resource = addon.javaPlugin.getResource("settings/item/${keyPath(key)}")
+        if (resource != null) {
+            val newConfig = resource.reader().use { ConfigSection(YamlConfiguration.loadConfiguration(it)) }
+            settings.merge(newConfig)
+            settings.save()
+        }
+    }
+
     fun register() = apply {
+        ItemStackBuilder(template) // Modifies the template directly
+            .editMeta { meta -> meta.persistentDataContainer.set(idKey, PylonSerializers.NAMESPACED_KEY, key) }
+            .lore(LoreBuilder().addon(addon))
path: pylon-core/src/main/kotlin/io/github/pylonmc/pylon/core/item/PylonItemSchema.kt
user: login: Seggan
body: An item might want to edit lore before the addon line is inserted. I originally needed this in the companion PR but ended up not using it
created_at: 2025-04-02T19:40:47Z
author_association: MEMBER
diff_hunk: @@ -22,4 +26,32 @@ interface PylonAddon : Keyed {
      * The display name used, for example, at the bottom of items to show which addon an item is from
      */
     fun displayName(): String
+
+    /**
+     * Merges config from resources to the Pylon config directory.
path: pylon-core/src/main/kotlin/io/github/pylonmc/pylon/core/addon/PylonAddon.kt
user: login: OhmV-IR
body: @brief this line
created_at: 2025-04-05T01:20:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -22,4 +26,32 @@ interface PylonAddon : Keyed {
      * The display name used, for example, at the bottom of items to show which addon an item is from
      */
     fun displayName(): String
+
+    /**
+     * Merges config from resources to the Pylon config directory.
path: pylon-core/src/main/kotlin/io/github/pylonmc/pylon/core/addon/PylonAddon.kt
user: login: LordIdra
body: we're not really following javadoc convention anywhere, I don't think we should use it here, we can worry about this once we start proper docs
created_at: 2025-04-09T01:21:25Z
author_association: CONTRIBUTOR

PR comments:
user: login: LordIdra
created_at: 2025-04-02T19:28:08Z
body: Also - will block settings be a separate PR then?
author_association: CONTRIBUTOR
user: login: Seggan
created_at: 2025-04-02T19:41:06Z
body: > Also - will block settings be a separate PR then?


Yes
author_association: MEMBER
user: login: Seggan
created_at: 2025-04-06T19:06:53Z
body: > I think file paths are broken on windows again, and the fix will likely have to be implemented here so hold on merging this

Already replied on discord
author_association: MEMBER