Add /iris language and /iris debugdump on both platforms

/iris language opens a locale menu and sets the language for the caller
or, for a gamemaster, the server default; /iris debugdump writes a
diagnostic report and optionally uploads it. Neither needs iris.all, so
a plain player can reach them. Every modded command now executes inside
a language audience scope, and the Bukkit dispatcher opens one around
execution, tab completion and help. Permissions, message keys and the
shipped locales follow.
This commit is contained in:
Brian Neumann-Fopiano committed 2026-09-03 05:03:28 -04:00
1 parent b25937f986
commit 657aaeaccf
39 files changed
+654 -226

No files matched your search

@@ -18,6 +18,7 @@
package art.arcane.iris;
import art.arcane.volmlib.util.diagnostics.BukkitDebugDump;
import art.arcane.iris.engine.IrisEngineEffects;
import art.arcane.iris.engine.IrisWorldManager;
@@ -50,6 +51,8 @@ import art.arcane.iris.core.link.IrisPapiInstaller;
import art.arcane.iris.core.link.IrisPapiListener;
import art.arcane.iris.core.link.IrisPapiState;
import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.volmlib.util.director.help.DirectorMiniMenu;
import art.arcane.volmlib.util.localization.BukkitLanguageSwitcher;
import art.arcane.iris.core.link.MultiverseCoreLink;
import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.core.gui.BukkitGuiHost;
@@ -199,6 +202,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private final BukkitWorldReconciler worldReconciler = new BukkitWorldReconciler(this);
private final PendingWorldDeleteQueue pendingWorldDeletes = new PendingWorldDeleteQueue(this);
private final PendingWorldReplacementManager pendingWorldReplacements = new PendingWorldReplacementManager(this);
private BukkitLanguageSwitcher languageSwitcher;
private BukkitDebugDump debugDump;
private volatile SettingsHotloadWatch settingsHotloadWatch;
private volatile Thread serverLifecycleThread;
@@ -602,6 +607,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
MultiBurst.burst.reopen();
MultiBurst.ioBurst.reopen();
IrisLanguage.initialize();
debugDump = BukkitDebugDump.create(this);
languageSwitcher = BukkitLanguageSwitcher.register(this, IrisLanguage.selections(),
new BukkitLanguageSwitcher.Options("iris", "iris.all",
DirectorMiniMenu.Theme.irisGreen(), IrisLanguage.directorResolver(), IrisLanguage.editorOptions()));
PaperLibBootstrap.install();
SimdSupport.install();
services = new KMap<>();
@@ -861,7 +870,32 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
}
public BukkitDebugDump debugDump() {
return debugDump;
}
public void selectLanguage(CommandSender sender, String[] arguments) {
BukkitLanguageSwitcher current = languageSwitcher;
if (current != null) {
current.command(sender, arguments);
}
}
public List<String> completeLanguage(CommandSender sender, String[] arguments) {
BukkitLanguageSwitcher current = languageSwitcher;
return current == null ? List.of() : current.complete(sender, arguments);
}
public void onDisable() {
if (debugDump != null) {
debugDump.close();
debugDump = null;
}
if (languageSwitcher != null) {
languageSwitcher.close();
languageSwitcher = null;
}
IrisLanguage.shutdown();
teardownPapi();
boolean serverStopping = IrisToolbelt.isServerStopping();
boolean restartingAtStartupBoundary = startupBoundaryRestart.get();
@@ -50,6 +50,7 @@ import art.arcane.iris.util.common.format.C;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.iris.util.common.scheduling.J;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandSender;
import org.bukkit.Location;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
@@ -95,6 +96,19 @@ public class CommandIris implements DirectorExecutor {
private CommandDatapack datapack;
VolmitSender sender = Iris.getSender();
@Director(name = "debugdump", sync = true, description = "Create and optionally upload a diagnostic report", descriptionKey = "iris.director.commandiris.director.debugdump")
public void debugdump(
@Param(name = "upload", defaultValue = "true", description = "Upload the report to mclo.gs", descriptionKey = "iris.director.commandiris.param.debugdump_upload") boolean upload,
@Param(name = "sender", contextual = true) CommandSender sender
) {
Iris.instance.debugDump().request(sender, upload);
}
@Director(description = "Choose your language or the server default")
public void language() {
Iris.instance.selectLanguage(sender().getS(), new String[0]);
}
@Director(description = "Create a new world", descriptionKey = "iris.director.commandiris.director.create_new_world", aliases = {"c"})
public void create(
@Param(aliases = "world-name", description = "The name of the world to create", descriptionKey = "iris.director.commandiris.param.name_world_create")
@@ -55,6 +55,8 @@ import org.jetbrains.annotations.Nullable;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -62,11 +64,14 @@ import java.util.Optional;
import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.core.localization.IrisMessages;
import art.arcane.volmlib.util.localization.MessageArgument;
import art.arcane.volmlib.util.localization.LanguageAudience;
import art.arcane.volmlib.util.plugin.ComponentMessenger;
public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, DirectorInvocationHook {
private static final String ROOT_COMMAND = "iris";
private static final String ROOT_PERMISSION = "iris.all";
private final ThreadLocal<Deque<LanguageAudience.Scope>> languageScopes = ThreadLocal.withInitial(ArrayDeque::new);
private final transient AtomicCache<DirectorRuntimeEngine> directorCache = new AtomicCache<>();
private final transient ThreadLocal<CommandSender> dispatchSenders = new ThreadLocal<>();
@@ -102,6 +107,12 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D
private DirectorContextRegistry buildDirectorContexts() {
DirectorContextRegistry contexts = new DirectorContextRegistry();
contexts.register(CommandSender.class, (invocation, map) -> {
if (invocation.getSender() instanceof BukkitDirectorSender sender) {
return sender.sender();
}
return null;
});
for (Map.Entry<Class<?>, DirectorContextHandler<?>> entry : DirectorContextHandler.contextHandlers.entrySet()) {
registerContextHandler(contexts, entry.getKey(), entry.getValue());
@@ -139,6 +150,7 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D
@Override
public void beforeInvoke(DirectorInvocation invocation, DirectorRuntimeNode node) {
if (invocation.getSender() instanceof BukkitDirectorSender sender) {
languageScopes.get().push(LanguageAudience.open(sender.sender() instanceof Player player ? player.getUniqueId() : null));
DirectorContext.touch(new VolmitSender(sender.sender()));
}
}
@@ -146,6 +158,13 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D
@Override
public void afterInvoke(DirectorInvocation invocation, DirectorRuntimeNode node) {
DirectorContext.remove();
Deque<LanguageAudience.Scope> scopes = languageScopes.get();
if (!scopes.isEmpty()) {
scopes.pop().close();
}
if (scopes.isEmpty()) {
languageScopes.remove();
}
}
@Nullable
@@ -169,7 +188,12 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D
}
public void executeRoot(CommandSender sender, String label, String[] args) {
if (!sender.hasPermission(ROOT_PERMISSION)) {
if (args.length > 0 && args[0].equalsIgnoreCase("language")) {
Iris.instance.selectLanguage(sender, Arrays.copyOfRange(args, 1, args.length));
return;
}
if (!(args.length > 0 && args[0].equalsIgnoreCase("debugdump"))
&& !sender.hasPermission(ROOT_PERMISSION)) {
ComponentMessenger.sendSection(sender, IrisLanguage.text(
IrisMessages.COMMAND_PERMISSION_DENIED,
MessageArgument.trusted("permission", ROOT_PERMISSION)
@@ -181,7 +205,9 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D
}
public List<String> tabCompleteRoot(CommandSender sender, String alias, String[] args) {
List<String> suggestions = runDirectorTab(sender, alias, args);
List<String> suggestions = args.length > 0 && args[0].equalsIgnoreCase("language")
? Iris.instance.completeLanguage(sender, Arrays.copyOfRange(args, 1, args.length))
: runDirectorTab(sender, alias, args);
if (sender instanceof Player player && IrisSettings.get().getGeneral().isCommandSounds()) {
player.playSound(player.getLocation(), Sound.BLOCK_AMETHYST_BLOCK_CHIME, 0.25f, RNG.r.f(0.125f, 1.95f));
}
@@ -235,23 +261,25 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D
}
private boolean sendHelpIfRequested(CommandSender sender, String[] args) {
Optional<DirectorHelpPage> request = DirectorMiniMenu.resolveHelp(getDirector(), Arrays.asList(args));
if (request.isEmpty()) {
return false;
}
try (LanguageAudience.Scope audience = LanguageAudience.open(sender instanceof Player player ? player.getUniqueId() : null)) {
Optional<DirectorHelpPage> request = DirectorMiniMenu.resolveHelp(getDirector(), Arrays.asList(args));
if (request.isEmpty()) {
return false;
}
DirectorMiniMenu.deliver(
sender,
request.get(),
DirectorMiniMenu.Theme.irisGreen(),
IrisLanguage.directorResolver()
);
return true;
DirectorMiniMenu.deliver(
sender,
request.get(),
DirectorMiniMenu.Theme.irisGreen(),
IrisLanguage.directorResolver()
);
return true;
}
}
private DirectorExecutionResult runDirector(CommandSender sender, String label, String[] args) {
dispatchSenders.set(sender);
try {
try (LanguageAudience.Scope audience = LanguageAudience.open(sender instanceof Player player ? player.getUniqueId() : null)) {
return getDirector().execute(new DirectorInvocation(new BukkitDirectorSender(sender), label, Arrays.asList(args)));
} catch (Throwable e) {
Iris.warn("Director command execution failed: " + e.getClass().getSimpleName() + " " + e.getMessage());
@@ -263,7 +291,7 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D
private List<String> runDirectorTab(CommandSender sender, String alias, String[] args) {
DirectorContext.touch(new VolmitSender(sender));
try {
try (LanguageAudience.Scope audience = LanguageAudience.open(sender instanceof Player player ? player.getUniqueId() : null)) {
return getDirector().tabComplete(new DirectorInvocation(new BukkitDirectorSender(sender), alias, Arrays.asList(args)));
} catch (Throwable e) {
Iris.warn("Director tab completion failed: " + e.getClass().getSimpleName() + " " + e.getMessage());
@@ -10,6 +10,12 @@ authors: [ cyberpwn, NextdoorPsycho, Vatuu ]
website: VolmitSoftware.com
description: More than a Dimension!
permissions:
iris.debugdump:
description: Allows creating and optionally uploading diagnostic reports.
default: op
iris.language.self:
description: Allows choosing your own language for this plugin.
default: true
iris.all:
description: Allows use of the full /iris command tree (worlds, studio, pregen, packs, developer tools).
default: op
@@ -26,6 +26,12 @@ commands:
iris:
aliases: [ ir, irs ]
permissions:
iris.debugdump:
description: Allows creating and optionally uploading diagnostic reports.
default: op
iris.language.self:
description: Allows choosing your own language for this plugin.
default: true
iris.all:
description: Allows use of the full /iris command tree (worlds, studio, pregen, packs, developer tools).
default: op
@@ -57,7 +57,14 @@ public class PaperPluginMetadataTest {
assertTrue(metadata.contains("folia-supported: true"));
assertTrue(metadata.contains("load: STARTUP"));
assertFalse(metadata.contains("commands:"));
assertTrue(metadata.contains("permissions:\n iris.all:\n"
assertTrue(metadata.contains("permissions:\n"));
assertTrue(metadata.contains(" iris.debugdump:\n"
+ " description: Allows creating and optionally uploading diagnostic reports.\n"
+ " default: op"));
assertTrue(metadata.contains(" iris.language.self:\n"
+ " description: Allows choosing your own language for this plugin.\n"
+ " default: true"));
assertTrue(metadata.contains(" iris.all:\n"
+ " description: Allows use of the full /iris command tree (worlds, studio, pregen, packs, developer tools).\n"
+ " default: op\n"
+ " iris.treefeller:\n"
@@ -0,0 +1,56 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.Iris;
import art.arcane.iris.core.service.CommandSVC;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class CommandLanguageRoutingTest {
private Iris previousPlugin;
private Iris plugin;
@Before
public void installPlugin() {
previousPlugin = Iris.instance;
plugin = mock(Iris.class);
Iris.instance = plugin;
}
@After
public void restorePlugin() {
Iris.instance = previousPlugin;
}
@Test
public void forwardsEveryLanguageArgumentBeforeCheckingTheAdminRootPermission() {
Player player = mock(Player.class);
String[] arguments = {"language", "self", "de_DE"};
new CommandSVC().executeRoot(player, "iris", arguments);
verify(plugin).selectLanguage(player, new String[]{"self", "de_DE"});
verify(player, never()).hasPermission("iris.all");
}
@Test
public void forwardsLanguageTabArgumentsToTheSharedCompletionService() {
CommandSender sender = mock(CommandSender.class);
String[] arguments = {"language", "self", "de"};
String[] languageArguments = {"self", "de"};
when(plugin.completeLanguage(sender, languageArguments)).thenReturn(List.of("de_DE"));
assertEquals(List.of("de_DE"), new CommandSVC().tabCompleteRoot(sender, "iris", arguments));
verify(plugin).completeLanguage(sender, languageArguments);
}
}
@@ -82,12 +82,15 @@ public class IrisPapiLifecycleTest {
@Test
public void bothDisablePathsTearThePlaceholderSurfaceDown() throws Exception {
String source = source();
String disable = body("public void onDisable() {");
String preUnload = body("public void onPreUnload(ReloadAware.PreUnloadReason reason) {");
int disableTeardown = disable.indexOf("teardownPapi();");
int preUnloadTeardown = preUnload.indexOf("teardownPapi();");
assertTrue("onDisable must tear the expansion down",
source.contains("public void onDisable() {\n teardownPapi();"));
assertTrue("the BileTools pre-unload hook must tear the expansion down",
source.contains("public void onPreUnload(ReloadAware.PreUnloadReason reason) {\n teardownPapi();"));
assertTrue("onDisable must tear the expansion down before branching into runtime shutdown",
disableTeardown >= 0 && disableTeardown < disable.indexOf("IrisToolbelt.isServerStopping()"));
assertTrue("the BileTools pre-unload hook must tear the expansion down before any shutdown branch",
preUnloadTeardown >= 0 && preUnloadTeardown < preUnload.indexOf("IrisToolbelt.isServerStopping()"));
}
@Test