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.
- 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 |
[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 |
[back] |
— | Pops the menu history stack |
[refresh] |
— | Re-renders the current menu |
[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.