Skip to content
bwmp-devPublic

About

A plugin framework for Minecraft servers: config, messages, commands, scheduling, GUIs, registries and 1.18-26.x compatibility. Shaded into consumers, not installed.

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

14 Commits

Folders and files

Repository files navigation

Keystone

A shared framework for Minecraft plugins: config, messages, commands, scheduling, GUIs, registries and multi-version compatibility.

Targets Minecraft 1.18 – 26.x on Spigot, Paper, Paper forks and Folia, from Java 17 bytecode.

Keystone is a library, not a plugin. There is nothing extra to install — each consumer shades it into its own jar.


Using it

Set keystone-parent as your parent, name a relocation package, and add the dependency:

<parent>
    <groupId>dev.bwmp</groupId>
    <artifactId>keystone-parent</artifactId>
    <version>1.0.0</version>
</parent>

<properties>
    <keystone.libs.package>dev.bwmp.yourplugin.libs</keystone.libs.package>
</properties>

<dependencies>
    <dependency>
        <groupId>dev.bwmp</groupId>
        <artifactId>keystone-core</artifactId>
    </dependency>
    <dependency>
        <groupId>org.spigotmc</groupId>
        <artifactId>spigot-api</artifactId>
        <scope>provided</scope>
    </dependency>
</dependencies>

Shading and relocation are then automatic. keystone.libs.package must be unique to your plugin — two plugins sharing a relocation target reintroduce exactly the class conflict relocation prevents, so the build fails if you leave it unset or use an invalid package name.

Then in your plugin:

public final class YourPlugin extends JavaPlugin {
    private KeystoneHandle keystone;

    @Override public void onEnable()  { keystone = Keystone.bootstrap(this); }
    @Override public void onDisable() { if (keystone != null) keystone.shutdown(); }
}

Subsystems hang off the handle and are created lazily — a plugin that never opens a menu pays nothing for the GUI listener.


What you get

Messages

MessageService messages = keystone.messages();      // reads messages.yml
messages.send(player, "item-renamed", MessageService.value("name", playerTypedText));

Placeholders are resolvers, not string replacement, and this is the reason the class exists. Splicing a value into the raw text and then parsing the result makes the value indistinguishable from markup the author wrote — so a player renaming an item to <click:run_command:/x>hi gets a working click event in the confirmation message. Adventure's resolver API substitutes during parsing, where a value's content can never be reinterpreted.

value() inserts verbatim and is what the Map<String,String> overload uses, so the safe choice is the default. parsed() opts into markup and has to be asked for by name. KeystoneText.SAFE is a restricted tag set for anything a player typed — no click, hover, insertion or font.

Config

ManagedConfig config = keystone.config("config.yml");
Snapshot<Settings> settings = new Snapshot<>(parse(config));

ManagedConfig merges the copy shipped in your jar with the one on disk. Snapshot<T> holds an immutable value behind a volatile reference: readers see the old value or the new one, never a half-applied reload — which is what makes config safe to read from any thread, and required on Folia.

LoadReport accumulates problems instead of throwing on the first, so an admin who made three mistakes learns about all three now.

Commands

new RootCommand(messages, "usage")
    .register(SimpleSubcommand.of("give", this::give)
        .permission("yourplugin.give")
        .usage("give <item> [player]")
        .completer((sender, args) -> RootCommand.matching(itemIds(), args.get(0, ""))))
    .bind(this, "yourcommand");

Permission and player-only checks are declared per subcommand and enforced once by the tree, rather than repeated at the top of each handler where one omission is a silent hole. Tab completion hides subcommands the sender cannot use.

Scheduling

KeystoneScheduler scheduler = keystone.scheduler();
scheduler.atEntity(player, () -> ...);            // the thread owning that player
scheduler.atLocation(loc, () -> block.setType(...));

One interface, a Bukkit backend and a Folia backend chosen by probe. Using it instead of BukkitRunnable is what makes a plugin Folia-capable. Off Folia the region-aware methods collapse onto the main thread, so the same code is correct on both.

Folia also requires folia-supported: true in your plugin.yml or it will not load your plugin at all.

Registries

OwnedRegistry<MyHandler> handlers = keystone.registry("handler");
handlers.register(otherPlugin, id, handler);

Entries know who owns them, ids must match the owner's namespace, and everything a plugin registered is dropped when that plugin disables — wired centrally, so no consumer has to remember the listener.

GUIs

GuiMenu and PaginatedMenu on the plain Bukkit inventory API, so they work down to 1.18. Menus are identified by inventory holder rather than by title, and every click is cancelled before any slot lookup — which is what stops shift-click and hotbar swaps pulling icons out of slots that have no button.

Platform

keystone.platform().describe();                  // for bug reports
keystone.items().supportsItemModel();

