A professional, annotation-driven plugin framework for Paper 1.21.x built around dependency injection, automatic component scanning, and a clean module lifecycle.
dev.mzcy.core · Paper 1.21.x · Java 21 · Gradle KTS · Lombok
- Overview
- Architecture
- Getting Started
- Dependency Injection
- Config Framework
- Command Framework
- Inventory Framework
- Data Store
- Event Listeners
- Module System
- Item Builders
- Display Systems
- NPC Framework
- Interaction Systems
- Task Pipeline
- Loot Table System
- Particle System
- PlaceholderAPI Integration
- Update Checker
- Hot-Reload
- Debug Overlay
- Utilities
- Annotation Reference
- Exception Hierarchy
- Boot Sequence
- Full Example Plugin
Core is a framework plugin — it does not add gameplay. It provides the infrastructure that your own plugins build on top of:
| Subsystem | What it gives you |
|---|---|
| DI Container | Constructor, field, and method injection with singleton/prototype scopes |
| Class Scanner | Automatic discovery of @Component, @Command, @Config, @Listener, @DataStore, @InventoryGui |
| Config Framework | Type-safe YAML/JSON configs as plain Java objects |
| Command Framework | Annotation-based commands with sub-command routing, cooldowns, no plugin.yml declarations needed |
| Inventory Framework | Fluent GUI builder with automatic click routing, paged GUIs, and per-player state isolation |
| Data Store | Binary, non-human-readable persistent key-value storage per plugin |
| Display Systems | Title/Actionbar manager, BossBar manager, Scoreboard sidebar, Hologram framework (Display entities) |
| NPC Framework | Citizens-free NPC system with hologram labels, skin support, and click actions |
| Interaction Systems | Chat input, multi-step forms, context menus, NPC conversation trees |
| Task Pipeline | Fluent async/sync task chains with @Task annotation scheduling |
| Loot Tables | Weighted, pool-based loot system with conditions and @LootTableDef auto-registration |
| Particle System | Typed particle effects, geometric shapes, and animated sequences |
| PlaceholderAPI | Soft-dependency integration with auto-discovered PlaceholderProvider components |
| Item Builders | Modular, typed fluent builders for every item meta variant |
| Hot-Reload | Config + listener reload without restart via @Reloadable and /core reload |
| Debug Overlay | /core debug with JVM, server, DI, and custom @Debug entries — pasteable to pastes.dev |
| Utilities | Scheduler, ComponentUtil, ColorUtil, TimeUtil, SoundUtil, Preconditions |
CorePlugin (Bootstrap)
│
├── Container (DI)
│ └── Injector
│
├── ComponentRegistry
│ ├── ClassScanner ← scans JAR entries
│ ├── ScanResult ← categorized class sets
│ └── AnnotationProcessor ← wires into Container
│
├── ConfigManager ← loads/saves AbstractConfig subclasses
├── DataStoreManager ← initializes AbstractDataStore subclasses
├── CommandManager ← registers BaseCommand subclasses
│ └── CooldownManager ← @Cooldown annotation enforcement
├── InventoryManager ← tracks AbstractGui instances
│ └── GuiListener ← routes Bukkit click/close events
├── ScoreboardManager ← named FastSidebar registry
├── HologramManager ← Display entity holograms
├── NpcManager ← Citizens-free NPC system
├── BossBarManager ← per-player boss bars
├── ActionbarManager ← priority-queue action bars
├── ChatInputManager ← chat input sessions
├── FormManager ← multi-step form sessions
├── MenuManager ← context menu sessions
├── ConversationManager ← NPC conversation trees
├── TaskManager ← @Task scheduling + TaskChain factory
├── LootManager ← @LootTableDef registry + rolling
├── PlaceholderManager ← PAPI soft-dependency
├── HotReloadManager ← @Reloadable + /core reload
├── DebugOverlay ← /core debug + pastes.dev upload
│ └── DebugRegistry ← @Debug entry discovery
└── ModuleRegistry ← load → enable → disable lifecycle
Every subsystem is registered as a singleton in the DI container, meaning you can inject any manager directly into your components.
build.gradle.kts (your plugin):
repositories {
maven("https://repo.mzcy.dev/releases") // or local
}
dependencies {
compileOnly("dev.mzcy:core:1.0.0-SNAPSHOT")
}plugin.yml:
depend:
- CoreThat's it. Core handles all scanning, injection, and registration automatically.
Recommended package layout for a plugin using Core:
dev.mzcy.myplugin/
├── MyPlugin.java
├── command/
│ └── SpawnCommand.java ← @Command + extends BaseCommand
├── config/
│ └── MainConfig.java ← @Config + extends AbstractConfig
├── data/
│ └── PlayerDataStore.java ← @DataStore + extends AbstractDataStore
├── gui/
│ └── MainMenuGui.java ← @InventoryGui + extends AbstractGui
├── listener/
│ └── JoinListener.java ← @Component + @Listener
├── loot/
│ └── ModLootTables.java ← @Component with @LootTableDef methods
└── service/
└── PlayerService.java ← @Component
Your MyPlugin.java just needs to trigger Core's scanner:
public final class MyPlugin extends JavaPlugin {
@Override
public void onEnable() {
final CorePlugin core = CorePlugin.getInstance();
core.getComponentRegistry().scanAndProcess("dev.mzcy.myplugin");
core.getConfigManager().initializeAll(core.getScanResult());
core.getDataStoreManager().initializeAll(core.getScanResult());
core.getCommandManager().registerAll(core.getScanResult());
core.getInventoryManager().initializeAll(core.getScanResult());
}
}@Component
public class EconomyService {
public void addBalance(UUID player, double amount) { ... }
}The scanner discovers @Component classes automatically. You never call new EconomyService().
@Component
public class ShopService {
// Field injection
@Inject
private EconomyService economyService;
// Constructor injection (preferred — makes dependencies explicit)
@Inject
public ShopService(EconomyService economyService, MainConfig config) { ... }
// Method injection (called after field injection)
@Inject
public void setConfig(MainConfig config) { ... }
}| Annotation | Behavior |
|---|---|
@Singleton (default) |
One shared instance for the entire container lifetime |
@Prototype |
New instance created on every injection point |
GUIs are automatically @Prototype — each open() call gets a fresh instance with isolated per-player state.
container.bind(DataSource.class, MySQLDataSource.class, "mysql", Scope.SINGLETON);
container.bind(DataSource.class, RedisDataSource.class, "redis", Scope.SINGLETON);
@Inject @Named("mysql") private DataSource primaryDatabase;
@Inject @Named("redis") private DataSource cacheDatabase;@Component
public class ConnectionPool {
@PostConstruct // called after all @Inject fields are resolved
public void init() { openConnections(); }
@PreDestroy // called before the container destroys this instance
public void cleanup() { closeConnections(); }
}Container container = CorePlugin.getInstance().getContainer();
container.bind(PaymentGateway.class, StripeGateway.class);
container.bindInstance(MyLibrary.class, MyLibrary.create());
container.bindFactory(Report.class, PdfReport.class,
() -> new PdfReport(new FileOutputStream("out.pdf")), Scope.PROTOTYPE);
EconomyService service = container.resolve(EconomyService.class);@Config(value = "settings", format = ConfigFormat.YAML)
public class MainConfig extends AbstractConfig {
public String prefix = "<dark_gray>[<aqua>MyPlugin<dark_gray>] ";
public boolean debug = false;
public int maxHomes = 5;
public List<String> worlds = List.of("world", "world_nether");
public DatabaseSection database = new DatabaseSection();
public static class DatabaseSection implements java.io.Serializable {
public String host = "localhost";
public int port = 3306;
public String name = "myplugin";
}
@Override
protected void onLoad() {
if (maxHomes < 1) maxHomes = 1;
}
}The file is created at plugins/MyPlugin/settings.yml on first load. Default values serve as fallback when the file
does not exist.
| Attribute | Default | Description |
|---|---|---|
value |
required | Filename without extension |
format |
YAML |
YAML or JSON |
directory |
"" (root) |
Sub-directory within the data folder |
autoSave |
true |
Save on plugin disable |
copyDefaults |
true |
Copy from JAR resources if file missing |
@Inject private MainConfig config;
// Or via manager
MainConfig config = CorePlugin.getInstance().getConfigManager().get(MainConfig.class);config.reload();
CorePlugin.getInstance().getConfigManager().reloadAll();YAML (default):
prefix: '<dark_gray>[<aqua>MyPlugin<dark_gray>] '
debug: false
maxHomes: 5JSON:
{
"prefix": "<dark_gray>[<aqua>MyPlugin<dark_gray>] ",
"debug": false,
"maxHomes": 5
}Extend BaseCommand and annotate with @Command. No plugin.yml declaration needed:
@Command(
name = "home",
description = "Manage your homes",
usage = "/home <set|delete|list|tp>",
permission = "myplugin.home",
aliases = {"homes", "h"},
playerOnly = true
)
public class HomeCommand extends BaseCommand {
@Inject private HomeService homeService;
@Override
protected void onCommand(@NotNull CommandContext ctx) {
ctx.send("<yellow>Usage: /home <set|delete|list|tp>");
homeService.listHomes(ctx.playerOrThrow())
.forEach(name -> ctx.send("<gray> - " + name));
}
}@SubCommand(value = "set", permission = "myplugin.home.set",
usage = "/home set <n>", minArgs = 1, playerOnly = true)
public void onSet(CommandContext ctx) {
final String name = ctx.arg(0).orElse("home");
homeService.setHome(ctx.playerOrThrow(), name);
ctx.sendSuccess("Home <white>" + name + "<green> set!");
}
@SubCommand(value = "delete", minArgs = 1, playerOnly = true)
public void onDelete(CommandContext ctx) { ... }
@SubCommand(value = "list", playerOnly = true)
public void onList(CommandContext ctx) { ... }
@SubCommand(value = "tp", usage = "/home tp <n>", minArgs = 1, playerOnly = true)
public void onTeleport(CommandContext ctx) { ... }Routing happens automatically — /home set beach calls onSet, /home list calls onList, etc.
ctx.isPlayer();
ctx.player(); // Optional<Player>
ctx.playerOrThrow(); // Player — throws if not player
ctx.hasPermission("node");
ctx.argCount();
ctx.arg(0); // Optional<String>
ctx.argInt(1); // Optional<Integer>
ctx.argDouble(2); // Optional<Double>
ctx.joinArgs(1); // "arg1 arg2 arg3" from index 1 onward
ctx.send("<green>Done!");
ctx.sendError("Something went wrong.");
ctx.sendSuccess("Action completed.");
ctx.sendPlain("No formatting here.");@Override
protected List<String> onTabComplete(@NotNull CommandContext ctx) {
if (ctx.argCount() == 1) return homeService.listHomes(ctx.playerOrThrow());
return super.onTabComplete(ctx);
}
@Override
protected List<String> onSubTabComplete(@NotNull CommandContext ctx,
@NotNull SubCommandHandler handler) {
if (handler.token().equals("tp") || handler.token().equals("delete")) {
return homeService.listHomes(ctx.playerOrThrow());
}
return Collections.emptyList();
}Apply @Cooldown to any @Command class or @SubCommand method:
@SubCommand("heal")
@Cooldown(
value = 30,
unit = TimeUnit.SECONDS,
message = "<red>Heal again in <bold><remaining></bold>."
)
public void onHeal(CommandContext ctx) {
ctx.playerOrThrow().setHealth(20);
ctx.sendSuccess("Healed!");
}
// Global cooldown — shared across all players
@SubCommand("daily")
@Cooldown(value = 1, unit = TimeUnit.DAYS, global = true)
public void onDaily(CommandContext ctx) { ... }
// Custom bypass permission
@SubCommand("other")
@Cooldown(value = 5, bypassPermission = "myplugin.admin")
public void onOther(CommandContext ctx) { ... }| Placeholder | Description |
|---|---|
<remaining> |
Human-readable time remaining |
<total> |
Total cooldown duration |
Players with core.cooldown.bypass skip all cooldowns automatically. Manual API:
CooldownManager cooldowns = CorePlugin.getInstance().getCooldownManager();
cooldowns.apply(player, "my_action", Duration.ofSeconds(30));
cooldowns.clear(player, "my_action");
cooldowns.getRemaining(player, "my_action");
cooldowns.isOnCooldown(player, "my_action");@InventoryGui(id = "main_menu", title = "<dark_gray>✦ Main Menu ✦", rows = 3)
public class MainMenuGui extends AbstractGui {
@Inject private HomeService homeService;
@Override
protected void build(@NotNull GuiBuilder builder) {
builder
.border(Material.GRAY_STAINED_GLASS_PANE)
.slot(13,
ItemBuilder.of(Material.NETHER_STAR)
.name("<gold>My Homes")
.lore("<gray>Click to manage homes")
.build(),
event -> {
getViewer().closeInventory();
CorePlugin.getInstance().getInventoryManager()
.open("homes_gui", (Player) event.getWhoClicked());
}
)
.slot(22,
ItemBuilder.of(Material.BARRIER).name("<red>Close").build(),
event -> event.getWhoClicked().closeInventory()
);
}
@Override protected void onOpen(@NotNull Player player) { ... }
@Override protected void onClose(@NotNull Player player) { ... }
}CorePlugin.getInstance().getInventoryManager().open("main_menu", player);
MainMenuGui gui = CorePlugin.getInstance().getInventoryManager()
.open(MainMenuGui.class, player);CorePlugin.getInstance().getInventoryManager()
.findGui(player.getOpenInventory().getTopInventory())
.ifPresent(AbstractGui::refresh);builder
.slot(index, item, clickAction) // interactive
.slot(index, item) // decorative
.slotRange(0, 8, fillerItem) // fill range
.fill(Material.BLACK_STAINED_GLASS_PANE) // fill empty slots
.fill(customItem)
.border(Material.GRAY_STAINED_GLASS_PANE) // draw border
.clear(index); // remove slotExtend PagedGui for automatic pagination:
@InventoryGui(id = "home_list", title = "<gold>Your Homes", rows = 6)
public class HomeListGui extends PagedGui {
@Inject private HomeService homeService;
@Override
protected List<PagedItem> getItems() {
return homeService.getHomes(getViewer().getUniqueId()).stream()
.map(home -> PagedItem.of(
ItemBuilder.of(Material.RED_BED).name("<gold>" + home.getName()).build(),
event -> homeService.teleport((Player) event.getWhoClicked(), home)
))
.toList();
}
// Optional overrides
@Override protected List<Integer> getContentSlots() { ... }
@Override protected void decorateBackground(@NotNull GuiBuilder builder) {
builder.border(Material.BLACK_STAINED_GLASS_PANE);
}
@Override protected ItemStack buildPreviousButton(@NotNull PageContext ctx) { ... }
@Override protected ItemStack buildNextButton(@NotNull PageContext ctx) { ... }
@Override protected ItemStack buildPageIndicator(@NotNull PageContext ctx) { ... }
}For searchable lists, extend SearchablePagedGui and implement getAllItems() instead of getItems():
@InventoryGui(id = "player_list", title = "<aqua>Players", rows = 6)
public class PlayerListGui extends SearchablePagedGui {
@Override
protected List<PagedItem> getAllItems() { ... }
@Override
protected void buildControls(@NotNull GuiBuilder builder, @NotNull PageContext ctx) {
super.buildControls(builder, ctx); // prev / indicator / next
builder.slot(47, buildSearchButton(), event ->
chatInput.builder(getViewer()).prompt("<gold>Search:").timeout(20)
.request().thenAccept(r -> { if (r.isCompleted()) applyFilter(r.getValue()); })
);
if (hasActiveFilter()) {
builder.slot(48, buildClearFilterButton(), event -> clearFilter());
}
}
}Navigation methods: nextPage(), previousPage(), goToPage(n), firstPage(), lastPage().
@DataStore(value = "playerdata", directory = "data")
public class PlayerDataStore extends AbstractDataStore<UUID, PlayerData> {
public PlayerDataStore() { super(new BinaryDataSerializer<>()); }
@Override protected String keyToFileName(@NotNull UUID key) { return key.toString(); }
@Override protected UUID fileNameToKey(@NotNull String f) { return UUID.fromString(f); }
}Data is stored as plugins/MyPlugin/data/playerdata/<uuid>.dat — binary, XOR-obfuscated, not human-readable.
store.put(uuid, data);
store.get(uuid); // Optional<PlayerData>
store.remove(uuid); // returns true if removed
store.getAll(); // Map<UUID, PlayerData>
store.contains(uuid);
store.size();store.put(uuid, sessionData, Instant.now().plus(Duration.ofHours(24)));
store.getEntry(uuid).ifPresent(entry -> {
System.out.println("Created: " + entry.getCreatedAt());
System.out.println("Expires: " + entry.getExpiresAt());
System.out.println("Expired: " + entry.isExpired());
});@DataStore("factiondata")
public class FactionDataStore extends AbstractDataStore<String, FactionData> {
public FactionDataStore() { super(new BinaryDataSerializer<>()); }
@Override protected String keyToFileName(@NotNull String key) {
return key.toLowerCase().replaceAll("[^a-z0-9_]", "_");
}
@Override protected String fileNameToKey(@NotNull String fileName) { return fileName; }
}@Component
@Listener
public class PlayerJoinListener implements Listener {
@Inject private PlayerService playerService;
@Inject private MainConfig config;
@EventHandler(priority = EventPriority.NORMAL)
public void onJoin(PlayerJoinEvent event) {
final Player player = event.getPlayer();
event.joinMessage(ComponentUtil.parse(
config.prefix + "<green>" + player.getName() + " joined the game."));
playerService.loadOrCreate(player.getUniqueId(), player.getName());
}
@EventHandler
public void onQuit(PlayerQuitEvent event) {
playerService.savePlayer(event.getPlayer());
}
}The framework automatically calls Bukkit.getPluginManager().registerEvents(...) — no manual registration needed.
public class EconomyModule extends AbstractCoreModule {
private final Container container;
public EconomyModule(Container container) {
super("Economy");
this.container = container;
}
@Override
protected void onLoad() {
container.bind(PaymentGateway.class, LocalPaymentGateway.class);
container.bind(EconomyService.class);
}
@Override
protected void onEnable() {
SchedulerUtil.repeatAsync(plugin, this::processQueue,
SchedulerUtil.seconds(5), SchedulerUtil.seconds(5));
}
@Override
protected void onDisable() {
container.resolve(TransactionLog.class).flush();
}
}core.getModuleRegistry().register(new EconomyModule(core.getContainer()));Registration → loadAll() → enableAll() → [runtime] → disableAll() (reverse order)
All builders use the CRTP pattern — every method returns the most specific builder type, so you never lose the sub-type while chaining.
ItemStack sword = ItemBuilder.of(Material.DIAMOND_SWORD)
.name("<gradient:#FF6B6B:#FFE66D>⚔ Excalibur</gradient>")
.lore("<gray>A blade of legend.", "<red>❤ +10 Attack Damage")
.enchant(Enchantment.SHARPNESS, 5)
.enchant(Enchantment.UNBREAKING, 3)
.unbreakable(true)
.hideAllFlags()
.build();
ItemStack filler = ItemBuilder.filler();
ItemStack redFiller = ItemBuilder.filler(Material.RED_STAINED_GLASS_PANE);SkullBuilder.of().owner(player.getUniqueId()).name("<yellow>Head").build();
SkullBuilder.of().textureBase64("eyJ0ZXh0dXJlcyI6...").build();
SkullBuilder.of().textureUrl("https://textures.minecraft.net/texture/...").build();LeatherArmorBuilder.of(Material.LEATHER_CHESTPLATE).colorHex("#C0392B").build();
LeatherArmorBuilder.of(Material.LEATHER_BOOTS).color(0, 150, 255).build();
LeatherArmorBuilder.of(Material.LEATHER_HELMET).color(Color.GREEN).build();BookBuilder.written()
.title("<gold>Core Manual").author("<gray>mzcy")
.page("<yellow><bold>Welcome!\n\n<reset><gray>Page one content.")
.page("<aqua>Chapter 1: Getting Started")
.build();
BookBuilder.writable().name("<gray>Empty Journal").build();FireworkBuilder.of()
.power(2)
.effect(FireworkBuilder.FireworkEffectBuilder.create()
.type(FireworkEffect.Type.STAR)
.color(Color.AQUA, Color.WHITE)
.fadeColor(Color.BLUE)
.trail(true).flicker(true).build())
.build();
FireworkBuilder.of().power(3)
.ballEffect(Color.RED, Color.ORANGE)
.starEffect(Color.YELLOW, Color.WHITE)
.build();// Titles — fluent builder
TitleBuilder.create()
.title("<gold><bold>Round Over!")
.subtitle("<gray>You placed <white>3rd")
.fadeIn(Duration.ofMillis(500))
.stay(Duration.ofSeconds(3))
.fadeOut(Duration.ofMillis(500))
.send(player);
// Static shortcuts
TitleBuilder.send(player, "<gold>Hello!", "<gray>Welcome back");
TitleBuilder.sendTitle(player, "<gold>Hello!");
TitleBuilder.clear(player);
// Actionbar — priority queue prevents mutual overwriting
actionbarManager.set(player, "coords",
"<gray>X: <white>" + loc.getBlockX(), 0); // priority 0 = background
actionbarManager.setDynamic(player, "coords",
() -> "<gray>X: <white>" + player.getLocation().getBlockX(), 0);
actionbarManager.sendTemporarySeconds(player, "<green>✔ Saved!", 3, 10);
actionbarManager.clear(player, "coords");
actionbarManager.clearAll(player);Higher priority messages are shown first. When a temporary message expires, the next highest takes over automatically.
// Countdown bar — progress automatically decreases to 0
bossBarManager.builder(player, "respawn")
.title("<red>Respawning in...")
.color(BossBar.Color.RED)
.overlay(BossBar.Overlay.NOTCHED_10)
.countdown(Duration.ofSeconds(5))
.show();
// Dynamic bar with live updates
bossBarManager.builder(player, "combat")
.dynamicTitle(() -> "<red>⚔ Combat: <white>" + remaining + "s")
.dynamicProgress(() -> combatService.getRemainingFraction(player))
.color(BossBar.Color.RED)
.duration(Duration.ofSeconds(30))
.show();
// Permanent notification
bossBarManager.builder(player, "event")
.title("<gold>⚡ Double XP Weekend Active!")
.color(BossBar.Color.YELLOW)
.progress(1.0f)
.show();
bossBarManager.hide(player, "combat");
bossBarManager.hideAll(player);
bossBarManager.has(player, "combat");Per-player sidebars with dynamic lines, dirty-check updates (no flicker), auto-show on join:
scoreboardManager.register("main",
SidebarBuilder.create("<gradient:#FFD700:#FFA500><bold>MyServer</gradient>")
.line("<dark_gray>━━━━━━━━━━━━━━━")
.blank()
.dynamic(() -> "<gray>Players<dark_gray>: <white>"
+ Bukkit.getOnlinePlayers().size())
.dynamic(() -> "<gray>TPS<dark_gray>: <white>"
+ String.format("%.1f", Math.min(20.0, Bukkit.getTPS()[0])))
.blank()
.line("<gray>play.myserver.net")
.build(plugin)
);
scoreboardManager.setDefault("main"); // auto-shown to all players on join
scoreboardManager.startUpdating("main", 20L);
scoreboardManager.show(player, "vip"); // switch sidebar for one player
scoreboardManager.hide(player);
// Dynamic title
scoreboardManager.getSidebar("main")
.ifPresent(s -> s.setTitle("<red>Server Restarting!"));
// Update a specific line
scoreboardManager.getSidebar("main")
.ifPresent(s -> s.setLine(2, "<yellow>Players: " + count));Uses modern Display entities (1.19.4+) — TextDisplay, ItemDisplay, BlockDisplay. No ArmorStands:
// Text + item mixed hologram
hologramManager.builder("shop_sign", location)
.item(new ItemStack(Material.DIAMOND),
ItemDisplay.ItemDisplayTransform.GROUND, 1.2f)
.text("<aqua><bold>Diamond Shop")
.text("<gray>Right-click the NPC to browse")
.dynamicText(() -> "<yellow>Stock: <white>" + shop.getStock())
.lineSpacing(0.08)
.spawn();
// Block display hologram
hologramManager.builder("beacon_holo", location)
.block(Material.BEACON.createBlockData(), 0.6f, 45f)
.text("<light_purple>Beacon Active")
.dynamicText(() -> "<gray>Range: <white>" + beaconService.getRange() + " blocks")
.spawn();
// Dynamic leaderboard
hologramManager.builder("kills_lb", location)
.text("<red><bold>⚔ Kill Leaderboard")
.text("<dark_gray>──────────────")
.dynamicText(() -> "<gold>#1 <white>" + lb.getTop(1).getName()
+ " <gray>— <red>" + lb.getTop(1).getKills())
.dynamicText(() -> "<gray>#2 <white>" + lb.getTop(2).getName())
.spawn();
// Mutate after spawn
hologramManager.get("shop_sign")
.flatMap(h -> h.getTextLine(2))
.ifPresent(line -> line.setText("<green>Open Now"));
hologramManager.setUpdateInterval(10L); // update every 0.5s
hologramManager.remove("shop_sign");
hologramManager.removeAll();Citizens-free NPC system using ArmorStand proxies with Display entity hologram labels:
npcManager.builder("shop_keeper")
.name("<gold><bold>Shop Keeper")
.location(new Location(world, 0.5, 64, 0.5))
.texture(TEXTURE_VALUE, TEXTURE_SIGNATURE)
.hologram("<gold><bold>Shop Keeper", "<gray>Right-click to browse")
.lookAtPlayer(true)
.lookAtDistance(6.0)
.viewDistance(48)
.glowing(false)
.collidable(false)
.onClick((player, npc, type) -> {
if (type == NpcClickType.RIGHT_CLICK) {
inventoryManager.open("shop_gui", player);
}
})
.spawn();
// Dynamic hologram update
npcManager.get("shop_keeper").ifPresent(npc ->
npc.updateHologram(List.of(
"<gold><bold>Shop Keeper",
"<gray>Items: <white>" + shop.getItemCount(),
shop.isOpen() ? "<green>Open" : "<red>Closed"
))
);
npcManager.despawn("shop_keeper");
npcManager.despawnAll();
npcManager.count();chatInputManager.builder(player)
.prompt("<gold>Enter your home name:")
.cancelKeyword("cancel")
.cancelMessage("<gray>Cancelled.")
.timeoutMessage("<red>Timed out.")
.timeout(Duration.ofSeconds(30))
.closeInventory(true)
.validator(
InputValidator.Validators.alphanumeric()
.and(InputValidator.Validators.maxLength(16))
)
.request()
.thenAccept(result -> {
switch (result.getStatus()) {
case COMPLETED -> homeService.createHome(player, result.getValue());
case CANCELLED -> player.sendMessage("<gray>Cancelled.");
case TIMED_OUT -> player.sendMessage("<red>Too slow!");
case DISCONNECTED -> log.info(player.getName() + " disconnected.");
}
});Built-in validators: notBlank(), maxLength(n), minLength(n), alphanumeric(), integer(),
integerInRange(min, max), positiveDecimal(), matches(regex, msg), onlinePlayer(). Combine with .and().
Multi-step sequential input forms:
Form form = Form.builder("create_home")
.title("<gold>Create Home")
.field(FormField.builder("name")
.prompt("<gold>Home name:")
.placeholder("e.g. base, farm")
.validator(InputValidator.Validators.alphanumeric()
.and(InputValidator.Validators.maxLength(16)))
.build()
)
.field(FormField.builder("icon")
.prompt("<gold>Icon material:")
.required(false)
.defaultValue("RED_BED")
.build()
)
.field(FormField.builder("confirm")
.prompt("<yellow>Confirm? (yes/no)")
.inputType(FormField.InputType.CONFIRM)
.build()
)
.onSubmit(response -> {
if (response.isConfirmed("confirm")) {
homeService.createHome(player,
response.get("name"), response.get("icon"));
}
})
.onCancel(r -> player.sendMessage("<gray>Cancelled at: " + r.getCancelledAtField()))
.build();
formManager.register(form);
formManager.open("create_home", player);
// Inline (no registration needed)
formManager.open(form, player).thenAccept(response -> { ... });FormResponse methods: get(key), getInt(key), getDouble(key), isConfirmed(key), isSubmitted(),
isCancelled(), getCancelledAtField().
Lightweight chat-based context menus — no inventory needed. Players click text or type a number:
ContextMenu.builder("home_options")
.title("<gold>Home Options — " + home.getName())
.item(MenuItem.of("<green>⚡ Teleport",
List.of("<gray>Teleport to this home"),
(p, m) -> homeService.teleport(p, home)))
.item(MenuItem.of("<yellow>✏ Rename",
(p, m) -> formManager.open("rename_home", p)))
.item(MenuItem.separator())
.item(MenuItem.of("<red>✗ Delete",
List.of("<gray>This cannot be undone"),
(p, m) -> confirmAndDelete(p, home)))
.item(MenuItem.submenu("<aqua>More Options",
buildMoreMenu(player)))
.timeout(30L)
.build()
.open(player);Output in chat:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Home Options — base
▸ [1] ⚡ Teleport
│ Teleport to this home
▸ [2] ✏ Rename
──────────────
▸ [3] ✗ Delete
│ This cannot be undone
▸ [4] More Options ▶
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Click an option or type its number.
MenuItem types: ACTION, SEPARATOR, DISABLED, SUBMENU.
Branching NPC dialogue trees with conditions, actions, and player choice:
ConversationTree tree = ConversationTree.builder("guard_intro")
.npcName("<gold>⚔ Guard")
.startNode("greeting")
.dialogue("greeting", List.of(
"<gold>[Guard]</gold> <white>Halt! State your business."
), "check_rank")
.condition("check_rank",
(player, ctx) -> rankService.hasRank(player, "citizen"),
"citizen_path", "stranger_path"
)
.dialogue("citizen_path", List.of(
"<gold>[Guard]</gold> <white>Welcome home, citizen."
), "choice")
.choice("choice", null, List.of(
ConversationChoice.of("quest", "Any work available?", "offer_quest"),
ConversationChoice.of("bye", "Farewell.", "end_friendly")
))
.action("offer_quest",
(p, ctx) -> {
questService.assign(p, "wolf_hunt");
ctx.setFlag("quest_accepted");
},
"quest_end"
)
.end("quest_end", "<gold>[Guard]</gold> <green>Good luck.")
.end("end_friendly", "<gold>[Guard]</gold> <white>Safe travels.")
.end("stranger_path","<gold>[Guard]</gold> <red>Move along.")
.build();
conversationManager.register(tree);
// Start from NPC click
conversationManager.start("guard_intro", player)
.thenAccept(ctx -> {
if (ctx.hasFlag("quest_accepted")) {
player.sendMessage("<green>Quest added to your journal!");
}
});Node types: DIALOGUE, CHOICE, ACTION, CONDITION, END. Choices support visibleIf conditions.
ConversationContext provides key-value data store and flag system between nodes.
Fluent async/sync task chains with automatic thread-switching mid-chain:
taskManager.chain()
.asyncSupply(() -> database.loadPlayerData(uuid)) // async DB call
.syncConsume((ctx, data) -> { // → main thread
ctx.set("data", data);
player.sendMessage("<green>Profile loading...");
})
.async((ctx, data) -> enrichData(data)) // → async thread
.delay(40L) // wait 2 seconds (sync)
.syncConsume((ctx, data) -> openMainMenu(player)) // → main thread
.onError(ex -> player.sendMessage("<red>Error: " + ex.getMessage()))
.onComplete(ctx -> log.info("Chain completed."))
.onCancel(ctx -> log.info("Chain cancelled."))
.execute(); // returns CompletableFuture<TaskContext>Guard clauses mid-chain:
.sync((ctx, balance) -> {
if (balance < price) { ctx.cancel(); }
return balance;
})
.onCancel(ctx -> player.sendMessage("<red>Not enough money."))Scheduled tasks via @Task — auto-discovered on boot:
@Component
public class BackupService {
@Task(name = "DB Backup", async = true, repeat = true,
delay = 6000L, period = 72000L)
public void runBackup() { database.backup(); }
@Task(name = "Startup Check", async = true, delay = 40L)
public void startupCheck() { database.verifySchema(); }
}Manual scheduling:
taskManager.schedule("leaderboard_update", true, 0L, 200L,
leaderboardService::rebuild);
taskManager.cancelTask("leaderboard_update");
taskManager.cancelAll();Weighted pool-based drop system with conditions and @LootTableDef auto-registration:
@Component
public class ModLootTables {
@LootTableDef
public LootTable zombieDrops() {
return LootTable.builder("zombie_drops")
.pool(LootPool.builder("common")
.rolls(1, 3)
.bonusRollsPerLooting(1)
.entry(LootEntry.of(new ItemStack(Material.ROTTEN_FLESH), 80, 1, 4))
.entry(LootEntry.of(new ItemStack(Material.BONE), 30, 1, 2))
.entry(LootEntry.empty(20))
.build()
)
.pool(LootPool.builder("rare")
.rolls(1)
.condition(LootCondition.LootConditions.chance(0.025))
.entry(LootEntry.of(new ItemStack(Material.IRON_INGOT), 100))
.build()
)
.build();
}
@LootTableDef
public LootTable bossDrops() {
return LootTable.builder("boss_drops")
.pool(LootPool.builder("guaranteed")
.rolls(1)
.entry(LootEntry.of(
ItemBuilder.of(Material.NETHER_STAR).name("<light_purple>Boss Soul").build(),
100))
.build()
)
.pool(LootPool.builder("bonus")
.rolls(2, 4).bonusRollsPerLooting(1)
.entry(LootEntry.of(new ItemStack(Material.DIAMOND), 40, 1, 3))
.entry(LootEntry.dynamic(() -> enchantRandomly(new ItemStack(Material.IRON_SWORD)), 20))
.entry(LootEntry.empty(10))
.build()
)
.build();
}
}Rolling API:
// Roll for a player (auto-detects looting enchantment level)
lootManager.rollForPlayer("zombie_drops", player);
// Roll and drop items at a world location
lootManager.rollAndDrop("boss_drops", player, entity.getLocation());
// Give directly to player's inventory (overflow drops at feet)
lootManager.rollAndGive("daily_reward", player);
// Custom context with luck modifier
LootContext ctx = LootContext.builder()
.player(player).lootingLevel(3).luck(0.5).build();
List<ItemStack> items = lootManager.roll("boss_drops", ctx);Built-in conditions: chance(p), minLevel(n), hasPermission(node), hasFlag(flag), isDaytime(),
hasEnchantment(ench), lootingLevel(n). All support .and(), .or(), .negate().
// One-shot static effects
ParticleUtil.burst(location, Particle.EXPLOSION, 10);
ParticleUtil.ring(location, 2.0, 32, Particle.END_ROD);
ParticleUtil.dust(location, Color.fromRGB(255, 87, 51), 1.5f, 8);
ParticleUtil.sphere(location, 3.0, 60, Particle.END_ROD);
ParticleUtil.line(from, to, 20, Particle.FLAME);
// Typed effects with generics
ParticleEffect.dust(Color.AQUA, 1.5f).spawn(location);
ParticleEffect.dustTransition(Color.RED, Color.BLUE, 2.0f).spawn(location);
ParticleEffect.block(Material.DIAMOND_BLOCK).withCount(15).spawn(location);
ParticleEffect.of(Particle.HEART, 3).withOffset(0.3).spawn(player, location);
// Animated effects
ParticleAnimator helix = ParticleUtil.helix(plugin, location, Particle.END_ROD, 100L);
ParticleAnimator pulse = ParticleUtil.pulse(plugin, location, Particle.TOTEM_OF_UNDYING, 40L);
// Custom animator — full control
new ParticleAnimator(plugin)
.interval(1L).loop(true)
.step(ParticleAnimation.of(
ParticleEffect.dust(Color.AQUA, 1.5f),
() -> ParticleShape.star(center, 2.0, 1.0, 6)
))
.step(ParticleAnimation.of(
ParticleEffect.dust(Color.WHITE, 1.0f),
() -> ParticleShape.circle(center, 2.5, 24)
))
.onComplete(a -> player.sendMessage("Done!"))
.start();Available shapes: circle, disk, line, sphere (Fibonacci lattice), cylinder, helix, star, wave.
Soft-dependency — gracefully no-ops if PAPI is not installed. Add to plugin.yml:
softdepend:
- PlaceholderAPI// Option A — implement PlaceholderProvider on any @Component (auto-discovered)
@Component
public class EconomyPlaceholders implements PlaceholderProvider {
@Inject private EconomyService economy;
@Override
public void registerPlaceholders(@NotNull PlaceholderRegistry registry) {
registry
.register("balance", player ->
String.valueOf(economy.getBalance(player.getUniqueId())))
.registerGlobal("total_players", () ->
String.valueOf(Bukkit.getOnlinePlayers().size()))
.registerStatic("currency_symbol", "€")
.registerOnline("player_name", player -> player.getName());
}
}
// Option B — manual registration
CorePlugin.getInstance().getPlaceholderManager()
.getRegistry().register("my_key", player -> "hello!");
// Resolve PAPI placeholders in a string
String msg = placeholderManager.setPlaceholders(player, "Balance: %core_balance%");
// Resolve PAPI + parse MiniMessage in one call
Component comp = placeholderManager.parseWithPlaceholders(
player, "<gold>Balance: <white>%core_balance%");Built-in placeholders: %core_version%, %core_online%, %core_max_players%, %core_tps%, %core_uptime%,
%core_player_name%, %core_player_uuid%, %core_player_online%.
Registration styles: register() (player-aware), registerGlobal() (same for all), registerStatic() (fixed value),
registerOnline() (null-safe player).
Checks https://github.com/mzcydev/paper-core releases via the GitHub API. All network I/O is async.
// One-liner — async, logs to console automatically
new UpdateChecker(this).checkAsync();
// With callback (runs on main thread)
new UpdateChecker(this).checkAsync(result -> {
if (result.isUpdateAvailable()) {
log.warning("Update available: " + result.getLatestVersion());
log.warning("Download: " + result.getReleaseUrl());
// UpdateNotifier auto-notifies ops on join when registered
}
});
// Sync (blocks calling thread — use only off main thread)
UpdateResult result = new UpdateChecker(this).checkSync();Statuses: UPDATE_AVAILABLE, UP_TO_DATE, DEV_BUILD, FAILED. Full SemVer comparison: 1.2.3, 1.2.3-SNAPSHOT,
1.2.3-beta.1, 1.2.3-rc.1, leading v stripped automatically.
Reload configs and listeners without restarting the server. Triggered via /core reload.
// Annotate any @Component method — auto-discovered
@Component
public class ShopService {
@Inject private ShopConfig config;
@Reloadable(name = "Shop Service", order = 20)
public void reload() {
config.reload();
rebuildCache();
}
}
// Register a manual step
hotReloadManager.addStep("Economy Cache", () -> {
economyService.flushCache();
economyService.warmCache();
});
// Trigger programmatically
ReloadResult result = hotReloadManager.reload(sender);
switch (result.getStatus()) {
case SUCCESS -> sender.sendMessage("All " + result.totalSteps() + " steps succeeded.");
case PARTIAL -> result.getFailedSteps().forEach(s -> log.warn("Failed: " + s));
case ALREADY_RUNNING -> sender.sendMessage("A reload is already in progress.");
}Reload phases (in order):
- Unregister all managed Bukkit listeners
- Execute built-in steps (
Configs.reloadAll(), listener re-registration) - Execute registered
ReloadSteps - Execute
@Reloadablemethods sorted byorder - Re-register all listeners
- Re-inject all singleton fields (new config values propagate automatically)
/core debug → Full overlay in chat (color-coded)
/core debug paste → Upload report to pastes.dev, returns clickable URL
/core bindings → List all DI container bindings with scope + live status
/core gc → Request JVM GC + show freed memory delta
/core version → Core version, Paper version, Java version
/core reload → Trigger hot-reload
Built-in sections: JVM (heap, uptime, Java version, CPUs), Server (TPS×3 color-coded, players, ticks, worlds), * DI Container* (binding count, live singletons), Configs (files + existence), DataStores (per-store entry count), Inventories (open GUIs, registered types), Scoreboard, NPC, PlaceholderAPI.
Add custom entries via @Debug on any @Component:
@Component
@Debug
public class ShopDebugInfo {
@Inject private ShopService shopService;
@Debug(category = "Shop", label = "Active Listings")
public String listings() {
return String.valueOf(shopService.countActiveListings());
}
@Debug(category = "Shop", label = "Revenue Today")
public String revenue() {
return "<gold>" + shopService.getRevenue();
}
@Debug(category = "Shop", label = "Category Breakdown")
public Map<String, Integer> breakdown() {
return shopService.getCountByCategory(); // Map is formatted automatically
}
}
// Manual registration
core.getDebugOverlay().getRegistry()
.registerEntry("Custom", "Server Status", () -> myService.getStatus());The paste upload uses pastes.dev API — plain text, all MiniMessage tags stripped, metadata header with timestamp and
generator included.
SchedulerUtil.run(plugin, () -> player.sendMessage("Hello!"));
SchedulerUtil.runLater(plugin, () -> teleport(player), SchedulerUtil.seconds(3));
BukkitTask task = SchedulerUtil.repeat(plugin, this::tick, 0L, SchedulerUtil.seconds(5));
SchedulerUtil.runAsync(plugin, () -> heavyComputation());
SchedulerUtil.runLaterAsync(plugin, () -> dbWrite(), SchedulerUtil.seconds(1));
SchedulerUtil.supplyAsync(plugin, () -> database.findPlayer(uuid))
.thenAcceptAsync(data -> player.sendMessage("Balance: " + data.getBalance()),
SchedulerUtil.syncExecutor(plugin));
SchedulerUtil.cancel(task);
SchedulerUtil.seconds(5); // → 100 ticks
SchedulerUtil.minutes(1); // → 1200 ticksComponentUtil.parse("<red>Hello <bold>World");
ComponentUtil.parse("<prefix> Welcome, <player>!",
Map.of("prefix", "<dark_gray>[Core]", "player", player.getName()));
ComponentUtil.fromLegacy("&aHello &bWorld");
ComponentUtil.toLegacy(component);
ComponentUtil.toMiniMessage(component);
ComponentUtil.toPlain(component);
ComponentUtil.stripFormatting("<red><bold>Hello"); // → "Hello"
ComponentUtil.parseList(List.of("<red>Line 1", "<green>Line 2"));
ComponentUtil.empty();
ComponentUtil.newline();
ComponentUtil.plain("No formatting");ColorUtil.fromHex("#FF5733");
ColorUtil.fromHex("33FF57"); // # is optional
ColorUtil.fromHex("#00F"); // shorthand expands to #0000FF
ColorUtil.fromHexSafe("#invalid"); // returns null on failure
ColorUtil.fromRgb(255, 87, 51);
ColorUtil.toTextColor(bukkit);
ColorUtil.toBukkitColor(textColor);
ColorUtil.toHex(color); // → "#FF5733"
ColorUtil.lerp(Color.RED, Color.BLUE, 0.5f);
ColorUtil.gradient(Color.RED, Color.YELLOW, 10);
ColorUtil.gradientText("Hello World", "#FF0000", "#0000FF");
ColorUtil.rainbowText("Rainbow!", 0);TimeUtil.format(Duration.ofSeconds(3661)); // → "1h 1m 1s"
TimeUtil.format(Duration.ofSeconds(90)); // → "1m 30s"
TimeUtil.formatSeconds(45); // → "45s"
TimeUtil.formatUntil(Instant.now().plusSeconds(120)); // → "2m 0s"
TimeUtil.parse("1h30m"); // → Duration.ofMinutes(90)
TimeUtil.parse("2d12h"); // → Duration.ofHours(60)
TimeUtil.parseSafe("bad_input"); // → Duration.ZERO
TimeUtil.toTicks(Duration.ofSeconds(5)); // → 100
TimeUtil.fromTicks(200); // → 10 seconds// Presets — zero allocation, all static final
SoundUtil.play(player, SoundUtil.Presets.SUCCESS);
SoundUtil.play(player, SoundUtil.Presets.CLICK);
SoundUtil.play(player, SoundUtil.Presets.ERROR);
SoundUtil.playAll(players, SoundUtil.Presets.PING);
SoundUtil.broadcast(server, SoundUtil.Presets.LEVEL_UP);
SoundUtil.stop(player, Sound.MUSIC_GAME);
SoundUtil.stopAll(player);
// Custom effect
SoundEffect.of(Sound.ENTITY_DRAGON_DEATH, 0.5f, 1.2f).play(player);
SoundEffect.builder()
.sound(Sound.UI_BUTTON_CLICK).volume(0.6f).pitch(1.5f)
.category(SoundCategory.MASTER).build()
.playAt(location);
// Timed sequences
SoundSequence.create()
.then(SoundUtil.Presets.TICK, 0L)
.then(SoundUtil.Presets.TICK, 20L)
.then(SoundUtil.Presets.TICK_FINAL, 40L)
.then(SoundUtil.Presets.TELEPORT_OUT, 60L)
.play(plugin, player);Available presets: CLICK, CLICK_SOFT, CLICK_HIGH, SUCCESS, ERROR, WARNING, LEVEL_UP, COIN, PURCHASE,
OPEN, CLOSE, PAGE_PREV, PAGE_NEXT, PING, TICK, TICK_FINAL, TELEPORT_OUT, TELEPORT_IN, DEPOSIT,
WITHDRAW.
Preconditions.notNull(player, "Player must not be null");
Preconditions.notBlank(input, "Name must not be blank");
Preconditions.isTrue(balance >= 0, "Balance cannot be negative");
Preconditions.isFalse(banned, "Banned players cannot do this");
Preconditions.inRange(index, 0, 53, "Slot out of range");
Preconditions.notEmpty(homeList, "Home list must not be empty");| Annotation | Target | Purpose |
|---|---|---|
@Component |
Class | Register as DI-managed component |
@Singleton |
Class | Explicit singleton scope (default) |
@Prototype |
Class | New instance per injection point |
@Inject |
Field / Constructor / Method | Mark injection point |
@Named("id") |
Field / Parameter | Qualify injection by name |
@PostConstruct |
Method | Called after all fields injected |
@PreDestroy |
Method | Called before container destroys instance |
@Config(...) |
Class | Declare a config file binding |
@Command(...) |
Class | Register a command handler |
@SubCommand(...) |
Method | Register a sub-command on a BaseCommand |
@Cooldown(...) |
Method / Class | Apply cooldown to a command or sub-command |
@Listener |
Class | Auto-register as Bukkit event listener |
@DataStore(...) |
Class | Register a binary data store |
@InventoryGui(...) |
Class | Register a GUI inventory |
@Task(...) |
Method | Schedule a recurring or one-shot task |
@Reloadable(...) |
Method | Invoked during hot-reload |
@Debug(...) |
Method / Class | Expose info to /core debug |
@LootTableDef |
Method | Register a LootTable factory method |
CoreException (RuntimeException)
├── ModuleException — thrown during module load/enable
├── InjectionException — thrown during DI resolution or injection
├── ConfigException — thrown during config load/save
├── CommandException — thrown during command registration or dispatch
├── DataStoreException — thrown during store I/O
└── InventoryException — thrown during GUI build or open
All exceptions are unchecked and carry a descriptive message including the failing component name.
onEnable()
│
├─ [1] Container construction
│ Self-registers: Plugin, Server, Logger, Path
│ Constructs and binds all framework managers
│
├─ [2] ClassScanner → ScanResult
│ Scans JAR for all annotated types
│ AnnotationProcessor wires everything into Container
│
├─ [3] ConfigManager.initializeAll()
│ Wires each @Config with file path + adapter
│ Copies defaults from JAR if missing, calls load()
│
├─ [4] DataStoreManager.initializeAll()
│ Creates store directories, loads .dat files into cache
│
├─ [5] CommandManager.registerAll()
│ Registers each @Command into Paper's CommandMap
│ Wires CooldownManager into each BaseCommand instance
│
├─ [6] InventoryManager.initializeAll()
│ Registers @InventoryGui types by ID
│ Registers GuiListener for click/drag/close
│
├─ [7] PlaceholderManager.initialize()
│ Detects PAPI, registers expansion, discovers PlaceholderProviders
│
├─ [8] Bukkit listener registration
│ Calls registerEvents() for all @Listener components
│
├─ [9] LootManager.discoverAndRegister()
│ Finds and calls all @LootTableDef factory methods
│
├─ [10] TaskManager.discoverAndSchedule()
│ Finds and schedules all @Task methods
│
├─ [11] DebugOverlay.discoverFrom()
│ Finds all @Debug-annotated components
│
├─ [12] UpdateChecker.checkAsync()
│ Async GitHub API check, registers UpdateNotifier if update found
│
├─ [13] ModuleRegistry.loadAll()
│ Calls load() on all registered modules in order
│
└─ [14] ModuleRegistry.enableAll()
Calls enable() on all registered modules in order
onDisable()
├─ ModuleRegistry.disableAll() (reverse order)
├─ ConversationManager.shutdown()
├─ FormManager.shutdown()
├─ MenuManager.shutdown()
├─ ChatInputManager.shutdown()
├─ TaskManager.cancelAll()
├─ NpcManager.destroy()
├─ HologramManager.destroy()
├─ ScoreboardManager.destroyAll()
├─ BossBarManager.shutdown()
├─ ActionbarManager.shutdown()
├─ InventoryManager.closeAll()
├─ CommandManager.unregisterAll()
├─ PlaceholderManager.shutdown()
├─ DataStoreManager.flushAll()
├─ ConfigManager.saveAll()
└─ Container.destroy() (@PreDestroy + clear all bindings)
// MyPlugin.java
public final class MyPlugin extends JavaPlugin {
@Override
public void onEnable() {
final CorePlugin core = CorePlugin.getInstance();
core.getComponentRegistry().scanAndProcess("dev.mzcy.example");
core.getConfigManager().initializeAll(core.getScanResult());
core.getDataStoreManager().initializeAll(core.getScanResult());
core.getCommandManager().registerAll(core.getScanResult());
core.getInventoryManager().initializeAll(core.getScanResult());
}
}
// ExampleConfig.java
@Config("config")
public class ExampleConfig extends AbstractConfig {
public String welcomeMessage = "<green>Welcome, <player>!";
public int startBalance = 100;
}
// PlayerDataStore.java
@DataStore("players")
public class PlayerDataStore extends AbstractDataStore<UUID, PlayerData> {
public PlayerDataStore() { super(new BinaryDataSerializer<>()); }
@Override protected String keyToFileName(@NotNull UUID key) { return key.toString(); }
@Override protected UUID fileNameToKey(@NotNull String f) { return UUID.fromString(f); }
}
// PlayerService.java
@Component
public class PlayerService {
@Inject private PlayerDataStore store;
@Inject private ExampleConfig config;
public void onJoin(Player player) {
if (!store.contains(player.getUniqueId())) {
store.put(player.getUniqueId(),
new PlayerData(player.getName(), config.startBalance));
}
}
public int getBalance(UUID uuid) {
return store.get(uuid).map(PlayerData::getBalance).orElse(0);
}
@Reloadable(name = "Player Cache", order = 10)
public void reload() {
// Cache rebuild after hot-reload
}
}
// JoinListener.java
@Component
@Listener
public class JoinListener implements Listener {
@Inject private PlayerService playerService;
@Inject private ExampleConfig config;
@EventHandler
public void onJoin(PlayerJoinEvent event) {
final Player player = event.getPlayer();
playerService.onJoin(player);
player.sendMessage(ComponentUtil.parse(
config.welcomeMessage,
Map.of("player", player.getName())
));
}
}
// BalanceCommand.java
@Command(name = "balance", aliases = {"bal"},
permission = "example.balance", playerOnly = true)
public class BalanceCommand extends BaseCommand {
@Inject private PlayerService playerService;
@Override
protected void onCommand(@NotNull CommandContext ctx) {
final int balance = playerService.getBalance(ctx.playerOrThrow().getUniqueId());
ctx.send("<gold>Your balance: <white>" + balance + " coins");
}
@SubCommand("set")
@Cooldown(value = 5, unit = TimeUnit.SECONDS, bypassPermission = "example.admin")
public void onSet(CommandContext ctx) {
ctx.argInt(0).ifPresent(amount -> {
playerService.setBalance(ctx.playerOrThrow().getUniqueId(), amount);
ctx.sendSuccess("Balance set to <white>" + amount);
});
}
}MIT License — Copyright (c) 2026 mzcy_ and contributors.
Built with ❤ for the Paper ecosystem.