A lightweight HOCON-based configuration helper for Kotlin/JVM plugins.
- Config comments via annotations:
@Comment(...)@SectionComment(...)
- Locale-aware messages with file layout:
messages.conf(base)languages/<locale>.conf(overrides)
- Locale fallback chain:
- exact locale -> language -> configured default locale -> base file
- Selective config recovery for invalid values with warning logs
- Experimental
QuickMiniMessagebackend for messages, opt-in viaMessages.Options
- Java 21+
repositories {
mavenCentral()
maven("https://jitpack.io")
maven("https://repo.nekroplex.com/releases")
}
dependencies {
implementation("com.github.RpMGrut:cocal:v1.4")
}import me.delyfss.cocal.Comment
import me.delyfss.cocal.Config
import me.delyfss.cocal.Path
import me.delyfss.cocal.SectionComment
enum class BarColor { RED, BLUE }
data class BossBar(
@Comment("Enable boss bar")
val enabled: Boolean = true,
@Comment("Displayed text")
val text: String = "<#62f5b1>Battle in <time>",
val color: BarColor = BarColor.RED
)
data class BattleConfig(
@Comment("Main switch")
val enabled: Boolean = false,
@Comment("Countdown sequence")
@Path("countdown-seconds")
val countdownSeconds: List<Int> = listOf(5, 4, 3, 2, 1),
@SectionComment("Tool settings")
val tool: Tool = Tool(),
@SectionComment("Named boss bar presets")
@Path("boss-bars")
val bossBars: Map<String, BossBar> = mapOf(
"pvp" to BossBar(),
"nether" to BossBar(text = "Nether opens in <time>", color = BarColor.BLUE)
)
)
data class Tool(
val material: String = "STONE_AXE",
@Path("display-name")
val displayName: String = "<#7bff6b><bold>Mega Axe</bold>",
val lore: List<String> = listOf(
"<gray>Right-click - place flask",
"<gray>Left-click on barrier - remove"
)
)
class ConfigManager(private val plugin: JavaPlugin) {
private val loader = Config(plugin.dataFolder, "battle.conf", BattleConfig())
fun reload(): BattleConfig = loader.load()
}val loader = Config(
plugin.dataFolder,
"battle.conf",
BattleConfig(),
Config.Options(
header = listOf("Battle Royale", "https://github.com/your-repo"),
prettyPrint = true,
alwaysWriteFile = true,
commentsEnabled = true,
commentPrefix = "# "
)
)Map keys support:
StringIntDoubleEnum
Options:
header: comment block at top of fileprettyPrint: formatted outputalwaysWriteFile: re-render merged file on each loadcommentsEnabled: render annotation commentscommentPrefix: comment prefix for generated comment lines
class MenuStateConfig(folder: File) : DynamicConfig(
folder,
"menu-state.conf",
DynamicConfig.Options(
debounceDelayMs = 1000,
commentsEnabled = true
)
) {
@Path("last-opened")
@Comment("Unix millis of last open")
var lastOpened: Long = 0L
var tabs: MutableList<String> = mutableListOf()
}
val state = MenuStateConfig(plugin.dataFolder)
state.update {
lastOpened = System.currentTimeMillis()
tabs.add("main")
}Notes:
- mutate inside
update { ... } - call
close()on plugin disable
plugins/YourPlugin/
messages.conf
languages/
en-US.conf
ru-RU.conf
ru.conf
messages-meta {
default-locale = "en-US"
}
messages {
prefix = "<#7bff6b><bold>Server</bold> <gray>> </gray>"
ability.cooldown {
chat = "<prefix><#ffad42>Ability cooldown: <white><time></white>s"
actionbar = "<#ffad42>Ready in <time>s"
}
command.usage = "<prefix><gray>Usage: <#f9c23c>/server <white>(give|reload)</white>"
}messages {
ability.cooldown {
chat = "<prefix><#ffad42>Перезарядка: <white><time></white>с"
actionbar = "<#ffad42>Готово через <time>с"
}
command.usage = "<prefix><gray>Использование: <#f9c23c>/server <white>(give|reload)</white>"
}For locale ru-RU:
languages/ru-RU.conflanguages/ru.conf- locale from
messages-meta.default-locale messages.confbase
If languages/ does not exist, behavior is the same as 1.2.
class MessageExample(private val plugin: JavaPlugin) {
private val messages = Messages.fromFile(
plugin = plugin,
fileProvider = { File(plugin.dataFolder, "messages.conf") },
logger = plugin.logger,
options = Messages.Options(
rootPath = "messages",
parserBackend = Messages.ParserBackend.MINI_MESSAGE
),
onCorrupted = { _, _ ->
plugin.getResource("messages.conf")?.reader()?.readText()
}
)
fun init() = messages.load()
fun autoLocale(player: Player) {
// Backward-compatible API: locale is auto-detected from player.locale
messages.send(player, "ability.cooldown", mapOf("time" to "4"))
}
fun forcedLocale(sender: CommandSender) {
// New 1.3 API
messages.sendLocalized(sender, "ability.cooldown", "ru-RU", mapOf("time" to "4"))
}
fun plain(locale: String): String? {
return messages.plainLocalized("command.usage", locale)
}
}Messages.ParserBackend.MINI_MESSAGE remains the safe default.
If you want to try the faster parser backend:
val messages = Messages.fromFile(
plugin = plugin,
fileProvider = { File(plugin.dataFolder, "messages.conf") },
logger = plugin.logger,
options = Messages.Options(
rootPath = "messages",
parserBackend = Messages.ParserBackend.QUICK_MINI_MESSAGE
)
)QUICK_MINI_MESSAGE is experimental:
- it is faster on common tags and plain formatting
- it is not the default backend
- it may differ from regular
MiniMessageon more complex tags such asgradientandrainbow
Available channels inside one message template remain the same:
- chat (
chat,text,lines) - actionbar (
actionbar/action-bar) - titlebar (
titlebarstring/object) - sound (
soundstring/object)
No breaking migration is required.
- Existing
Config,DynamicConfig,Messagesusage keeps working. - You can adopt comments incrementally by adding
@Comment/@SectionComment. - You can adopt locale files incrementally by creating
languages/*.conf. - Old methods (
send,plain,template,raw) stay valid.
If a locale file has syntax/type errors:
- a backup like
ru-RUsave-2026-02-21-12-34-56.confis created - warning is logged
- that locale is skipped
- fallback continues using remaining chain
Messages.fromFile(..., onCorrupted = { ... }) can restore file text from resource or custom fallback.
Config now recovers invalid values selectively:
- only the invalid path is rolled back to its default value
- warning contains file, line, path, bad value preview, and recovery action
- other valid user overrides stay untouched
Backups (*save-...conf) are created only for global recovery scenarios (for example syntax corruption or unrecoverable type errors).
Special case for dynamic maps:
- if a custom map entry has an invalid value and no default exists for that exact path,
Configperforms a global backup/reset
Still supported:
class MenuConfig {
@Path("menu.title")
var title: String = "<green>Test"
}
val config = Config(folder, "menu.conf", MenuConfig()).load()You can migrate to data classes gradually.