Skip to main content

Getting Started

Every StoneLib module is constructed directly. There is no bootstrap class to extend and nothing to register with StoneLib itself. Create the pieces you need in onEnable, keep them in fields, and shut down the ones that hold resources in onDisable.

Order matters

Construct modules in dependency order:

  1. Configuration first. Messages, storage, and database settings may all read config values.
  2. Messages next, so everything after can report problems to players.
  3. Scheduler before anything that schedules work.
  4. Storage before anything that loads data from it.
  5. Commands, listeners, and menus once the things they call exist.
  6. Holograms and render loops last, since they are usually populated from loaded data.

Shut down in roughly the reverse order: save data, cancel tasks, then close the database pool.

A small plugin

A plugin with one config file, a messages file, a YAML repository, a command, and cooldowns:

package com.example.myplugin;

import dev.anchorlight.stonelib.command.CommandRouter;
import dev.anchorlight.stonelib.config.ConfigManager;
import dev.anchorlight.stonelib.cooldown.CooldownService;
import dev.anchorlight.stonelib.menu.MenuListener;
import dev.anchorlight.stonelib.message.MessageService;
import dev.anchorlight.stonelib.scheduler.SchedulerService;
import dev.anchorlight.stonelib.storage.YamlRepository;
import org.bukkit.plugin.java.JavaPlugin;

import java.util.UUID;

public final class MyPlugin extends JavaPlugin {

private ConfigManager config;
private MessageService messages;
private SchedulerService scheduler;
private YamlRepository<UUID, Profile> profiles;
private CooldownService cooldowns;

@Override
public void onEnable() {
config = new ConfigManager(this); // plugins/MyPlugin/config.yml
messages = new MessageService(this, "messages.yml");
scheduler = new SchedulerService(this);
cooldowns = new CooldownService("myplugin.cooldown.bypass");

profiles = new YamlRepository<>(this, "profiles.yml",
new ProfileCodec(), Profile::id, UUID::fromString);
profiles.load();

getServer().getPluginManager().registerEvents(new MenuListener(), this);

CommandRouter router = new CommandRouter(this, "myplugin");
router.register(new ReloadCommand(config, messages));
router.register(new DashCommand(cooldowns, messages));
getCommand("myplugin").setExecutor((sender, command, label, args) -> router.dispatch(sender, args));
getCommand("myplugin").setTabCompleter((sender, command, alias, args) -> router.tabComplete(sender, args));

scheduler.runTimer(profiles::save, 20L * 300, 20L * 300); // autosave every five minutes
}

@Override
public void onDisable() {
if (scheduler != null) {
scheduler.cancelAll();
}
if (profiles != null) {
profiles.save();
}
}
}

The bundled resources the plugin needs:

src/main/resources/
├── plugin.yml
├── config.yml
└── messages.yml

ConfigManager and MessageService both require the file to exist as a bundled resource. On first start it is copied to the data folder; on every later start and reload, new keys from the bundled copy are merged in. See Configuration.

A networked plugin

A plugin running on several Paper backends behind Velocity, sharing state in MySQL, has a longer checklist. Keep onEnable short and ordered, and push the construction into private helpers:

@Override
public void onEnable() {
config = new ConfigManager(this);
messages = new MessageService(this, "messages.yml");
scheduler = new SchedulerService(this);

String serverId = config.getConfig().getString("server-id");
if (serverId == null || serverId.isBlank()) {
getLogger().severe("server-id is not set in config.yml; disabling.");
getServer().getPluginManager().disablePlugin(this);
return;
}

pool = new ConnectionPool(DatabaseConfig.fromSection(config.getConfig().getConfigurationSection("database")), "MyPlugin");
selections = new MySqlRepository<>(pool, getLogger(), "selections",
new SelectionCodec(), UUID::fromString, List.of("trail", "hat", "visible"));

// Schema work and the first load block on the database, so do them off the main thread.
scheduler.supplyAsync(() -> {
try {
new SchemaMigrator(pool, "MyPlugin")
.migration(1, "CREATE TABLE IF NOT EXISTS audit (id BIGINT AUTO_INCREMENT PRIMARY KEY, entry TEXT)")
.migrate();
selections.createTable();
selections.load();
return true;
} catch (SQLException e) {
getLogger().log(Level.SEVERE, "Database setup failed", e);
return false;
}
}, ok -> {
if (ok) {
startGameplay();
}
});

bus = new MessageBus(this, "myplugin:sync", serverId);
bus.subscribe("selection-changed", message ->
scheduler.runAsync(() -> selections.reload(UUID.fromString(message.payload()))));
bus.register();
}

@Override
public void onDisable() {
if (renderLoop != null) {
renderLoop.stop();
}
if (scheduler != null) {
scheduler.cancelAll();
}
if (selections != null) {
selections.save(); // flushes pending writes; blocks, which is acceptable on shutdown
}
if (bus != null) {
bus.unregister();
}
if (pool != null) {
pool.close();
}
}

The proxy half is one line in the Velocity plugin. See Cross-Server Messaging.

Reload commands

Modules that read files have a reload() method. A reload sub-command typically calls them in the same order as onEnable:

public final class ReloadCommand implements SubCommand {

private final ConfigManager config;
private final MessageService messages;

public ReloadCommand(ConfigManager config, MessageService messages) {
this.config = config;
this.messages = messages;
}

@Override
public String getName() {
return "reload";
}

@Override
public String getPermission() {
return "myplugin.reload";
}

@Override
public void execute(CommandSender sender, String[] args) {
config.reload();
messages.reload();
messages.send(sender, "reloaded");
}
}

Next steps

  • Read Threading Rules before calling anything that touches storage.
  • Browse the Modules for the full API of each piece.