Kotlin library for Paper/Spigot plugins. Provides HOCON config loading, localized MiniMessage support, a config-driven menu/GUI subsystem, Database (HikariCP), and Redis/Dragonfly (Lettuce) out of a single dependency.
A broad hardening + feature pass across every subsystem (all backward compatible).
Menu
- Menu actions now resolve placeholders —
[message],[console],[player],[player-op],[openmenu]substitute the menu's<key>placeholders at click time (previously verbatim), so dynamicPageSourceitems can parameterize their commands/messages. - Menu title is now placeholder-resolved (incl.
<page>/<pages>). - New
[player-op]action (runs a command with a temporary wildcard permission, always removed after). - Player-head items (
head = <name|uuid>) and view requirements (view-requirements— a failing item isn't rendered, not just click-gated). - Auto-update / animated menus via
MenuConfig.updateInterval(ticks); refresh re-renders in place (no close/open flicker). PageSourceRegistrybindings are released onunregisterMenu/disable(no leak across/reload); navigation history is capped; paginated clicks cache their compiled actions; off-main-thread calls warn.
Messages
- Security: untrusted placeholder VALUES are MiniMessage-escaped (no
<click>/colour injection via e.g. player names); trustedprefix/shared placeholders keep formatting. Use component placeholders for formatted values. - Thread-safety (
@Volatilereloadable state), PlaceholderAPI availability re-checked each call (no permanent "absent" poisoning), a pluggable per-playerlocaleResolver, a bounded parsed-Component cache, and precompiled locale regexes.
Database
- Async API (
withConnectionAsync/transactionAsync/queryAsync/updateAsync),query/queryOne/update/executeBatchhelpers, nested-connection reuse (fixes SQLite pool-size-1 deadlock), amigrate(namespace, migrations)runner,@Volatilepool, driver class + SQLite parent-dir creation.
Redis
- Per-listener unsubscribe refcount (one plugin's unsubscribe no longer kills the channel for others), background non-blocking connect with retry, key namespacing (
keyPrefix), pattern subscribe (psubscribe), typedgetObject/setObject, bounded request queue, idempotent start.
Config
- Atomic writes (temp + rename — no truncation on crash), recovery no longer destroys a live config on a schema/programmer error,
UUID/Durationfield support, cached reflection metadata, synchronizedload().DynamicConfignow reads the existing file (was write-only).
Build
- Dropped the flaky
QuickMiniMessagedependency + its single-mirror repo (it broke jitpack/CI and had no TagResolver API);QUICK_MINI_MESSAGEaliases MiniMessage. Disabled the plainjartask so it can't overwrite the shaded fat jar.
- Clickable paginated items.
PageSource-backed items are now fully interactive — clicking a pagination slot compiles and runs that item's per-click action lists (and click requirements / deny actions), exactly like a static shape item. Previously page items were display-only. - Placeholder persistence. The
Map<String,String>passed toopen(player, id, placeholders)is stored on theMenuSession, so refresh, clicks,[refresh],[openmenu]and[back]re-render with the SAME placeholders instead of an empty map.[openmenu]/[back]carry the current placeholders forward. - Page navigation actions:
[page next],[page previous]([page prev]),[page first],[page last]— clamp against the real page count and refresh. Pagination now also exposes the current/total page as placeholders (PaginationConfig.currentPlaceholder/totalPlaceholder, default<page>/<pages>). [back]history fixed — the navigation stack now carries acrossopen()calls, so back actually pops the previous menu.- Fully backward compatible: static menus and existing action-less page items behave exactly as before.
- Menu per-click action lists (DeluxeMenus parity) — each item can declare
left-actions,right-actions,shift-left-actions,shift-right-actions,middle-actionsin addition to the generalactionsfallback. See the dedicated section below. PLAYER-type menus now do the full save → overwrite → restore cycle. The player's inventory is snapshot on open, replaced with menu items, and restored on close / quit / service disable.- Config legacy loader reads
@Paththrough Kotlin reflection, fixing a silent 0-byte config-wipe bug that hit Kotlin 2.3+ plugins withvar-based models. The writer also refuses to overwrite a non-empty file with an empty blueprint. - Zero build warnings.
- Messages
rawString(path)/rawStringLocalized(path, locale)— fetch the unprocessed string (no MiniMessage, no placeholders, no Adventure). Useful for logs and custom processing.Map<String, Component>placeholder overloads on every public method (plain,send,sendLocalized,sendMany,plainLocalized). Component formatting is preserved via MiniMessageTagResolver.miniMessage(path, …)/miniMessageLocalized(…)— same asplainbut returns aComponentdirectly.
- Config
- Bukkit-style enum values and keys are now normalised automatically.
type = "minecraft:gray_stained_glass_pane"andtype = "gray-stained-glass-pane"both resolve toMaterial.GRAY_STAINED_GLASS_PANE. Works for anyEnum<*>—Material,EntityType,PotionType,Sound, etc.
- Bukkit-style enum values and keys are now normalised automatically.
- Menu / GUI subsystem (new —
me.delyfss.cocal.menu)- Config-driven, shape-based menus with 19 inventory types (CHEST, ANVIL, HOPPER, WORKBENCH, …).
- Built-in actions:
[close],[player],[console],[message],[sound],[openmenu],[back],[refresh],[scroll up|down|left|right]. - Custom actions via
Actions.register(factory). - Built-in
[permission]click requirement; extensible registry for more. - Pagination & scrolling via
PageSourceinterface. MenuProtectionListenerblocks drag/drop/pickup/shift-click with a 75 ms debounce. Only cocal inventories are touched — regular chests/anvils/etc. are ignored.
- Database subsystem (new —
me.delyfss.cocal.database)- HikariCP pool, MySQL/MariaDB/SQLite drivers.
DatabaseService.withConnection { }andtransaction { }helpers (auto-commit, rollback on exception).
- Redis / Dragonfly subsystem (new —
me.delyfss.cocal.cache)- Lettuce-backed, fully async (
CompletableFuture), works against any Redis-protocol server. - Key/value, hash, set, and pub/sub operations.
- Graceful degradation — when disabled or unreachable, every operation short-circuits safely.
- Lettuce-backed, fully async (
- Distribution — cocal ships as both a library (for
compileOnly) and a runnable plugin (plugins/cocal.jar). The core plugin wires up one shared menu listener, one DB pool, and one Redis client per server, exposed viaBukkit.getServicesManager().
- Java 21+
- Paper / Spigot 1.20.4+
repositories {
mavenCentral()
maven("https://jitpack.io")
maven("https://repo.nekroplex.com/releases")
}
dependencies {
compileOnly("com.github.RpMGrut:cocal:v1.6")
}Install cocal-1.6.jar into your server's plugins/ directory. Downstream plugins then access shared services:
import me.delyfss.cocal.Cocal
class MyPlugin : JavaPlugin() {
override fun onEnable() {
val menus = Cocal.menus
menus.registerMenu("main", loadMenuConfig())
// ...
}
}Or construct services yourself (library-only mode):
val menuService = MenuService(plugin).also { it.enable() }
val database = DatabaseService(DatabaseConfig(...), logger).also { it.start() }
val redis = RedisService(RedisConfig(...), logger).also { it.start() }import me.delyfss.cocal.Comment
import me.delyfss.cocal.Config
import me.delyfss.cocal.Path
import me.delyfss.cocal.SectionComment
import org.bukkit.Material
data class Tool(
@Comment("Item material (namespaced keys accepted)")
val material: Material = Material.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"
)
)
data class BattleConfig(
@Comment("Main switch")
val enabled: Boolean = false,
@Path("countdown-seconds")
val countdownSeconds: List<Int> = listOf(5, 4, 3, 2, 1),
@SectionComment("Tool settings")
val tool: Tool = Tool()
)
class ConfigManager(private val plugin: JavaPlugin) {
private val loader = Config(plugin.dataFolder, "battle.conf", BattleConfig())
fun reload(): BattleConfig = loader.load()
}Any of these HOCON values resolve to Material.GRAY_STAINED_GLASS_PANE:
type = "GRAY_STAINED_GLASS_PANE"
type = "gray_stained_glass_pane"
type = "minecraft:gray_stained_glass_pane"
type = "gray-stained-glass-pane"
type = "Gray Stained Glass Pane"The same applies to map keys and list elements typed as any Enum<*>.
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
)
)
messages.load()
// Existing plain/send methods still work:
messages.send(player, "ability.cooldown", mapOf("time" to "4"))
// New in 1.5 — raw string getter for logs/custom processing
val raw = messages.rawString("ability.cooldown")
// New in 1.5 — Map<String, Component> placeholder overloads preserve component formatting
val username = Component.text("Alex", NamedTextColor.GOLD)
messages.send(
player,
"greeting",
replacements = emptyMap(),
componentReplacements = mapOf("username" to username)
)
// New in 1.5 — miniMessage(path) returns a parsed Component
val welcome: Component? = messages.miniMessage("welcome")Unchanged from 1.4. See the 1.4 docs inside this file's history.
main {
type = "CHEST"
name = "<#62aef5>City Buildings"
size = 54
shape = [
"AAAAAAAAA",
"A#A#A#A#A",
"A#A#A#A#A",
"AAAAAAAAA",
"A C A",
"AAAAAAAAA"
]
items {
A {
type = "gray_stained_glass_pane"
name = ""
lore = []
}
C {
type = "barrier"
name = "<#e35b5b>Close"
lore = [
"<#dadde8>Closes the menu"
]
click-requirements = ["[permission] mycity.menu"]
deny-actions = ["[message] <red>You don't have permission"]
actions = ["[close]"]
}
}
}Load and open it:
val loaded = Config(plugin.dataFolder, "main.conf", MenuConfig()).load()
Cocal.menus.registerMenu("main", loaded)
Cocal.menus.open(player, "main")- Each character in each row corresponds to one inventory slot.
#and space characters are empty slots.- Every non-empty character must have a matching key in
items. - The inventory size is dictated by the
sizefield, not the shape dimensions.
CHEST (default, variable size 9/18/27/36/45/54), ENDER_CHEST, BARREL, SHULKER_BOX, ANVIL, BEACON, BLAST_FURNACE, BREWING, CARTOGRAPHY, DISPENSER, DROPPER, ENCHANTING, FURNACE, GRINDSTONE, HOPPER, LOOM, PLAYER, SMOKER, WORKBENCH.
Non-chest inventories have a fixed slot count from vanilla Bukkit; the size field is ignored for them.
| Tag | Argument | Description |
|---|---|---|
[close] |
— | Closes the menu for the clicker |
[player] |
<command> |
Runs the command as the player |
[player-op] |
<command> |
Runs the command as the player with a temporary wildcard permission (removed after) |
[console] |
<command> |
Runs the command as console |
[message] |
<minimessage> |
Sends a MiniMessage line to the player |
[sound] |
<name>[:volume[:pitch]] |
Plays a Bukkit Sound |
[openmenu] |
<id> |
Navigates to another registered menu (carries current placeholders) |
[back] |
— | Pops the menu history stack (carries current placeholders) |
[refresh] |
— | Re-renders the current menu |
[page next|previous|first|last] |
— | Moves through pages (clamped to the page count) and refreshes |
[scroll up|down|left|right] |
[step] |
Mutates MenuContext.scrollOffset and refreshes |
Each item supports a general actions list plus five click-type-specific lists. When a click type has its own non-empty list, only that list runs; otherwise actions is the fallback.
items {
K {
type = "diamond_sword"
name = "<gold>Kit"
actions = [
"[message] <gray>Pick a click: LMB=free, RMB=premium, Shift+LMB=info"
]
left-actions = [
"[player] kit free"
"[sound] entity_experience_orb_pickup"
]
right-actions = [
"[player] kit premium"
]
shift-left-actions = [
"[message] <#62aef5>Kits give you a starter loadout"
]
shift-right-actions = [
"[console] broadcast <player> opened the kit menu"
]
middle-actions = [
"[refresh]"
]
}
}Click → list mapping:
Bukkit ClickType |
List used | Fallback if empty |
|---|---|---|
LEFT |
left-actions |
actions |
RIGHT |
right-actions |
actions |
SHIFT_LEFT |
shift-left-actions |
actions (not left-actions) |
SHIFT_RIGHT |
shift-right-actions |
actions (not right-actions) |
MIDDLE |
middle-actions |
actions |
| any other (number keys, drops, double-click, etc.) | — | actions |
Same item can still declare click-requirements and deny-actions; they apply uniformly across every click type. If you need per-click permission gates, use multiple requirements/deny-actions inside the individual click lists instead.
object GiveMoneyActionFactory : ActionFactory {
override val tag = "givemoney"
override fun create(argument: String): Action = object : Action {
private val amount = argument.toDoubleOrNull() ?: 0.0
override fun run(context: ActionContext) {
Economy.add(context.player, amount)
}
}
}
Actions.register(GiveMoneyActionFactory)Only [permission] <node> ships in 1.5. The Requirement / RequirementFactory interfaces are extensible — custom requirements register the same way as actions.
PageSourceRegistry.bind("buildings_menu", object : PageSource {
override fun size(context: MenuContext): Int = repository.count()
override fun itemAt(index: Int, context: MenuContext): MenuItemConfig =
repository[index].toMenuItem()
})Registered exactly once by MenuService.enable(). It guards every cocal menu against click/drag/drop/pickup exploits. It never touches non-cocal inventories — every callback short-circuits on inventory.holder !is MenuHolder.
val database = DatabaseService(
config = DatabaseConfig(
driver = DatabaseDriver.MYSQL,
url = "localhost:3306/mydb",
user = "root",
password = "…",
maximumPoolSize = 10
),
logger = plugin.logger
)
database.start()
database.transaction { connection ->
connection.prepareStatement("INSERT INTO players(uuid, name) VALUES (?, ?)").use {
it.setString(1, player.uniqueId.toString())
it.setString(2, player.name)
it.executeUpdate()
}
}MySQL, MariaDB, and SQLite are supported. SQLite is bundled at runtime; MySQL/MariaDB drivers must be supplied by the consuming plugin.
Works identically against Redis, Dragonfly, or KeyDB — cocal only uses the Redis wire protocol.
val redis = RedisService(
config = RedisConfig(
enabled = true,
uri = "redis://localhost:6379"
),
logger = plugin.logger
)
redis.start()
if (redis.isAvailable) {
redis.setWithTtl("cache:player:$uuid", json, ttlSeconds = 300).join()
val cached = redis.get("cache:player:$uuid").join()
val token = redis.subscribe("cocal:broadcast") { message ->
logger.info("received $message")
}
}When enabled = false or the connection fails, isAvailable stays false and every method returns a safe default (empty, false, 0, or null) so plugins can keep running.
cocal-1.6.jar drops into plugins/ like any other plugin. On first run it creates plugins/cocal/core.conf:
database {
enabled = false
driver = "SQLITE"
url = "plugins/cocal/cocal.db"
user = ""
password = ""
maximum-pool-size = 10
minimum-idle = 2
}
redis {
enabled = false
uri = "redis://localhost:6379"
connection-timeout-millis = 5000
client-name = "cocal"
}Edit this file to enable the shared DB pool and Redis client, then restart the server. Downstream plugins access the shared services via Cocal.menus, Cocal.database, and Cocal.redis.
MenuType.PLAYER is a special inventory type where the "GUI" is the player's own inventory. Useful for kit selectors, custom hotbars, lobby menus, etc. cocal handles the full save / overwrite / restore cycle:
- On
open(), cocal snapshotsplayer.inventory.contentsinto the session. - The storage slots (0..35) are cleared and filled with items from the menu shape.
- On
[close], player quit, or service disable, the saved contents are written back.
Shape slot → player inventory slot mapping (see PlayerMenuSlots):
| Shape row | Player inventory slots | Visual position |
|---|---|---|
| 0 | 9..17 | top row of main inventory |
| 1 | 18..26 | middle row of main inventory |
| 2 | 27..35 | bottom row of main inventory |
| 3 | 0..8 | hotbar |
Rows beyond 3 are ignored. Armor and offhand are not touched by the shape but are preserved in the snapshot and restored correctly.
While a PLAYER menu is active, the protection listener cancels: item drops, drag operations, inventory clicks that would move menu items, and F-key offhand swaps. Clicks on menu slots inside the player's own inventory (opened with E) are routed through the normal action dispatch path.
Per TZ section 13, these items were explicitly scoped out:
- Requirements beyond
[permission]—HasItem,HasMoney,Regex,StringLength, JavaScript and proximity requirements from DeluxeMenus. TheRequirementregistry is ready; the factories are not shipped. Adding them is one file each — see the "Custom actions" section for the pattern. - Embedded Redis for tests — the real-Redis integration test is gated on
REDIS_TEST_URI; no embedded server is shipped because Dragonfly isn't embeddable andembedded-redisprojects are unmaintained.
No breaking changes. Existing Config, DynamicConfig, and Messages usage keeps working unchanged. The new subsystems are additive and opt-in.
- Menu not opening — check that
MenuService.enable()was called (it is called automatically when the cocal plugin is installed). - Items missing from shape — the warning
Menu '…' shape references unknown item '…'is logged when a shape character has no matchingitemskey. - Redis
isAvailable == false— check the logs for the Lettuce connection exception; common causes areenabled = false, wrong URI, or firewall rules. - Database
IllegalStateException: DatabaseService not started— callstart()before anywithConnection/transactioninvocation.