PlatformSubcommand is a ready-made diagnostics subcommand you can mount in your own command tree.

Metrics

Opt-in, in its own artifact — nothing phones home unless you add the dependency:

<dependency>
    <groupId>dev.bwmp</groupId>
    <artifactId>keystone-metrics</artifactId>
</dependency>
metrics = KeystoneMetrics.builder(keystone)
        .bstats(28123)
        .telemetry("https://plugins.metrics.bwmp.dev", "holopanels")
        .chart(Chart.singleLine("panels", () -> panels.size()))
        .chart(Chart.simplePie("storage", () -> settings.get().storage()))
        .start();

A chart is declared once and goes to every sink. That is the whole point: a plugin reporting to both bStats and its author's own endpoint would otherwise register each chart twice against two unrelated APIs, and the two copies drift until someone gives up on the second one. Chart is a plain value — a sink that has never heard of bStats still gets it, and MetricsSink is an interface you can implement.

Three charts are added for you: Keystone version, probe-detected server brand, and whether this is Folia. bStats records Bukkit.getName(), which every Paper fork inherits from Paper — so on its numbers a Purpur server is a Paper server, and the Purpur/Pufferfish/Paper split is exactly what decides whether a fork is worth supporting.

Shutdown is wired to the handle. KeystoneMetrics.chart() still works after start(), which is what lets an extension registered by another plugin be counted without waiting for a restart.

Samplers must be cheap and thread-safe. Which thread they run on is not fixed: the telemetry sink samples on the server thread, bStats samples on Bukkit's, and on Folia bStats samples on its own instead.

Server owners get one switch for everything, in plugins/Keystone/telemetry.yml, and turning off bStats in plugins/bStats/config.yml turns off Keystone telemetry too. bStats keeps its own 30-minute cadence because modifying it is grounds for removal from the service.

bStats has to be relocated or it throws from its own constructor. keystone-parent does it. If you override <relocations> with combine.self="override" — which the Paper-only Adventure build below does — copy the org.bstats. entry across as well, or BStatsSink will log a banner at every boot and send nothing.


Compatibility model

One module per API level, all emitting Java 17 bytecode, so no class can ever fail to load with UnsupportedClassVersionError. Compiling each tier against its own API is what stops it calling a method the target range does not have — the split is enforced by the compiler, not by discipline.

Tier Compiled against Used on
base spigot-api 1.20.4 1.18 – 1.20.4
modern spigot-api 1.21.1 1.20.5 – 1.21.3
latest paper-api 26.1.2 1.21.4+
folia paper-api 1.21.1 Folia

Selection is always a capability probe, never a version comparison — forks misreport versions, and a probe tests the thing that actually matters.


Adventure: two builds

Keystone bundles Adventure so it works on Spigot. Whether you take that copy is your choice, and it is mutually exclusive:

Cross-platform (the default). Adventure is shaded and relocated with Keystone. Your components are a different type from the server's, so all text goes through MessageService and LegacyRenderer — never player.sendMessage(Component).

Paper-only. Exclude net.kyori from keystone-core and override the parent's relocations to drop the Adventure entry:

<dependency>
    <groupId>dev.bwmp</groupId>
    <artifactId>keystone-core</artifactId>
    <exclusions>
        <exclusion><groupId>net.kyori</groupId><artifactId>*</artifactId></exclusion>
    </exclusions>
</dependency>
<relocations combine.self="override">
    <relocation>
        <pattern>dev.bwmp.keystone.</pattern>
        <shadedPattern>${keystone.libs.package}.keystone.</shadedPattern>
    </relocation>
    <!-- Drop this only if you also excluded keystone-metrics. -->
    <relocation>
        <pattern>org.bstats.</pattern>
        <shadedPattern>${keystone.libs.package}.bstats.</shadedPattern>
    </relocation>
</relocations>

combine.self="override" replaces the parent's list wholesale, so every entry you still need has to be repeated. Dropping Adventure is the point; dropping bStats by accident is the trap.

Do this if you hand components to Paper APIs, or publish an API exposing Component. Keystone detects which situation it is in at runtime and sends natively rather than through BukkitAudiences.


If you publish an API

Your published API artifact must not reference any type Keystone relocates. A third party would compile against dev.bwmp.keystone.X while your shipped jar contains dev.bwmp.yourplugin.libs.keystone.X — a NoClassDefFoundError naming a class that looks correct.

Re-declare the concept in your own package and delegate. Sigil does this with SigilScheduler; HoloPanels does it with its Registration handle. Types used purely inside your plugin need no such wrapper.


About

A plugin framework for Minecraft servers: config, messages, commands, scheduling, GUIs, registries and 1.18-26.x compatibility. Shaded into consumers, not installed.

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages