diff --git a/README.md b/README.md index 71edf87..f7504ee 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,10 @@ dependencies: ## Usage To use SingularityLib, your main class must extend `CorePlugin` instead of `JavaPlugin`. + +Copy-paste examples (CommandGroup, Paper conversations, Item Studio export) live in +[`docs/examples/`](docs/examples/README.md). The GitHub wiki is not published; those +markdown pages are the docs home. ```java public class Main extends CorePlugin { @Override diff --git a/docs/examples/README.md b/docs/examples/README.md new file mode 100644 index 0000000..beb9020 --- /dev/null +++ b/docs/examples/README.md @@ -0,0 +1,24 @@ +# Singularity examples + +Copy-pasteable consumer snippets for SingularityLib **2.0** (Paper 26.2+, JDK 25). +The GitHub wiki for this repo is not published (`Pinont/SingularityLib.wiki` 404s), +so these pages are the docs home. + +| Page | What it covers | +| --- | --- | +| [CommandGroup](command-group.md) | Root command + `SubCommand` dispatch, aliases, help, registration | +| [Conversations](conversations.md) | Paper `ConversationFactory` prompts (`withModality(false)`, type `cancel` to abort) | +| [Export snippets](export-snippets.md) | Item Studio / Entity Studio Java export → `ItemCreator` / `CustomItem` | + +In-game flows (Item Studio, World Creator prompts, click-to-copy) live in +[Singularity-DevTool](https://github.com/Pinont/Singularity-DevTool) on +`rework/v2` after [PR #1](https://github.com/Pinont/Singularity-DevTool/pull/1) +(`72ff53e`). See that repo’s +[`docs/examples/`](https://github.com/Pinont/Singularity-DevTool/tree/rework/v2/docs/examples) +for the menu clicks. + +**API notes these examples assume:** + +- Package `com.github.pinont.singularitylib` (Maven coordinates stay `io.github.pinont:singularitylib`). +- `new ItemCreator(CorePlugin.getInstance(), Material.…)` — the Plugin argument is required in 2.x. +- Consumer plugins extend `CorePlugin` and register components with `registerComponents(…)` or `@AutoRegister`. diff --git a/docs/examples/command-group.md b/docs/examples/command-group.md new file mode 100644 index 0000000..42ee5cb --- /dev/null +++ b/docs/examples/command-group.md @@ -0,0 +1,200 @@ +# CommandGroup + +`CommandGroup` is a `SimpleCommand` that owns a map of `SubCommand`s. With no +arguments it prints a gold/yellow help listing. With a first argument it +dispatches to the matching subcommand and passes **the remaining args**. + +Verified against +[`CommandGroup.java`](https://github.com/Pinont/SingularityLib/blob/main/src/main/java/com/github/pinont/singularitylib/api/command/CommandGroup.java) +and +[`SubCommand.java`](https://github.com/Pinont/SingularityLib/blob/main/src/main/java/com/github/pinont/singularitylib/api/command/SubCommand.java) +on **`main`**. Production usage: +[`DevToolCommand`](https://github.com/Pinont/Singularity-DevTool/blob/72ff53e941eb34636f12f54e769945abe3acebfe/src/main/java/com/github/pinont/devtool/commands/DevToolCommand.java) +on DevTool `rework/v2` (`72ff53e`). + +## Register a group + +`CommandGroup` is a `SimpleCommand`, so it registers the same way as any other +command. Prefer explicit registration from `onPluginStart()`: + +```java +package com.example.arena; + +import com.github.pinont.singularitylib.plugin.CorePlugin; + +public class ArenaPlugin extends CorePlugin { + + @Override + public void onPluginStart() { + registerComponents(new ArenaCommand()); + } + + @Override + public void onPluginStop() { + } +} +``` + +`getName()` may list aliases with colons. `CommandManager` splits on `:` and +registers each token. `"arena:ar"` becomes `/arena` and `/ar`. + +You can also mark the group `@AutoRegister` (no-arg constructor required) if +your build runs `singularitylib-processor`. DevTool itself uses +`registerComponents(new DevToolCommand())` instead of a scan. + +## Copy-paste: root + two subcommands + +```java +package com.example.arena; + +import com.github.pinont.singularitylib.api.command.CommandGroup; +import com.github.pinont.singularitylib.api.command.SubCommand; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +public class ArenaCommand extends CommandGroup { + + public ArenaCommand() { + registerSubcommand(new CreateSub()); + registerSubcommand(new DeleteSub()); + registerSubcommand(new ListSub()); + } + + @Override + public String getName() { + return "arena:ar"; + } +} + +final class CreateSub extends SubCommand { + + @Override + public String getName() { + return "create:c"; + } + + @Override + public String getDescription() { + return "Create an arena"; + } + + @Override + public String getPermission() { + return "arena.create"; + } + + @Override + public boolean isPlayerOnly() { + return true; + } + + @Override + public void execute(CommandSender sender, String[] args) { + Player player = (Player) sender; + if (args.length < 1) { + player.sendMessage(Component.text("Usage: /arena create ", NamedTextColor.YELLOW)); + return; + } + player.sendMessage(Component.text("Created arena " + args[0], NamedTextColor.GREEN)); + } +} + +final class DeleteSub extends SubCommand { + + @Override + public String getName() { + return "delete"; + } + + @Override + public String getDescription() { + return "Delete an arena"; + } + + @Override + public String getPermission() { + return "arena.delete"; + } + + @Override + public void execute(CommandSender sender, String[] args) { + if (args.length < 1) { + sender.sendMessage(Component.text("Usage: /arena delete ", NamedTextColor.YELLOW)); + return; + } + sender.sendMessage(Component.text("Deleted arena " + args[0], NamedTextColor.RED)); + } +} + +final class ListSub extends SubCommand { + + @Override + public String getName() { + return "list:ls"; + } + + @Override + public String getDescription() { + return "List arenas"; + } + + @Override + public void execute(CommandSender sender, String[] args) { + sender.sendMessage(Component.text("Arenas: (none yet)", NamedTextColor.GRAY)); + } +} +``` + +## Behaviour to expect + +| Input | Result | +| --- | --- | +| `/arena` | Auto help: `—— arena:ar help ——` then one yellow line per subcommand (`getName()` is printed as-is, including aliases) | +| `/arena create spawn` | `CreateSub.execute` with `args = ["spawn"]` (subcommand name stripped) | +| `/arena c spawn` | Same — `create:c` registers both `create` and `c` | +| `/arena nope` | Red `Unknown subcommand: nope. Use /arena:ar help` | +| Console `/arena create spawn` | Red `This subcommand is players-only.` (`isPlayerOnly()`) | +| No permission | Red `You do not have permission to use: /arena:ar create:c` | + +Empty `getPermission()` / `null` means no permission check. Empty +`getDescription()` omits the ` — …` suffix on the help line. + +The sender-based `execute(CommandSender, String[])` path is what tests should +call (see `CommandGroupTest` in this repo). Paper still enters through +`execute(CommandSourceStack, String[])`, which forwards to the sender overload. + +## Override the root (DevTool pattern) + +If no-args should **not** print help, override `execute(CommandSender, String[])` +and only call `super.execute` for known subcommands. DevTool does this so +`/devtool` opens a GUI and `/devtool itemstudio` still dispatches: + +```java +@Override +public void execute(CommandSender sender, String[] args) { + if (args.length == 0) { + sender.sendMessage(Component.text("Open the menu, or /arena help", NamedTextColor.YELLOW)); + return; + } + if (getSubcommandNames().contains(args[0].toLowerCase())) { + super.execute(sender, args); + return; + } + sender.sendMessage(Component.text("Unknown: " + args[0], NamedTextColor.RED)); +} +``` + +`getSubcommandNames()` returns every registered key (primary names **and** +aliases), in insertion order. + +## What CommandGroup does not do + +- It does not implement Brigadier/tab suggestions. `SimpleCommand` extends + Paper `BasicCommand`; add `suggest(CommandSourceStack, String[])` yourself if + you want subcommand completion. +- `paper-plugin.yml` has no `commands:` block in the bootstrap model. Registration + is programmatic via `CommandManager` / `LifecycleEvents.COMMANDS`. +- Subcommand classes are **not** `SimpleCommand`s. Do not `@AutoRegister` a + `SubCommand` by itself — register it on a `CommandGroup`. diff --git a/docs/examples/conversations.md b/docs/examples/conversations.md new file mode 100644 index 0000000..1782143 --- /dev/null +++ b/docs/examples/conversations.md @@ -0,0 +1,144 @@ +# Conversations + +Do not steal chat with a global `AsyncChatEvent` / `ChatEvent` listener. +DevTool replaced that hack with Paper `ConversationFactory` after +[PR #1](https://github.com/Pinont/Singularity-DevTool/pull/1) merged into +`rework/v2` at +[`72ff53e`](https://github.com/Pinont/Singularity-DevTool/commit/72ff53e941eb34636f12f54e769945abe3acebfe). + +Read the merged implementation: + +- [`StartConversation`](https://github.com/Pinont/Singularity-DevTool/blob/72ff53e941eb34636f12f54e769945abe3acebfe/src/main/java/com/github/pinont/devtool/methods/StartConversation.java) + — per-player prompt, `withModality(false)`, escape `cancel` +- [`PromptWorldInput`](https://github.com/Pinont/Singularity-DevTool/blob/72ff53e941eb34636f12f54e769945abe3acebfe/src/main/java/com/github/pinont/devtool/methods/PromptWorldInput.java) + — world name / border (positive int) / seed (long) +- [`ConfigEditorMenu`](https://github.com/Pinont/Singularity-DevTool/blob/72ff53e941eb34636f12f54e769945abe3acebfe/src/main/java/com/github/pinont/devtool/menu/submenu/ConfigEditorMenu.java) + — string config keys use the same `StartConversation.ask(…)` + +In-game clicks: DevTool +[`docs/examples/in-game.md`](https://github.com/Pinont/Singularity-DevTool/blob/rework/v2/docs/examples/in-game.md). + +`ConversationFactory` is deprecated-for-removal on Paper 26.2 (Dialogs replace +it later). It is still the API DevTool and MockBukkit use on this target. + +## Copy-paste: minimal consumer prompt + +Drop this helper next to your `CorePlugin` subclass. It matches DevTool’s +contract: **not modal** (other chat is not captured unless this player is in +the prompt), **60s timeout**, type **`cancel`** (or time out) to abort. + +```java +package com.example.arena.prompt; + +import com.github.pinont.singularitylib.plugin.CorePlugin; +import org.bukkit.conversations.Conversation; +import org.bukkit.conversations.ConversationContext; +import org.bukkit.conversations.ConversationFactory; +import org.bukkit.conversations.Prompt; +import org.bukkit.conversations.StringPrompt; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; + +import java.util.function.Consumer; + +@SuppressWarnings({"deprecation", "removal"}) +public final class AskPlayer { + + public static final String ESCAPE = "cancel"; + + private AskPlayer() { + } + + /** + * Closes the current inventory and begins a modal-off chat prompt. + * Type {@code cancel} (or wait out the timeout) to abort. + */ + public static Conversation ask(Player player, String promptText, + Consumer onAnswer, Runnable onCancel) { + Plugin plugin = CorePlugin.getInstance(); + player.closeInventory(); + + ConversationFactory factory = new ConversationFactory(plugin) + .withModality(false) + .withLocalEcho(true) + .withTimeout(60) + .withEscapeSequence(ESCAPE) + .withPrefix(context -> "Arena » ") + .thatExcludesNonPlayersWithMessage("Players only.") + .withFirstPrompt(new StringPrompt() { + @Override + public String getPromptText(ConversationContext context) { + return promptText; + } + + @Override + public Prompt acceptInput(ConversationContext context, String input) { + if (input == null || input.isBlank()) { + return this; + } + onAnswer.accept(input.trim()); + return Prompt.END_OF_CONVERSATION; + } + }) + .addConversationAbandonedListener(event -> { + if (!event.gracefulExit() && onCancel != null) { + onCancel.run(); + } + }); + + Conversation conversation = factory.buildConversation(player); + conversation.begin(); + return conversation; + } +} +``` + +## World-name / border / seed (PromptWorldInput pattern) + +Same shape as DevTool’s World Creator. Close the GUI, prompt, parse, reopen: + +```java +AskPlayer.ask(player, + "Please send a world name into chat (or type cancel).", + name -> player.sendMessage("World name: " + name), + () -> player.sendMessage("Cancelled.")); + +AskPlayer.ask(player, + "Please send a world border size into chat (or type cancel).", + input -> { + try { + int parsed = Integer.parseInt(input); + if (parsed <= 0) { + player.sendMessage("World border size must be greater than 0"); + return; + } + player.sendMessage("Border: " + parsed); + } catch (NumberFormatException e) { + player.sendMessage("World border size must be a number."); + } + }, + () -> player.sendMessage("Cancelled.")); + +AskPlayer.ask(player, + "Please send a seed number into chat (or type cancel).", + input -> { + try { + long parsed = Long.parseLong(input); + player.sendMessage("Seed: " + parsed); + } catch (NumberFormatException e) { + player.sendMessage("World seed must be a number."); + } + }, + () -> player.sendMessage("Cancelled.")); +``` + +Blank input re-prompts (`return this`). `cancel` fires the abandoned listener +with `gracefulExit() == false`, which runs `onCancel`. A valid answer ends the +conversation (`Prompt.END_OF_CONVERSATION`) and does **not** run `onCancel`. + +## Why `withModality(false)` + +`withModality(true)` blocks all other chat and commands for that player until +the prompt ends. DevTool uses **`false`** so only the conversation’s own +messages are captured; everyone else (and this player, when not prompting) +keeps a normal chat pipeline. There is no plugin-wide chat listener. diff --git a/docs/examples/export-snippets.md b/docs/examples/export-snippets.md new file mode 100644 index 0000000..d713bb7 --- /dev/null +++ b/docs/examples/export-snippets.md @@ -0,0 +1,170 @@ +# Export snippets (ItemCreator / CustomItem) + +DevTool Item Studio and Entity Studio emit ready-to-paste Java that reconstructs +what you built with SingularityLib APIs. Merged on DevTool `rework/v2` at +[`72ff53e`](https://github.com/Pinont/Singularity-DevTool/commit/72ff53e941eb34636f12f54e769945abe3acebfe) +([PR #1](https://github.com/Pinont/Singularity-DevTool/pull/1)). + +Generators: + +- [`ItemSnippet`](https://github.com/Pinont/Singularity-DevTool/blob/72ff53e941eb34636f12f54e769945abe3acebfe/src/main/java/com/github/pinont/devtool/methods/ItemSnippet.java) +- [`EntitySnippet`](https://github.com/Pinont/Singularity-DevTool/blob/72ff53e941eb34636f12f54e769945abe3acebfe/src/main/java/com/github/pinont/devtool/methods/EntitySnippet.java) +- Delivery: [`ExportSnippet`](https://github.com/Pinont/Singularity-DevTool/blob/72ff53e941eb34636f12f54e769945abe3acebfe/src/main/java/com/github/pinont/devtool/methods/ExportSnippet.java) + — Adventure `ClickEvent.copyToClipboard`, chat preview, written book + +In-game clicks: +[`docs/examples/in-game.md`](https://github.com/Pinont/Singularity-DevTool/blob/rework/v2/docs/examples/in-game.md) +on DevTool. + +**2.x constructor:** always +`new ItemCreator(CorePlugin.getInstance(), Material.…)` (or +`(CorePlugin.getInstance(), Material.…, amount)` / `(CorePlugin.getInstance(), itemStack)`). +There is no Plugin-less `ItemCreator(Material)` anymore. + +## What a generated ItemCreator snippet looks like + +Picking **diamond sword** in Item Studio → **Export ItemCreator** produces +exactly this (from `ItemSnippet.studioItemCreator(Material.DIAMOND_SWORD)`): + +```java +import com.github.pinont.singularitylib.api.enums.AttributeType; +import com.github.pinont.singularitylib.api.items.Attributes; +import com.github.pinont.singularitylib.api.items.ItemCreator; +import com.github.pinont.singularitylib.plugin.CorePlugin; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.attribute.AttributeModifier; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.inventory.ItemStack; + +ItemStack item = new ItemCreator(CorePlugin.getInstance(), Material.DIAMOND_SWORD) + .setName(Component.text("Studio: Diamond sword", NamedTextColor.LIGHT_PURPLE)) + .addLore(Component.text("Made in the DevTool Item Studio", NamedTextColor.GRAY)) + .create(); +item = Attributes.setAttribute(item, AttributeType.ATTACK_DAMAGE, 5.0, + AttributeModifier.Operation.ADD_NUMBER); +``` + +Paste inside a player-scoped method (or any place that already has a running +`CorePlugin`). Then give it: + +```java +player.getInventory().addItem(item); +``` + +`Attributes.setAttribute` returns a **new** `ItemStack` (original untouched). +Keep the reassignment. + +The extra `NamespacedKey` / `Enchantment` imports are always emitted by the +studio generator so held-item reverse-engineering (`ItemSnippet.fromItem`) can +add `.addEnchant(…)` lines without a second import pass. Unused imports are +safe to delete. + +## What a generated CustomItem snippet looks like + +**Export CustomItem** for a golden apple +(`ItemSnippet.studioCustomItem(Material.GOLDEN_APPLE)`): + +```java +import com.github.pinont.singularitylib.api.enums.AttributeType; +import com.github.pinont.singularitylib.api.items.Attributes; +import com.github.pinont.singularitylib.api.items.ItemCreator; +import com.github.pinont.singularitylib.api.items.CustomItem; +import com.github.pinont.singularitylib.api.items.ItemInteraction; +import com.github.pinont.singularitylib.plugin.CorePlugin; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.attribute.AttributeModifier; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.inventory.ItemStack; + +public class StudioGoldenApple extends CustomItem { + + @Override + public ItemCreator register() { + return new ItemCreator(CorePlugin.getInstance(), Material.GOLDEN_APPLE) + .setName(Component.text("Studio: Golden apple", NamedTextColor.LIGHT_PURPLE)) + .addLore(Component.text("Made in the DevTool Item Studio", NamedTextColor.GRAY)); + } + + @Override + public ItemInteraction getInteraction() { + return null; + } +} +``` + +`register()` returns the **builder**, not `create()`. `CustomItem.getItem()` +calls `register().addInteraction(getInteraction()).create()`. A `null` +interaction is skipped (`ItemCreator.addInteraction(null)` is a no-op). + +### Wire it into your plugin + +```java +@Override +public void onPluginStart() { + registerComponents(new StudioGoldenApple()); +} +``` + +Give a stack: + +```java +player.getInventory().addItem(new StudioGoldenApple().getItem()); +``` + +To make it do something, replace `return null` with an `ItemInteraction` +(right/left click set, `execute(Player)` body). Keep the class on a no-arg +constructor if you also use `@AutoRegister`. + +## Held-item export (`/devtool snippet`) + +`ItemSnippet.fromItem(held)` reverse-engineers amount, display name, lore, +unbreakable, and enchantments. Example for 4 stone: + +```java +ItemStack item = new ItemCreator(CorePlugin.getInstance(), Material.STONE, 4) + .create(); +``` + +Air / empty hand exports `// No item to export.` and does not throw. + +## Entity Studio spawn snippet + +`/devtool entitystudio` → pick a type → **Export spawn snippet**. For a zombie +(`EntitySnippet.spawn(EntityType.ZOMBIE)`): + +```java +import com.github.pinont.singularitylib.plugin.CorePlugin; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.entity.Entity; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Player; + +// Paste inside a player-scoped method (Player player = ...). +Entity entity = player.getWorld().spawnEntity(player.getLocation(), EntityType.ZOMBIE); +entity.customName(Component.text("Studio: Zombie", NamedTextColor.AQUA)); +entity.setCustomNameVisible(true); +// CorePlugin.getInstance() is available if you need to schedule a follow-up: +// CorePlugin.getInstance().getServer(); +``` + +## Delivery (clipboard + book) + +`ExportSnippet.toPlayer` always: + +1. Closes the inventory. +2. Sends `[Click to copy snippet]` with `ClickEvent.copyToClipboard` (first + 24000 chars if the payload is huge). +3. Prints the first 12 lines in chat. +4. Gives a written book (`author: DevTool`) paginated at 240 chars. Full + inventory drops the book at your feet. + +You do not need this helper in a consumer plugin unless you are building your +own studio. Paste the Java from the book / clipboard into a `CorePlugin` +project and compile.