Shared config + lang plumbing for the XpressDev Minecraft mods, extracted from the copy-pasted
config packages in CatchCombo and BBottleCaps.
It is a plain Kotlin/JVM library — no Minecraft, no Fabric Loader. gson, slf4j and Adventure are all
compileOnly, because every Fabric mod already ships them; bundling them here would put a second
copy on the classpath. Anything needing Minecraft types (Text, sending to a player) stays in the
mod.
| Copy-pasted class | Replaced by |
|---|---|
config/JsonFiles.kt |
JsonFiles |
config/Lang.kt |
Lang |
config/Message.kt (the DEFAULTS boilerplate) |
XpressMessage + messageDefaults<T>() |
config/ConfigStore.kt (load / rewrite-if-changed / @Volatile fields) |
ConfigManager, ConfigHandle, LangHandle |
The private JsonObject.int/double/bool/obj/array/primitive helpers in every ConfigCodec |
net.xpressdev.xpresslib.json.* |
ColorUtil / MMUtils MiniMessage parsing, MessageService / CapMessages |
MiniMessages, Messenger |
What stays in the mod: the config data classes and their JsonCodec (the keys and defaults are
mod-specific), and the Component -> Text conversion, which needs a live server.
Tag a release (git tag v1.1.1 && git push origin v1.1.1), then in the mod's build.gradle.kts:
repositories {
maven("https://jitpack.io")
mavenCentral()
}
dependencies {
implementation("com.github.XpressDevelopment:XpressLib:v1.1.1")
// Jar-in-Jar. Loom wraps a plain library jar in generated mod metadata for you.
// isTransitive = false keeps kotlin-stdlib out — fabric-language-kotlin already provides it.
include("com.github.XpressDevelopment:XpressLib:v1.1.1") { isTransitive = false }
}The first resolve triggers the build on JitPack and takes a minute; check
https://jitpack.io/#XpressDevelopment/XpressLib for the log if it fails.
jitpack.yml pins JDK 21 (JitPack's default is older). group and version in the build script
only apply when they were not passed in, because JitPack builds with
-Pgroup=com.github.<owner> -Pversion=<tag> and then looks for artifacts under exactly those
coordinates. The publication's artifactId is rootProject.name, which matches the repo name — that
is what com.github.XpressDevelopment:XpressLib resolves against. To reproduce a JitPack build
locally:
./gradlew -Pgroup=com.github.XpressDevelopment -Pversion=v1.1.1 clean build publishToMavenLocalThe repo is public, so JitPack needs no authorization — it just clones and builds. Were it ever made private, that would need a paid JitPack plan for the org plus an auth token as the repository username.
Two things the build image forces:
jitpack.ymlpinsopenjdk21; the default image JDK is older than the toolchain.settings.gradle.ktsmust not use thefoojay-resolverplugin. The image cannot reachplugins-artifacts.gradle.org, so resolving it fails the build during settings evaluation, before any of the build script runs. Nothing needs it — the JDK comes fromjitpack.ymlon CI and from the installed JDK locally.
./gradlew publishToMavenLocalrepositories { mavenLocal() }
dependencies {
implementation("net.xpressdev:XpressLib:1.1.1")
include("net.xpressdev:XpressLib:1.1.1") { isTransitive = false }
}enum class Message(override val key: String, override val default: String) : XpressMessage {
COMBO_CLEARED("info.combo_reset", "<red>Your catch combo has reset!"),
COMBO_UPDATE("info.catch_combo_update", "<red>{pokemon} <gold>Catch Combo: <red>{combo}");
}That is the whole enum — the companion object { val DEFAULTS = ... } is gone; ConfigManager.lang
takes Message.entries directly.
object ConfigStore {
private val configs = ConfigManager(
FabricLoader.getInstance().configDir.resolve("catchcombo"),
CatchComboMod.LOGGER,
)
private val configFile = configs.config("config.json", ConfigCodec, ::CatchComboConfig)
private val langFile = configs.lang(Message.entries)
// Only touched when the owner opted into Mongo, so it is not part of configs.load().
private val mongoFile = configs.config("mongo_config.json", MongoConfigCodec, ::MongoSettings, autoLoad = false)
val config: CatchComboConfig get() = configFile.value
val lang: Lang get() = langFile.value
val mongo: MongoSettings get() = mongoFile.value
fun load() {
configs.load()
if (config.storage.useMongo) mongoFile.load()
}
fun disableMongoPort() {
mongoFile.update { it.copy(portExistingDataToMongo = false) }
}
}ConfigHandle.load() keeps the behaviour both mods already had: read the file, decode with the
codec (falling back to defaults if it is missing or malformed), re-encode, and rewrite the file when
it does not match the canonical form. That is what adds keys introduced by an update and migrates
legacy keys in place. LangHandle.load() does the same for lang, with defaults first and stored
values winning, so an owner's edits and extra keys survive.
Calling configs.load() again reloads everything — that is the /reload path.
A codec is just decode + encode; the readers below are lenient by design, so a hand-edited file with one bad value logs a warning and falls back to that field's default rather than failing the load.
object ConfigCodec : JsonCodec<BottleCapsConfig> {
override fun decode(json: JsonObject) = BottleCapsConfig(
levelRequired = json.int("levelRequired", LOGGER) ?: BottleCapsConfig.DEFAULT_LEVEL_REQUIRED,
blacklistedSpecies = json.stringSet("blacklistedSpecies", LOGGER),
caps = BottleCapType.entries.associateWith { decodeCap(json, it) },
)
override fun encode(value: BottleCapsConfig) = JsonObject().apply {
addProperty("levelRequired", value.levelRequired)
add("blacklistedSpecies", jsonArrayOf(value.blacklistedSpecies))
add("caps") {
BottleCapType.entries.forEach { cap -> add(cap.name.lowercase()) { /* ... */ } }
}
}
}Readers in net.xpressdev.xpresslib.json:
obj,array,string,int,long,double,bool,primitive— null when absent or unusable; pass aLoggerto have unusable values reported.stringIn,intIn,longIn,doubleIn,boolIn,stringSetIn— read from a nested section and fall back to the root, which is how CatchCombo still reads files written before the config grew sections:json.boolIn(combo, "chainReset", defaults.chainReset, LOGGER).stringSet— a list of strings as an ordered set, trimmed and lowercased by default, non-text entries dropped with a warning.doubleMap— an object ofname -> numberpairs (CatchCombo'sotherAspects).jsonArrayOf,jsonObjectOf, andadd(key) { ... }for building the encoded side.
val text: String = ConfigStore.lang.format(Message.COMBO_UPDATE, "pokemon" to name, "combo" to count.toString()){placeholder} substitution only; the string comes back raw. A key missing from the file falls back
to the enum default and is warned about once. Most callers want Messenger below rather than this
directly.
MiniMessages.parse takes config text to an Adventure Component: MiniMessage, plus the legacy
codes owners keep typing (&c, §c, &#RRGGBB), plus italic switched off unless asked for.
Messenger chains that onto a lang lookup, so a message goes from key to platform text in one call:
object MessageService {
private val messages = Messenger(ConfigStore::lang, ColorUtil::toText)
fun send(player: ServerPlayerEntity, message: Message, vararg placeholders: Pair<String, String>) {
player.sendMessage(messages.render(message, *placeholders))
}
}The toNative lambda is the only platform-specific part — ColorUtil::toText in CatchCombo,
{ adventure!!.toNative(it) } in BBottleCaps. It is supplied rather than done in the library
because Component -> Text needs a RegistryManager or FabricServerAudiences, i.e. a running
server; that is also why each mod still captures the server in a SERVER_STARTED handler.
lang is passed as a supplier (ConfigStore::lang, not ConfigStore.lang) so a reload that swaps
the loaded Lang is picked up without rebuilding the messenger.
Adventure is compiled against 4.14.0, the lower of the versions the mods resolve, since its API is a subset of 4.17.0's.
./gradlew build # compile + tests
./gradlew publishToMavenLocal