fix(settings): use Purpur dialog API
Release / release (push) Successful in 5m29s
CI / build (push) Successful in 1m28s

This commit is contained in:
dmg
2026-08-24 11:25:01 -04:00
parent b6bbf9183a
commit 29413d36b3
14 changed files with 366 additions and 243 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: temurin distribution: temurin
java-version: 17 java-version: 25
cache: gradle cache: gradle
- name: Validate conventional commits - name: Validate conventional commits
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: temurin distribution: temurin
java-version: 17 java-version: 25
cache: gradle cache: gradle
- name: Set up Node.js - name: Set up Node.js
+3 -3
View File
@@ -1,6 +1,6 @@
# Spigot Base # Spigot Base
A Spigot 26.2 plugin providing progression-gated player bases and quality-of-life unlocks. A Purpur 26.2 plugin providing progression-gated player bases and quality-of-life unlocks.
Players begin by breaking Survival-mode grass blocks or dirt. The progression paths provide a persistent base, navigation particles, expanding base bounds, controlled flight, personal teleportation, and visitor teleportation. Players begin by breaking Survival-mode grass blocks or dirt. The progression paths provide a persistent base, navigation particles, expanding base bounds, controlled flight, personal teleportation, and visitor teleportation.
@@ -8,8 +8,8 @@ The approved behavior is specified in the [OKF design bundle](design/index.md).
## Requirements ## Requirements
- Spigot 26.2 - Purpur 26.2 build 2618
- Java 17 or newer - Java 25 or newer
## Build ## Build
+5 -5
View File
@@ -9,23 +9,23 @@ version = providers.gradleProperty("releaseVersion")
repositories { repositories {
mavenCentral() mavenCentral()
maven("https://hub.spigotmc.org/nexus/content/repositories/snapshots/") maven("https://repo.purpurmc.org/snapshots/")
} }
java { java {
toolchain { toolchain {
languageVersion = JavaLanguageVersion.of(17) languageVersion = JavaLanguageVersion.of(25)
} }
} }
tasks.withType<JavaCompile>().configureEach { tasks.withType<JavaCompile>().configureEach {
options.compilerArgs.addAll(listOf("-Xlint:all", "-Werror")) options.compilerArgs.addAll(listOf("-Xlint:all,-deprecation,-removal", "-Werror"))
} }
dependencies { dependencies {
compileOnly("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT") compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
testImplementation("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT") testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.mockito:mockito-core:5.18.0") testImplementation("org.mockito:mockito-core:5.18.0")
+7
View File
@@ -122,3 +122,10 @@ description: Chronological record of material decisions affecting the Spigot Bas
- Retained the detailed chat report under `/basesettings status` and kept dialog actions routed through the existing validated command behavior. - Retained the detailed chat report under `/basesettings status` and kept dialog actions routed through the existing validated command behavior.
- Added dialog routing, autocomplete, alias, non-player, generated-control, locked-state, and purchase-confirmation coverage. - Added dialog routing, autocomplete, alias, non-player, generated-control, locked-state, and purchase-confirmation coverage.
- Verified the feature with `./gradlew clean check jar`. - Verified the feature with `./gradlew clean check jar`.
## 2026-08-24 — Purpur dialog runtime compatibility
- Replaced the Spigot-only Bungee dialog types that were absent from production with Purpur's supported Paper dialog API.
- Compiled and tested against the exact Purpur 26.2 build 2618 API with Java 25, and updated both CI workflows to use Java 25.
- Preserved unit-test coverage through server-independent dialog specifications and adapted Bukkit test fixtures to Purpur's registry-aware API behavior.
- Verified 128 tests and the Java 25 plugin artifact with `./gradlew clean check jar`.
@@ -1,7 +1,7 @@
--- ---
type: User Story type: User Story
title: "US-012: Build and release the plugin" title: "US-012: Build and release the plugin"
description: Give maintainers repeatable Spigot builds, automated verification, and versioned Gitea releases. description: Give maintainers repeatable Purpur builds, automated verification, and versioned Gitea releases.
status: done status: done
--- ---
@@ -11,7 +11,7 @@ As a **plugin maintainer**, I want automated builds and releases modeled on Trig
## Acceptance criteria ## Acceptance criteria
- [x] The Gradle project compiles against Spigot API `26.2-R0.1-SNAPSHOT` using a Java 17 toolchain. - [x] The Gradle project compiles against Purpur API `26.2.build.2618-stable` using a Java 25 toolchain.
- [x] Compiler lint warnings fail the build. - [x] Compiler lint warnings fail the build.
- [x] Automated JUnit 5 tests run as part of the Gradle check lifecycle. - [x] Automated JUnit 5 tests run as part of the Gradle check lifecycle.
- [x] Pushes and pull requests build and test the plugin in Gitea Actions. - [x] Pushes and pull requests build and test the plugin in Gitea Actions.
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-020: Control a base through a dialog UI" title: "US-020: Control a base through a dialog UI"
description: Let players inspect and control their base through Minecraft's native dialog interface. description: Let players inspect and control their base through Minecraft's native dialog interface.
status: done status: in-progress
--- ---
# US-020: Control a base through a dialog UI # US-020: Control a base through a dialog UI
@@ -21,6 +21,7 @@ As a **player**, I want a dialog-based base dashboard so that I can inspect prog
- [x] `ui` is offered through command autocomplete. - [x] `ui` is offered through command autocomplete.
- [x] Non-player execution remains safely rejected. - [x] Non-player execution remains safely rejected.
- [x] Automated tests verify dialog routing and generated controls. - [x] Automated tests verify dialog routing and generated controls.
- [ ] The dialog uses Purpur's supported Paper dialog API and opens on the production Purpur 26.2 runtime without missing-class or command exceptions.
## Related ## Related
+4
View File
@@ -1 +1,5 @@
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
rootProject.name = "spigot-base" rootProject.name = "spigot-base"
@@ -1,5 +1,6 @@
package games.dmg.spigotbase; package games.dmg.spigotbase;
import io.papermc.paper.dialog.Dialog;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@@ -28,6 +29,7 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
private final TeleportPolicy teleportPolicy; private final TeleportPolicy teleportPolicy;
private final BaseFlightController flightController; private final BaseFlightController flightController;
private final PocketBaseManager pocketBases; private final PocketBaseManager pocketBases;
private final DialogProvider dialogProvider;
BaseSettingsCommand( BaseSettingsCommand(
BaseStateManager stateManager, BaseStateManager stateManager,
@@ -42,12 +44,30 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
PluginSettingsProvider settings, PluginSettingsProvider settings,
BaseFlightController flightController, BaseFlightController flightController,
PocketBaseManager pocketBases PocketBaseManager pocketBases
) {
this(
stateManager,
settings,
flightController,
pocketBases,
(owner, pocket) -> new BaseSettingsDialogFactory(settings.current())
.create(owner, pocket)
);
}
BaseSettingsCommand(
BaseStateManager stateManager,
PluginSettingsProvider settings,
BaseFlightController flightController,
PocketBaseManager pocketBases,
DialogProvider dialogProvider
) { ) {
this.stateManager = stateManager; this.stateManager = stateManager;
this.settings = settings; this.settings = settings;
this.teleportPolicy = new TeleportPolicy(settings); this.teleportPolicy = new TeleportPolicy(settings);
this.flightController = flightController; this.flightController = flightController;
this.pocketBases = pocketBases; this.pocketBases = pocketBases;
this.dialogProvider = dialogProvider;
} }
@Override @Override
@@ -449,7 +469,7 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
PocketBaseState pocket = pocketBases == null PocketBaseState pocket = pocketBases == null
? PocketBaseState.locked(state.playerId()) ? PocketBaseState.locked(state.playerId())
: pocketBases.state(state.playerId()); : pocketBases.state(state.playerId());
player.showDialog(new BaseSettingsDialogFactory(settings.current()).create(state, pocket)); player.showDialog(dialogProvider.create(state, pocket));
} }
private void showStatus(Player player, PlayerState state) { private void showStatus(Player player, PlayerState state) {
@@ -672,6 +692,11 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
return met ? ChatColor.YELLOW : ChatColor.RED; return met ? ChatColor.YELLOW : ChatColor.RED;
} }
@FunctionalInterface
interface DialogProvider {
Dialog create(PlayerState owner, PocketBaseState pocket);
}
private static void sendUsage(Player player) { private static void sendUsage(Player player) {
player.sendMessage(ChatColor.RED + "Usage: /basesettings " player.sendMessage(ChatColor.RED + "Usage: /basesettings "
+ "[ui|status|upgrade|pocket upgrade|pocket mobs " + "[ui|status|upgrade|pocket upgrade|pocket mobs "
@@ -1,19 +1,17 @@
package games.dmg.spigotbase; package games.dmg.spigotbase;
import io.papermc.paper.dialog.Dialog;
import io.papermc.paper.registry.RegistryKey;
import io.papermc.paper.registry.data.dialog.ActionButton;
import io.papermc.paper.registry.data.dialog.DialogBase;
import io.papermc.paper.registry.data.dialog.action.DialogAction;
import io.papermc.paper.registry.data.dialog.body.DialogBody;
import io.papermc.paper.registry.data.dialog.type.DialogType;
import io.papermc.paper.registry.set.RegistrySet;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import net.md_5.bungee.api.chat.BaseComponent; import net.kyori.adventure.text.Component;
import net.md_5.bungee.api.chat.TextComponent;
import net.md_5.bungee.api.dialog.ConfirmationDialog;
import net.md_5.bungee.api.dialog.Dialog;
import net.md_5.bungee.api.dialog.DialogBase;
import net.md_5.bungee.api.dialog.DialogListDialog;
import net.md_5.bungee.api.dialog.MultiActionDialog;
import net.md_5.bungee.api.dialog.NoticeDialog;
import net.md_5.bungee.api.dialog.action.ActionButton;
import net.md_5.bungee.api.dialog.action.RunCommandAction;
import net.md_5.bungee.api.dialog.body.PlainMessageBody;
final class BaseSettingsDialogFactory { final class BaseSettingsDialogFactory {
private static final int DIALOG_WIDTH = 420; private static final int DIALOG_WIDTH = 420;
@@ -24,8 +22,18 @@ final class BaseSettingsDialogFactory {
} }
Dialog create(PlayerState owner, PocketBaseState pocket) { Dialog create(PlayerState owner, PocketBaseState pocket) {
return new DialogListDialog( return render(specification(owner, pocket));
base( }
DialogSpec specification(PlayerState owner, PocketBaseState pocket) {
List<DialogSpec> dialogs = List.of(
generalSettings(owner),
progressReport(),
baseUpgrade(owner),
pocketSettings(owner, pocket)
);
return new ListSpec(
content(
"Base Settings", "Base Settings",
"Base Settings", "Base Settings",
"Base " + owner.baseLevel() + "/4 • Size " + owner.sizeLevel() + "/3" "Base " + owner.baseLevel() + "/4 • Size " + owner.sizeLevel() + "/3"
@@ -33,95 +41,91 @@ final class BaseSettingsDialogFactory {
+ "Pocket Base " + pocket.level() + "" + "Pocket Base " + pocket.level() + ""
+ humanize(pocket.biome().commandName()) + humanize(pocket.biome().commandName())
), ),
List.of( dialogs,
generalSettings(owner),
progressReport(),
baseUpgrade(owner),
pocketSettings(owner, pocket)
),
null,
2, 2,
190 190
); );
} }
private Dialog generalSettings(PlayerState owner) { private DialogSpec generalSettings(PlayerState owner) {
return new MultiActionDialog( List<ButtonSpec> actions = List.of(
base( toggle(
"Visitors",
owner.baseLevel() >= 4,
owner.visitorsEnabled(),
"Allowed",
"Blocked",
"basesettings visitors "
+ (owner.visitorsEnabled() ? "blocked" : "allowed")
),
toggle(
"Navigation",
owner.baseLevel() >= 2,
owner.navigationEnabled(),
"Enabled",
"Disabled",
"basesettings navigation " + mode(!owner.navigationEnabled())
),
toggle(
"Flight",
owner.flightLevel() >= 1,
owner.flightEnabled(),
"Enabled",
"Disabled",
"basesettings flight " + mode(!owner.flightEnabled())
),
toggle(
"Border",
owner.baseLevel() >= 1 && owner.base().isPresent(),
owner.borderEnabled(),
"Enabled",
"Disabled",
"basesettings border " + mode(!owner.borderEnabled())
),
toggle(
"Spawnable Overlay",
owner.baseLevel() >= 1
&& owner.base().isPresent()
&& owner.totalBlocksPlaced()
>= settings.spawnableOverlayUnlockPlacements(),
owner.spawnableOverlayEnabled(),
"Enabled",
"Disabled",
"basesettings spawnable " + mode(!owner.spawnableOverlayEnabled())
),
toggle(
"Progress Boss Bar",
true,
owner.bossBarEnabled(),
"Enabled",
"Disabled",
"basesettings bossbar " + mode(!owner.bossBarEnabled())
)
);
return new MultiSpec(
content(
"Base Controls", "Base Controls",
"Base Controls", "Base Controls",
"Select a setting to change it. Locked controls remain visible and explain " "Select a setting to change it. Locked controls remain visible and explain "
+ "their requirement in chat when selected." + "their requirement in chat when selected."
), ),
List.of( actions,
toggle( 2
"Visitors",
owner.baseLevel() >= 4,
owner.visitorsEnabled(),
"Allowed",
"Blocked",
"basesettings visitors "
+ (owner.visitorsEnabled() ? "blocked" : "allowed")
),
toggle(
"Navigation",
owner.baseLevel() >= 2,
owner.navigationEnabled(),
"Enabled",
"Disabled",
"basesettings navigation "
+ mode(!owner.navigationEnabled())
),
toggle(
"Flight",
owner.flightLevel() >= 1,
owner.flightEnabled(),
"Enabled",
"Disabled",
"basesettings flight " + mode(!owner.flightEnabled())
),
toggle(
"Border",
owner.baseLevel() >= 1 && owner.base().isPresent(),
owner.borderEnabled(),
"Enabled",
"Disabled",
"basesettings border " + mode(!owner.borderEnabled())
),
toggle(
"Spawnable Overlay",
owner.baseLevel() >= 1
&& owner.base().isPresent()
&& owner.totalBlocksPlaced()
>= settings.spawnableOverlayUnlockPlacements(),
owner.spawnableOverlayEnabled(),
"Enabled",
"Disabled",
"basesettings spawnable " + mode(!owner.spawnableOverlayEnabled())
),
toggle(
"Progress Boss Bar",
true,
owner.bossBarEnabled(),
"Enabled",
"Disabled",
"basesettings bossbar " + mode(!owner.bossBarEnabled())
)
),
2,
null
); );
} }
private Dialog progressReport() { private DialogSpec progressReport() {
return new NoticeDialog(base( return new NoticeSpec(
"Progress Report", content(
"Progress Report", "Progress Report",
"Display the detailed progression report in chat." "Progress Report",
)).action(commandButton("Show in Chat", "basesettings status")); "Display the detailed progression report in chat."
),
commandButton("Show in Chat", "basesettings status")
);
} }
private Dialog baseUpgrade(PlayerState owner) { private DialogSpec baseUpgrade(PlayerState owner) {
if (owner.baseLevel() >= 4) { if (owner.baseLevel() >= 4) {
return notice("Base IV", "Base IV and visitor access are already unlocked."); return notice("Base IV", "Base IV and visitor access are already unlocked.");
} }
@@ -131,17 +135,22 @@ final class BaseSettingsDialogFactory {
"Unlock and establish Base III before purchasing Base IV visitor access." "Unlock and establish Base III before purchasing Base IV visitor access."
); );
} }
int price = settings.visitorUnlockDiamondCost();
return confirmation( return confirmation(
"Unlock Base IV", "Unlock Base IV",
"Purchase visitor access for " + price + " diamonds?", "Purchase visitor access for " + settings.visitorUnlockDiamondCost()
+ " diamonds?",
"basesettings upgrade" "basesettings upgrade"
); );
} }
private Dialog pocketSettings(PlayerState owner, PocketBaseState pocket) { private DialogSpec pocketSettings(PlayerState owner, PocketBaseState pocket) {
return new DialogListDialog( List<DialogSpec> dialogs = List.of(
base( pocketUpgrade(owner, pocket),
pocketMobSettings(pocket),
pocketBiomeSettings(pocket)
);
return new ListSpec(
content(
"Pocket Base", "Pocket Base",
"Pocket Base " + pocket.level(), "Pocket Base " + pocket.level(),
pocket.level() == 0 pocket.level() == 0
@@ -151,18 +160,13 @@ final class BaseSettingsDialogFactory {
+ enabled(pocket.hostileMobSpawningEnabled()) + " • Passive mobs: " + enabled(pocket.hostileMobSpawningEnabled()) + " • Passive mobs: "
+ enabled(pocket.passiveMobSpawningEnabled()) + enabled(pocket.passiveMobSpawningEnabled())
), ),
List.of( dialogs,
pocketUpgrade(owner, pocket),
pocketMobSettings(pocket),
pocketBiomeSettings(pocket)
),
null,
1, 1,
260 260
); );
} }
private Dialog pocketUpgrade(PlayerState owner, PocketBaseState pocket) { private DialogSpec pocketUpgrade(PlayerState owner, PocketBaseState pocket) {
if (owner.baseLevel() < 4 || owner.base().isEmpty()) { if (owner.baseLevel() < 4 || owner.base().isEmpty()) {
return notice( return notice(
"Pocket Upgrade — Locked", "Pocket Upgrade — Locked",
@@ -183,57 +187,56 @@ final class BaseSettingsDialogFactory {
); );
} }
private Dialog pocketMobSettings(PocketBaseState pocket) { private DialogSpec pocketMobSettings(PocketBaseState pocket) {
if (pocket.level() < 1) { if (pocket.level() < 1) {
return notice( return notice(
"Mob Spawning — Locked", "Mob Spawning — Locked",
"Unlock Pocket Base I to control natural mob spawning." "Unlock Pocket Base I to control natural mob spawning."
); );
} }
return new MultiActionDialog( List<ButtonSpec> actions = List.of(
base( toggle(
"Hostile Mobs",
true,
pocket.hostileMobSpawningEnabled(),
"Enabled",
"Disabled",
"basesettings pocket mobs hostile "
+ mode(!pocket.hostileMobSpawningEnabled())
),
toggle(
"Passive Mobs",
true,
pocket.passiveMobSpawningEnabled(),
"Enabled",
"Disabled",
"basesettings pocket mobs passive "
+ mode(!pocket.passiveMobSpawningEnabled())
)
);
return new MultiSpec(
content(
"Mob Spawning", "Mob Spawning",
"Mob Spawning", "Mob Spawning",
"Control natural spawning independently for each mob category." "Control natural spawning independently for each mob category."
), ),
List.of( actions,
toggle( 1
"Hostile Mobs",
true,
pocket.hostileMobSpawningEnabled(),
"Enabled",
"Disabled",
"basesettings pocket mobs hostile "
+ mode(!pocket.hostileMobSpawningEnabled())
),
toggle(
"Passive Mobs",
true,
pocket.passiveMobSpawningEnabled(),
"Enabled",
"Disabled",
"basesettings pocket mobs passive "
+ mode(!pocket.passiveMobSpawningEnabled())
)
),
1,
null
); );
} }
private Dialog pocketBiomeSettings(PocketBaseState pocket) { private DialogSpec pocketBiomeSettings(PocketBaseState pocket) {
if (pocket.level() < 1) { if (pocket.level() < 1) {
return notice( return notice(
"Change Biome — Locked", "Change Biome — Locked",
"Unlock Pocket Base I to purchase biome changes." "Unlock Pocket Base I to purchase biome changes."
); );
} }
List<Dialog> worldTypes = Arrays.stream(PocketBaseWorldType.values()) List<DialogSpec> worldTypes = Arrays.stream(PocketBaseWorldType.values())
.map(type -> pocketBiomeType(pocket, type)) .map(type -> pocketBiomeType(pocket, type))
.map(Dialog.class::cast)
.toList(); .toList();
return new DialogListDialog( return new ListSpec(
base( content(
"Change Biome", "Change Biome",
"Change Biome", "Change Biome",
"Current biome: " + pocket.biome().worldType().commandName() + "/" "Current biome: " + pocket.biome().worldType().commandName() + "/"
@@ -242,35 +245,32 @@ final class BaseSettingsDialogFactory {
+ humanize(settings.pocketBaseBiomeCurrencyMaterial()) + "." + humanize(settings.pocketBaseBiomeCurrencyMaterial()) + "."
), ),
worldTypes, worldTypes,
null,
1, 1,
220 220
); );
} }
private DialogListDialog pocketBiomeType( private DialogSpec pocketBiomeType(
PocketBaseState pocket, PocketBaseState pocket,
PocketBaseWorldType worldType PocketBaseWorldType worldType
) { ) {
List<Dialog> biomes = Arrays.stream(PocketBaseBiome.values()) List<DialogSpec> biomes = Arrays.stream(PocketBaseBiome.values())
.filter(biome -> biome.worldType() == worldType) .filter(biome -> biome.worldType() == worldType)
.map(biome -> biomeConfirmation(pocket, biome)) .map(biome -> biomeConfirmation(pocket, biome))
.map(Dialog.class::cast)
.toList(); .toList();
return new DialogListDialog( return new ListSpec(
base( content(
humanize(worldType.commandName()), humanize(worldType.commandName()),
humanize(worldType.commandName()) + " Biomes", humanize(worldType.commandName()) + " Biomes",
"Choose a biome subtype. The active biome is marked as selected." "Choose a biome subtype. The active biome is marked as selected."
), ),
biomes, biomes,
null,
2, 2,
190 190
); );
} }
private Dialog biomeConfirmation(PocketBaseState pocket, PocketBaseBiome biome) { private DialogSpec biomeConfirmation(PocketBaseState pocket, PocketBaseBiome biome) {
String title = humanize(biome.commandName()) String title = humanize(biome.commandName())
+ (pocket.biome() == biome ? " — Selected" : ""); + (pocket.biome() == biome ? " — Selected" : "");
if (pocket.biome() == biome) { if (pocket.biome() == biome) {
@@ -285,16 +285,18 @@ final class BaseSettingsDialogFactory {
); );
} }
private static Dialog confirmation(String title, String message, String command) { private static DialogSpec confirmation(String title, String message, String command) {
return new ConfirmationDialog(base(title, title, message)) return new ConfirmationSpec(
.yes(commandButton("Confirm", command)); content(title, title, message),
commandButton("Confirm", command)
);
} }
private static Dialog notice(String title, String message) { private static DialogSpec notice(String title, String message) {
return new NoticeDialog(base(title, title, message)); return new NoticeSpec(content(title, title, message), null);
} }
private static ActionButton toggle( private static ButtonSpec toggle(
String name, String name,
boolean unlocked, boolean unlocked,
boolean active, boolean active,
@@ -306,29 +308,106 @@ final class BaseSettingsDialogFactory {
String tooltip = unlocked String tooltip = unlocked
? "Click to change this setting." ? "Click to change this setting."
: "This setting has not been unlocked yet."; : "This setting has not been unlocked yet.";
return new ActionButton( return new ButtonSpec(name + ": " + state, tooltip, 190, "/" + command);
text(name + ": " + state),
text(tooltip),
190,
new RunCommandAction("/" + command)
);
} }
private static ActionButton commandButton(String label, String command) { private static ButtonSpec commandButton(String label, String command) {
return new ActionButton(text(label), new RunCommandAction("/" + command)); return new ButtonSpec(label, "", 150, "/" + command);
} }
private static DialogBase base(String title, String externalTitle, String message) { private static DialogContent content(
return new DialogBase(text(title)) String title,
.externalTitle(text(externalTitle)) String externalTitle,
.body(List.of(new PlainMessageBody(text(message), DIALOG_WIDTH))) String message
) {
return new DialogContent(title, externalTitle, message);
}
private static Dialog render(DialogSpec specification) {
DialogType type;
if (specification instanceof ListSpec list) {
List<Dialog> dialogs = list.dialogs().stream()
.map(BaseSettingsDialogFactory::render)
.toList();
type = DialogType.dialogList(
RegistrySet.valueSet(RegistryKey.DIALOG, dialogs),
null,
list.columns(),
list.buttonWidth()
);
} else if (specification instanceof MultiSpec multi) {
List<ActionButton> actions = multi.actions().stream()
.map(BaseSettingsDialogFactory::render)
.toList();
type = DialogType.multiAction(actions, null, multi.columns());
} else if (specification instanceof ConfirmationSpec confirmation) {
ActionButton cancel = ActionButton.builder(Component.text("Cancel")).build();
type = DialogType.confirmation(render(confirmation.confirm()), cancel);
} else if (specification instanceof NoticeSpec notice) {
type = notice.action() == null
? DialogType.notice()
: DialogType.notice(render(notice.action()));
} else {
throw new IllegalArgumentException("Unsupported dialog specification");
}
DialogContent content = specification.content();
DialogBase base = DialogBase.builder(Component.text(content.title()))
.externalTitle(Component.text(content.externalTitle()))
.body(List.of(DialogBody.plainMessage(
Component.text(content.message()), DIALOG_WIDTH
)))
.canCloseWithEscape(true) .canCloseWithEscape(true)
.pause(false) .pause(false)
.afterAction(DialogBase.AfterAction.CLOSE); .afterAction(DialogBase.DialogAfterAction.CLOSE)
.build();
return Dialog.create(factory -> factory.empty().base(base).type(type));
} }
private static BaseComponent text(String value) { private static ActionButton render(ButtonSpec button) {
return new TextComponent(value); ActionButton.Builder builder = ActionButton.builder(Component.text(button.label()))
.width(button.width())
.action(DialogAction.commandTemplate(button.command()));
if (!button.tooltip().isEmpty()) {
builder.tooltip(Component.text(button.tooltip()));
}
return builder.build();
}
sealed interface DialogSpec permits ListSpec, MultiSpec, ConfirmationSpec, NoticeSpec {
DialogContent content();
}
record DialogContent(String title, String externalTitle, String message) {
}
record ListSpec(
DialogContent content,
List<DialogSpec> dialogs,
int columns,
int buttonWidth
) implements DialogSpec {
}
record MultiSpec(
DialogContent content,
List<ButtonSpec> actions,
int columns
) implements DialogSpec {
}
record ConfirmationSpec(
DialogContent content,
ButtonSpec confirm
) implements DialogSpec {
}
record NoticeSpec(
DialogContent content,
ButtonSpec action
) implements DialogSpec {
}
record ButtonSpec(String label, String tooltip, int width, String command) {
} }
private static String mode(boolean active) { private static String mode(boolean active) {
@@ -137,7 +137,7 @@ final class PocketBaseWorldService {
World world = server.getWorld(name); World world = server.getWorld(name);
boolean created = world == null; boolean created = world == null;
if (created) { if (created) {
WorldCreator creator = new WorldCreator(name) WorldCreator creator = new WorldCreator(NamespacedKey.minecraft(name))
.environment(World.Environment.NORMAL) .environment(World.Environment.NORMAL)
.generator(new VoidPocketChunkGenerator()) .generator(new VoidPocketChunkGenerator())
.generateStructures(false); .generateStructures(false);
@@ -24,7 +24,7 @@ import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory; import org.bukkit.inventory.PlayerInventory;
import net.md_5.bungee.api.dialog.Dialog; import net.kyori.adventure.dialog.DialogLike;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
@@ -186,10 +186,12 @@ final class BaseSettingsCommandTest {
when(player.getUniqueId()).thenReturn(playerId); when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Host"); when(player.getName()).thenReturn("Host");
PlayerInventory inventory = mock(PlayerInventory.class); PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack firstStack = item(Material.DIAMOND, 64);
ItemStack secondStack = item(Material.DIAMOND, 64);
when(player.getInventory()).thenReturn(inventory); when(player.getInventory()).thenReturn(inventory);
when(inventory.getStorageContents()).thenReturn(new ItemStack[] { when(inventory.getStorageContents()).thenReturn(new ItemStack[] {
new ItemStack(Material.DIAMOND, 64), firstStack,
new ItemStack(Material.DIAMOND, 64) secondStack
}); });
PlayerState current = PlayerState.newPlayer(playerId, "Host") PlayerState current = PlayerState.newPlayer(playerId, "Host")
@@ -227,10 +229,9 @@ final class BaseSettingsCommandTest {
when(player.getUniqueId()).thenReturn(playerId); when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder"); when(player.getName()).thenReturn("Builder");
PlayerInventory inventory = mock(PlayerInventory.class); PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack currency = item(Material.DIAMOND_BLOCK, 64);
when(player.getInventory()).thenReturn(inventory); when(player.getInventory()).thenReturn(inventory);
when(inventory.getStorageContents()).thenReturn(new ItemStack[] { when(inventory.getStorageContents()).thenReturn(new ItemStack[] {currency});
new ItemStack(Material.DIAMOND_BLOCK, 64)
});
PlayerState owner = PlayerState.newPlayer(playerId, "Builder") PlayerState owner = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true) .withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true)
.withBase( .withBase(
@@ -264,10 +265,9 @@ final class BaseSettingsCommandTest {
when(player.getUniqueId()).thenReturn(playerId); when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder"); when(player.getName()).thenReturn("Builder");
PlayerInventory inventory = mock(PlayerInventory.class); PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack currency = item(Material.NETHERITE_BLOCK, 16);
when(player.getInventory()).thenReturn(inventory); when(player.getInventory()).thenReturn(inventory);
when(inventory.getStorageContents()).thenReturn(new ItemStack[] { when(inventory.getStorageContents()).thenReturn(new ItemStack[] {currency});
new ItemStack(Material.NETHERITE_BLOCK, 16)
});
BaseStateManager stateManager = mock(BaseStateManager.class); BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder")) when(stateManager.player(playerId, "Builder"))
.thenReturn(PlayerState.newPlayer(playerId, "Builder")); .thenReturn(PlayerState.newPlayer(playerId, "Builder"));
@@ -429,10 +429,9 @@ final class BaseSettingsCommandTest {
when(player.getUniqueId()).thenReturn(playerId); when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder"); when(player.getName()).thenReturn("Builder");
PlayerInventory inventory = mock(PlayerInventory.class); PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack currency = item(Material.DIAMOND_BLOCK, 64);
when(player.getInventory()).thenReturn(inventory); when(player.getInventory()).thenReturn(inventory);
when(inventory.getStorageContents()).thenReturn(new ItemStack[] { when(inventory.getStorageContents()).thenReturn(new ItemStack[] {currency});
new ItemStack(Material.DIAMOND_BLOCK, 64)
});
PlayerState owner = PlayerState.newPlayer(playerId, "Builder") PlayerState owner = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true) .withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true)
.withBase( .withBase(
@@ -554,14 +553,16 @@ final class BaseSettingsCommandTest {
BaseSettingsCommand command = new BaseSettingsCommand( BaseSettingsCommand command = new BaseSettingsCommand(
stateManager, stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())), new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class) mock(BaseFlightController.class),
null,
(owner, pocket) -> null
); );
command.onCommand(player, null, "basesettings", new String[0]); command.onCommand(player, null, "basesettings", new String[0]);
command.onCommand(player, null, "homesettings", new String[] {"ui"}); command.onCommand(player, null, "homesettings", new String[] {"ui"});
command.onCommand(player, null, "basesettings", new String[] {"status"}); command.onCommand(player, null, "basesettings", new String[] {"status"});
verify(player, times(2)).showDialog(any(Dialog.class)); verify(player, times(2)).showDialog((DialogLike) null);
verify(player).sendMessage(ChatColor.GOLD + "=== Base Progress ==="); verify(player).sendMessage(ChatColor.GOLD + "=== Base Progress ===");
verify(player).sendMessage( verify(player).sendMessage(
org.mockito.ArgumentMatchers.<String>argThat(message -> org.mockito.ArgumentMatchers.<String>argThat(message ->
@@ -582,6 +583,18 @@ final class BaseSettingsCommandTest {
); );
} }
private static ItemStack item(Material material, int amount) {
ItemStack item = mock(ItemStack.class);
ItemStack copy = mock(ItemStack.class);
when(item.getType()).thenReturn(material);
when(item.getAmount()).thenReturn(amount);
when(item.clone()).thenReturn(copy);
when(copy.getType()).thenReturn(material);
when(copy.getAmount()).thenReturn(amount);
when(copy.clone()).thenReturn(copy);
return item;
}
private static CommandResult execute(PlayerState current, String... arguments) { private static CommandResult execute(PlayerState current, String... arguments) {
Player player = mock(Player.class); Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(current.playerId()); when(player.getUniqueId()).thenReturn(current.playerId());
@@ -1,5 +1,6 @@
package games.dmg.spigotbase; package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -9,14 +10,6 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import net.md_5.bungee.api.dialog.ConfirmationDialog;
import net.md_5.bungee.api.dialog.Dialog;
import net.md_5.bungee.api.dialog.DialogListDialog;
import net.md_5.bungee.api.dialog.MultiActionDialog;
import net.md_5.bungee.api.dialog.NoticeDialog;
import net.md_5.bungee.api.dialog.action.Action;
import net.md_5.bungee.api.dialog.action.ActionButton;
import net.md_5.bungee.api.dialog.action.RunCommandAction;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
final class BaseSettingsDialogFactoryTest { final class BaseSettingsDialogFactoryTest {
@@ -42,12 +35,15 @@ final class BaseSettingsDialogFactoryTest {
PocketBaseBiome.PLAINS PocketBaseBiome.PLAINS
); );
Dialog dialog = new BaseSettingsDialogFactory( BaseSettingsDialogFactory.DialogSpec specification =
PluginSettings.from(Map.of()) new BaseSettingsDialogFactory(PluginSettings.from(Map.of()))
).create(owner, pocket); .specification(owner, pocket);
DialogListDialog root = assertInstanceOf(DialogListDialog.class, dialog); BaseSettingsDialogFactory.ListSpec root = assertInstanceOf(
assertTrue(root.getBase().body().get(0).toString().contains("Base 4/4")); BaseSettingsDialogFactory.ListSpec.class,
specification
);
assertTrue(root.content().message().contains("Base 4/4"));
List<String> commands = commands(root); List<String> commands = commands(root);
assertTrue(commands.contains("/basesettings status")); assertTrue(commands.contains("/basesettings status"));
assertTrue(commands.contains("/basesettings visitors blocked")); assertTrue(commands.contains("/basesettings visitors blocked"));
@@ -72,23 +68,19 @@ final class BaseSettingsDialogFactoryTest {
Instant.EPOCH Instant.EPOCH
); );
DialogListDialog root = assertInstanceOf( BaseSettingsDialogFactory.ListSpec root = assertInstanceOf(
DialogListDialog.class, BaseSettingsDialogFactory.ListSpec.class,
new BaseSettingsDialogFactory(PluginSettings.from(Map.of())).create( new BaseSettingsDialogFactory(PluginSettings.from(Map.of())).specification(
owner, owner,
PocketBaseState.locked(playerId) PocketBaseState.locked(playerId)
) )
); );
ConfirmationDialog upgrade = assertInstanceOf( BaseSettingsDialogFactory.ConfirmationSpec upgrade = assertInstanceOf(
ConfirmationDialog.class, BaseSettingsDialogFactory.ConfirmationSpec.class,
root.dialogs().get(2) root.dialogs().get(2)
); );
RunCommandAction action = assertInstanceOf( assertEquals("/basesettings upgrade", upgrade.confirm().command());
RunCommandAction.class,
upgrade.yes().action()
);
assertTrue(action.template().equals("/basesettings upgrade"));
} }
@Test @Test
@@ -96,20 +88,19 @@ final class BaseSettingsDialogFactoryTest {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
PlayerState owner = PlayerState.newPlayer(playerId, "Newcomer"); PlayerState owner = PlayerState.newPlayer(playerId, "Newcomer");
DialogListDialog root = assertInstanceOf( BaseSettingsDialogFactory.ListSpec root = assertInstanceOf(
DialogListDialog.class, BaseSettingsDialogFactory.ListSpec.class,
new BaseSettingsDialogFactory(PluginSettings.from(Map.of())).create( new BaseSettingsDialogFactory(PluginSettings.from(Map.of())).specification(
owner, owner,
PocketBaseState.locked(playerId) PocketBaseState.locked(playerId)
) )
); );
MultiActionDialog controls = assertInstanceOf( BaseSettingsDialogFactory.MultiSpec controls = assertInstanceOf(
MultiActionDialog.class, BaseSettingsDialogFactory.MultiSpec.class,
root.dialogs().get(0) root.dialogs().get(0)
); );
List<String> labels = controls.actions().stream() List<String> labels = controls.actions().stream()
.map(ActionButton::label) .map(BaseSettingsDialogFactory.ButtonSpec::label)
.map(Object::toString)
.toList(); .toList();
assertTrue(labels.stream().anyMatch(label -> label.contains("Visitors: Locked"))); assertTrue(labels.stream().anyMatch(label -> label.contains("Visitors: Locked")));
@@ -119,34 +110,28 @@ final class BaseSettingsDialogFactoryTest {
assertTrue(labels.stream().anyMatch(label -> label.contains("Spawnable Overlay: Locked"))); assertTrue(labels.stream().anyMatch(label -> label.contains("Spawnable Overlay: Locked")));
} }
private static List<String> commands(Dialog dialog) { private static List<String> commands(
BaseSettingsDialogFactory.DialogSpec specification
) {
List<String> commands = new ArrayList<>(); List<String> commands = new ArrayList<>();
collect(dialog, commands); collect(specification, commands);
return commands; return commands;
} }
private static void collect(Dialog dialog, List<String> commands) { private static void collect(
if (dialog instanceof DialogListDialog list) { BaseSettingsDialogFactory.DialogSpec specification,
List<String> commands
) {
if (specification instanceof BaseSettingsDialogFactory.ListSpec list) {
list.dialogs().forEach(child -> collect(child, commands)); list.dialogs().forEach(child -> collect(child, commands));
collect(list.exitAction(), commands); } else if (specification instanceof BaseSettingsDialogFactory.MultiSpec multi) {
} else if (dialog instanceof MultiActionDialog actions) { multi.actions().forEach(button -> commands.add(button.command()));
actions.actions().forEach(button -> collect(button, commands)); } else if (specification
collect(actions.exitAction(), commands); instanceof BaseSettingsDialogFactory.ConfirmationSpec confirmation) {
} else if (dialog instanceof ConfirmationDialog confirmation) { commands.add(confirmation.confirm().command());
collect(confirmation.yes(), commands); } else if (specification instanceof BaseSettingsDialogFactory.NoticeSpec notice
collect(confirmation.no(), commands); && notice.action() != null) {
} else if (dialog instanceof NoticeDialog notice) { commands.add(notice.action().command());
collect(notice.action(), commands);
}
}
private static void collect(ActionButton button, List<String> commands) {
if (button == null) {
return;
}
Action action = button.action();
if (action instanceof RunCommandAction command) {
commands.add(command.template());
} }
} }
} }
@@ -37,6 +37,7 @@ final class PocketBaseControllerTest {
World world = mock(World.class); World world = mock(World.class);
Block clicked = block(world, -2, 66, 0, Material.DIAMOND_BLOCK); Block clicked = block(world, -2, 66, 0, Material.DIAMOND_BLOCK);
PlayerInteractEvent event = mock(PlayerInteractEvent.class); PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack activator = item(Material.FLINT_AND_STEEL);
BaseStateManager baseStates = mock(BaseStateManager.class); BaseStateManager baseStates = mock(BaseStateManager.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class); PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PluginSettings settings = PluginSettings.from(Map.of()); PluginSettings settings = PluginSettings.from(Map.of());
@@ -49,7 +50,7 @@ final class PocketBaseControllerTest {
when(event.getPlayer()).thenReturn(owner); when(event.getPlayer()).thenReturn(owner);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK); when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
when(event.getClickedBlock()).thenReturn(clicked); when(event.getClickedBlock()).thenReturn(clicked);
when(event.getItem()).thenReturn(new ItemStack(Material.FLINT_AND_STEEL)); when(event.getItem()).thenReturn(activator);
when(world.getUID()).thenReturn(worldId); when(world.getUID()).thenReturn(worldId);
when(world.getName()).thenReturn("world"); when(world.getName()).thenReturn("world");
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> { when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
@@ -98,6 +99,7 @@ final class PocketBaseControllerTest {
World world = mock(World.class); World world = mock(World.class);
Block clicked = block(world, 8, 67, 8, Material.DIAMOND_BLOCK); Block clicked = block(world, 8, 67, 8, Material.DIAMOND_BLOCK);
PlayerInteractEvent event = mock(PlayerInteractEvent.class); PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack activator = item(Material.FLINT_AND_STEEL);
PocketBaseManager pocketBases = mock(PocketBaseManager.class); PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PluginSettings settings = PluginSettings.from(Map.of()); PluginSettings settings = PluginSettings.from(Map.of());
@@ -105,7 +107,7 @@ final class PocketBaseControllerTest {
when(event.getPlayer()).thenReturn(owner); when(event.getPlayer()).thenReturn(owner);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK); when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
when(event.getClickedBlock()).thenReturn(clicked); when(event.getClickedBlock()).thenReturn(clicked);
when(event.getItem()).thenReturn(new ItemStack(Material.FLINT_AND_STEEL)); when(event.getItem()).thenReturn(activator);
when(world.getUID()).thenReturn(worldId); when(world.getUID()).thenReturn(worldId);
when(world.getName()).thenReturn("pocket"); when(world.getName()).thenReturn("pocket");
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> { when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
@@ -152,6 +154,7 @@ final class PocketBaseControllerTest {
World world = mock(World.class); World world = mock(World.class);
Block clicked = block(world, 30, 67, 8, Material.DIAMOND_BLOCK); Block clicked = block(world, 30, 67, 8, Material.DIAMOND_BLOCK);
PlayerInteractEvent event = mock(PlayerInteractEvent.class); PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack activator = item(Material.FLINT_AND_STEEL);
PocketBaseManager pocketBases = mock(PocketBaseManager.class); PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PluginSettings settings = PluginSettings.from(Map.of()); PluginSettings settings = PluginSettings.from(Map.of());
@@ -159,7 +162,7 @@ final class PocketBaseControllerTest {
when(event.getPlayer()).thenReturn(owner); when(event.getPlayer()).thenReturn(owner);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK); when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
when(event.getClickedBlock()).thenReturn(clicked); when(event.getClickedBlock()).thenReturn(clicked);
when(event.getItem()).thenReturn(new ItemStack(Material.FLINT_AND_STEEL)); when(event.getItem()).thenReturn(activator);
when(world.getUID()).thenReturn(worldId); when(world.getUID()).thenReturn(worldId);
when(world.getName()).thenReturn("pocket"); when(world.getName()).thenReturn("pocket");
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> { when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
@@ -227,6 +230,12 @@ final class PocketBaseControllerTest {
verify(event).setCancelled(true); verify(event).setCancelled(true);
} }
private static ItemStack item(Material material) {
ItemStack item = mock(ItemStack.class);
when(item.getType()).thenReturn(material);
return item;
}
private static Block block( private static Block block(
World world, World world,
int x, int x,