diff --git a/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java b/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java index cca8a961..2616b339 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java +++ b/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java @@ -400,6 +400,9 @@ public void registerListeners() { getServer().getPluginManager().registerEvents(classPickListener, this); getCommand("class").setExecutor(classPickListener); getCommand("subclass").setExecutor(classPickListener); + for (String root : java.util.List.of("class", "subclass", "players", "will")) { + getCommand(root).setTabCompleter((sender, command, label, args) -> java.util.List.of()); + } getServer().getPluginManager().registerEvents(clueInputManager, this); getServer().getPluginManager().registerEvents(placeClueManager, this); getServer().getPluginManager().registerEvents(magnifyingGlassListener, this); diff --git a/src/main/java/net/tfminecraft/rpcharacters/chat/ChatChannelCommandHandler.java b/src/main/java/net/tfminecraft/rpcharacters/chat/ChatChannelCommandHandler.java index 4a2d38e7..ce8a2e2a 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/chat/ChatChannelCommandHandler.java +++ b/src/main/java/net/tfminecraft/rpcharacters/chat/ChatChannelCommandHandler.java @@ -67,6 +67,10 @@ private boolean handleChannelSwitch(Player player, String label, String[] args) .replace("{channels}", String.join(", ", Cache.chatSwitchableChannels))); return true; } + if (!channel.getUsePermission().isBlank() && !player.hasPermission(channel.getUsePermission())) { + RPTexts.send(player, RPTexts.ERROR + "You cannot use this chat channel."); + return true; + } String current = ChatChannelPreferenceManager.get().getSwitchedChannelId(player); if (channelId.equals(current) || (current == null && channelId.equals(Cache.chatDefaultChannel))) { @@ -125,16 +129,20 @@ private static String resolveChannelId(String input) { @Override public List onTabComplete(CommandSender sender, Command command, String label, String[] args) { - if (args.length != 1) { + if (!(sender instanceof Player) || args.length != 1) { return List.of(); } String name = command.getName().toLowerCase(Locale.ROOT); - List options = CHANNEL_COMMAND.equals(name) + List options = CHANNEL_COMMAND.equals(name) && Cache.chatChannelSwitcherEnabled ? Cache.chatSwitchableChannels - : TOGGLE_COMMAND.equals(name) ? Cache.chatToggleableChannels : List.of(); + : TOGGLE_COMMAND.equals(name) && Cache.chatChannelTogglerEnabled ? Cache.chatToggleableChannels : List.of(); String prefix = args[0].toLowerCase(Locale.ROOT); return options.stream() - .filter(option -> option.startsWith(prefix)) + .filter(option -> ChatLoader.getChannel(option) != null) + .filter(option -> !CHANNEL_COMMAND.equals(name) + || ChatLoader.getChannel(option).getUsePermission().isBlank() + || sender.hasPermission(ChatLoader.getChannel(option).getUsePermission())) + .filter(option -> option.toLowerCase(Locale.ROOT).startsWith(prefix)) .collect(Collectors.toList()); } } diff --git a/src/main/java/net/tfminecraft/rpcharacters/chat/ChatCommandRegistry.java b/src/main/java/net/tfminecraft/rpcharacters/chat/ChatCommandRegistry.java index 21974492..1cff5b69 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/chat/ChatCommandRegistry.java +++ b/src/main/java/net/tfminecraft/rpcharacters/chat/ChatCommandRegistry.java @@ -27,6 +27,8 @@ public static void sync(Plugin plugin) { for (String label : labels) { registerCommand(plugin, label); } + // Config reload also changes permissions on existing labels in clients' command trees. + Bukkit.getOnlinePlayers().forEach(org.bukkit.entity.Player::updateCommands); } private static void registerCommand(Plugin plugin, String label) { @@ -40,6 +42,10 @@ private static void registerCommand(Plugin plugin, String label) { PluginCommand command = constructor.newInstance(normalized, plugin); command.setExecutor(EXECUTOR); command.setDescription("RP chat channel"); + ChatChannel channel = ChatLoader.getChannel(ChatLoader.resolveChannelFromCommand(normalized)); + if (channel != null && !channel.getUsePermission().isBlank()) { + command.setPermission(channel.getUsePermission()); + } Bukkit.getCommandMap().register(plugin.getName().toLowerCase(Locale.ROOT), command); registered.add(command); } catch (ReflectiveOperationException | RuntimeException e) { diff --git a/src/main/java/net/tfminecraft/rpcharacters/estate/EstateCommand.java b/src/main/java/net/tfminecraft/rpcharacters/estate/EstateCommand.java index 2dd37c81..77b64d3a 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/estate/EstateCommand.java +++ b/src/main/java/net/tfminecraft/rpcharacters/estate/EstateCommand.java @@ -1,6 +1,8 @@ package net.tfminecraft.rpcharacters.estate; import java.util.Arrays; +import java.util.ArrayList; +import java.util.Locale; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -45,13 +47,11 @@ public boolean onCommand(CommandSender sender, Command command, String label, St } if (args.length < 2) return reply(sender, "Name the estate: a death ID, character ID or dead character's name."); // The longest run of words naming an estate wins, so two-word names work before further arguments. - int end = args.length; - Optional found = Optional.empty(); - while (end > 1 && (found = staff.find(String.join(" ", Arrays.copyOfRange(args, 1, end)))).isEmpty()) end--; - if (found.isEmpty()) return reply(sender, "No estate matches " + args[1] + "."); - EstateRecord record = found.get(); + Resolved resolved = resolve(args, args.length, staff::find); + if (resolved == null) return reply(sender, "No estate matches " + args[1] + "."); + EstateRecord record = resolved.record(); String by = sender.getName(); - String[] rest = Arrays.copyOfRange(args, end, args.length); + String[] rest = Arrays.copyOfRange(args, resolved.end(), args.length); return switch (sub) { case "show" -> { staff.show(record).forEach(sender::sendMessage); @@ -86,13 +86,78 @@ private static boolean reply(CommandSender sender, String message) { return true; } + private record Resolved(EstateRecord record, int end) {} + + private static Resolved resolve(String[] args, int end, Function> lookup) { + while (end > 1) { + Optional found = lookup.apply(String.join(" ", Arrays.copyOfRange(args, 1, end))); + if (found.isPresent()) return new Resolved(found.get(), end); + end--; + } + return null; + } + @Override public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { - if (!sender.hasPermission(PERMISSION)) return args.length == 1 ? List.of("collect") : List.of(); - if (args.length == 1) return STAFF.stream().filter(s -> s.startsWith(args[0].toLowerCase(java.util.Locale.ROOT))).toList(); - if (args.length == 2 && args[0].equalsIgnoreCase("list")) return FILTERS; - if (args.length == 3 && args[0].equalsIgnoreCase("assign")) return List.of("money", "property", "guildlocks", "all"); - if (args.length == 3 && args[0].equalsIgnoreCase("decide")) return List.of("--no-guild"); - return List.of(); + if (args.length == 0) return List.of(); + String prefix = args[args.length - 1]; + if (!sender.hasPermission(PERMISSION)) return sender instanceof Player && args.length == 1 + ? filter(List.of("collect"), prefix) : List.of(); + if (args.length == 1) return filter(STAFF.stream() + .filter(sub -> !sub.equals("collect") || sender instanceof Player).toList(), prefix); + String sub = args[0].toLowerCase(Locale.ROOT); + if (sub.equals("collect") && !(sender instanceof Player)) return List.of(); + if (sub.equals("list")) return args.length == 2 ? filter(FILTERS, prefix) : List.of(); + if (!STAFF.contains(sub)) return List.of(); + + List records = staff.records(); + List names = new ArrayList<>(); + if (args.length == 2) records.forEach(record -> names.add(record.deathId.toString())); + String preceding = args.length == 2 ? "" : String.join(" ", Arrays.copyOfRange(args, 1, args.length - 1)) + " "; + String typed = (preceding + prefix).toLowerCase(Locale.ROOT); + for (EstateRecord record : records) { + if (record.name.toLowerCase(Locale.ROOT).startsWith(typed)) names.add(record.name.substring(preceding.length())); + } + if (!names.isEmpty()) return filter(names, prefix); + + // The current token is still being replaced; only completed words can consume the estate key. + Resolved resolved = resolve(args, args.length - 1, key -> records.stream() + .filter(record -> record.deathId.toString().equalsIgnoreCase(key) + || record.testator.characterId().toString().equalsIgnoreCase(key) + || record.name.equalsIgnoreCase(key)) + .reduce((older, newer) -> newer)); + if (resolved == null) return List.of(); + EstateRecord record = resolved.record(); + int position = args.length - 1 - resolved.end(); + List options = new ArrayList<>(); + if (position == 0) { + if (sub.equals("decide") && record.state == net.tfminecraft.tlibs.character.estate.EstateState.OPEN) { + options.add("--no-guild"); + } else if (sub.equals("assign") && record.settling()) { + var decision = record.decision; + if (assignable(decision.money())) options.add("money"); + if (assignable(decision.property())) options.add("property"); + if (assignable(decision.guildLocks())) options.add("guildlocks"); + if (!options.isEmpty()) options.add("all"); + } else if ((sub.equals("retry") || sub.equals("waive")) && record.settling()) { + record.participants.forEach((id, report) -> { + if (report.outcome().equals("BLOCKED") + && (!sub.equals("waive") || report.revision() == record.decision.revision())) options.add(id); + }); + } + } else if (position == 1 && sub.equals("assign") && record.settling() + && args[resolved.end()].equalsIgnoreCase("money") && record.decision.money().recipient().isFrozen()) { + options.add("guild"); + } + return filter(options, prefix); + } + + private static boolean assignable(EstateRecord.Part part) { + return part.recipient().isWaiting() || part.recipient().isFrozen(); + } + + private static List filter(List options, String prefix) { + String lower = prefix.toLowerCase(Locale.ROOT); + return options.stream().filter(option -> option.toLowerCase(Locale.ROOT).startsWith(lower)).distinct().toList(); } } diff --git a/src/main/java/net/tfminecraft/rpcharacters/estate/EstateStaff.java b/src/main/java/net/tfminecraft/rpcharacters/estate/EstateStaff.java index 6a033fdc..e41e3f74 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/estate/EstateStaff.java +++ b/src/main/java/net/tfminecraft/rpcharacters/estate/EstateStaff.java @@ -47,6 +47,11 @@ Optional find(String key) { } } + /** Loaded records only: no participant, character-directory or persistence calls. */ + List records() { + return List.copyOf(book.all()); + } + String hold(EstateRecord record, String staff) { if (record.state != EstateState.OPEN) return "Only an open estate can be held; this one is " + record.state + "."; return change(record, staff, "held", next -> { diff --git a/src/main/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleter.java b/src/main/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleter.java index 758ee88b..79d7e7ee 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleter.java +++ b/src/main/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleter.java @@ -6,6 +6,9 @@ import java.util.Locale; import java.util.stream.Collectors; +import net.Indyuce.mmocore.MMOCore; +import net.Indyuce.mmocore.api.player.profess.PlayerClass; + import org.bukkit.Bukkit; import org.bukkit.command.Command; import org.bukkit.command.CommandSender; @@ -14,6 +17,7 @@ import net.tfminecraft.rpcharacters.Cache; import net.tfminecraft.rpcharacters.classpick.ClassPickReset; +import net.tfminecraft.rpcharacters.enums.Status; import net.tfminecraft.rpcharacters.loaders.TraitLoader; import net.tfminecraft.rpcharacters.managers.PlayerManager; import net.tfminecraft.rpcharacters.objects.PlayerData; @@ -54,8 +58,8 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe completions.add("clues"); completions.add("wardrobe"); completions.add("party"); - completions.add("injure"); - completions.addAll(PERSONA_SUBCOMMANDS); + if (Permissions.isAdmin(sender) || sender.hasPermission("rpchar.injure")) completions.add("injure"); + PERSONA_SUBCOMMANDS.stream().filter(sub -> canUsePersona(sender, sub)).forEach(completions::add); if (Permissions.isAdmin(sender)) { completions.add("admin"); completions.add("reload"); @@ -89,6 +93,11 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe } String sub = args[0].toLowerCase(Locale.ROOT); + if (List.of("setclass", "seteighteen", "skipcooldown", "addtrait", "removetrait", "permakill") + .contains(sub) && !Permissions.isAdmin(sender)) return Collections.emptyList(); + if (sub.equals("menu") && !Permissions.isAdmin(sender)) return Collections.emptyList(); + if (sub.equals("injure") && !Permissions.isAdmin(sender) + && !sender.hasPermission("rpchar.injure")) return Collections.emptyList(); if (CharCommand.isPersonaSubcommand(sub)) { return completePersona(sender, sub, args); } @@ -165,7 +174,7 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe completions.add(ClassPickReset.SUBCOMMAND); } } else if (args[0].equalsIgnoreCase("addtrait") || args[0].equalsIgnoreCase("removetrait") - || args[0].equalsIgnoreCase("injure")) { + || args[0].equalsIgnoreCase("injure") || sub.equals("permakill")) { for (Player online : Bukkit.getOnlinePlayers()) { completions.add(online.getName()); } @@ -217,7 +226,11 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe } else if (args[0].equalsIgnoreCase("sethidden") && sender.hasPermission(Cache.personaCharacterHiddenPermission)) { completions.addAll(CLEAR); } else if (args[0].equalsIgnoreCase("setclass")) { - completions.add("className"); + if (MMOCore.plugin != null && MMOCore.plugin.classManager != null) { + for (PlayerClass playerClass : MMOCore.plugin.classManager.getAll()) { + completions.add(playerClass.getId()); + } + } } else if (args[0].equalsIgnoreCase("seteighteen") && Permissions.isAdmin(sender)) { completions.add("true"); completions.add("false"); @@ -238,54 +251,36 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe } } } else if (args[0].equalsIgnoreCase("addtrait") || args[0].equalsIgnoreCase("removetrait")) { - for (Trait trait : TraitLoader.get()) { - completions.add(trait.getId()); + Player target = Bukkit.getPlayerExact(args[1]); + PlayerData data = target == null ? null : PlayerManager.get(target); + if (data != null && data.hasActiveCharacter()) { + List owned = data.getActiveCharacter().getTraits(); + for (Trait trait : sub.equals("removetrait") ? owned : TraitLoader.get()) { + if (sub.equals("removetrait") || owned.stream() + .noneMatch(current -> current.getId().equalsIgnoreCase(trait.getId()))) { + completions.add(trait.getId()); + } + } } } else if (args[0].equalsIgnoreCase("admin") && Permissions.isAdmin(sender) && (args[1].equalsIgnoreCase("injure") || args[1].equalsIgnoreCase("permakill"))) { for (Player online : Bukkit.getOnlinePlayers()) { completions.add(online.getName()); } - } else if (args[0].equalsIgnoreCase("injure") && Permissions.isAdmin(sender)) { - Player target = Bukkit.getPlayerExact(args[1]); - if (target != null) { - PlayerData pd = PlayerManager.get(target); - if (pd != null) { - for (RPCharacter character : pd.getCharacters()) { - if (character.getSlug() != null) { - completions.add(character.getSlug()); - } - if (character.getName() != null) { - completions.add(character.getName()); - } - } - } - } - completions.add("permanent"); + } else if ((sub.equals("injure") || sub.equals("permakill")) && Permissions.isAdmin(sender)) { + completions.addAll(characterIds(args[1])); + if (sub.equals("injure")) completions.add("permanent"); } return filter(completions, args[2]); } if (args.length == 4 - && args[0].equalsIgnoreCase("admin") - && args[1].equalsIgnoreCase("injure") + && sub.equals("admin") + && (args[1].equalsIgnoreCase("injure") || args[1].equalsIgnoreCase("permakill")) && Permissions.isAdmin(sender)) { - Player target = Bukkit.getPlayerExact(args[2]); - if (target != null) { - PlayerData pd = PlayerManager.get(target); - if (pd != null) { - for (RPCharacter character : pd.getCharacters()) { - if (character.getSlug() != null) { - completions.add(character.getSlug()); - } - if (character.getName() != null) { - completions.add(character.getName()); - } - } - } - } - completions.add("permanent"); + completions.addAll(characterIds(args[2])); + if (args[1].equalsIgnoreCase("injure")) completions.add("permanent"); return filter(completions, args[3]); } @@ -358,7 +353,35 @@ private static List characterNames(String playerName) { return names; } + /** Slugs are unique within the account; IDs cover characters without a usable slug. */ + private static List characterIds(String playerName) { + Player target = Bukkit.getPlayerExact(playerName); + PlayerData data = target == null ? null : PlayerManager.get(target); + if (data == null) return Collections.emptyList(); + List ids = new ArrayList<>(); + for (RPCharacter character : data.getCharacters()) { + if (character.getStatus() != Status.ALIVE) continue; + String slug = character.getSlug(); + String id = slug != null && !slug.isBlank() && slug.chars().noneMatch(Character::isWhitespace) + && !slug.equalsIgnoreCase("permanent") ? slug : character.getId(); + if (id != null && !id.isBlank()) ids.add(id); + } + return ids; + } + + private static boolean canUsePersona(CommandSender sender, String sub) { + if (sub.equals("override")) return sender.hasPermission(Cache.personaOverridePermission); + if (!(sender instanceof Player player)) return false; + return switch (sub) { + case "profile" -> sender.hasPermission(Cache.profilePermission); + case "namecolour" -> PermissionGroupService.canUseNameColour(player); + case "alias", "gender", "description", "birthday" -> sender.hasPermission(Cache.personaSetPermission); + default -> true; + }; + } + private List completePersona(CommandSender sender, String sub, String[] args) { + if (!canUsePersona(sender, sub)) return Collections.emptyList(); if (sub.equals("override")) { return completeOverride(sender, args); } @@ -372,10 +395,6 @@ private List completePersona(CommandSender sender, String sub, String[] } return Collections.emptyList(); } - if (!(sender instanceof Player)) { - return Collections.emptyList(); - } - switch (sub) { case "mail": return args.length == 2 ? filter(List.of("on", "off"), args[1]) : Collections.emptyList(); @@ -414,9 +433,6 @@ private List completePersona(CommandSender sender, String sub, String[] } private List completeOverride(CommandSender sender, String[] args) { - if (!sender.hasPermission(Cache.personaOverridePermission)) { - return Collections.emptyList(); - } if (args.length == 2) { return Bukkit.getOnlinePlayers().stream() .map(Player::getName) diff --git a/src/main/java/net/tfminecraft/rpcharacters/wardrobe/WardrobeCommand.java b/src/main/java/net/tfminecraft/rpcharacters/wardrobe/WardrobeCommand.java index 85046eaa..8a3b3190 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/wardrobe/WardrobeCommand.java +++ b/src/main/java/net/tfminecraft/rpcharacters/wardrobe/WardrobeCommand.java @@ -149,8 +149,8 @@ public static List tabComplete(Player player, String[] args) { String name = slot.getDisplayName(); if (name != null && !name.isBlank()) { String cleaned = name.trim().replace(' ', '_'); - if (!cleaned.equalsIgnoreCase(id) - && !options.contains(cleaned)) { + if (id.equals(resolveSlotArg(snapshot, cleaned)) + && !cleaned.equalsIgnoreCase(id) && !options.contains(cleaned)) { options.add(cleaned); } } diff --git a/src/test/java/net/tfminecraft/rpcharacters/RPCharactersStartupTest.java b/src/test/java/net/tfminecraft/rpcharacters/RPCharactersStartupTest.java index eb35368b..190574d0 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/RPCharactersStartupTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/RPCharactersStartupTest.java @@ -35,6 +35,18 @@ public static class RuntimePlugin extends RPCharacters { @Override public void o T singleton(Class type,MockedStatic.Verification getter){var api=mockStatic(type);boundaries.add(api);T instance=mock(type);api.when(getter).thenReturn(instance);return instance;} @AfterEach void cleanup()throws Exception{for(int i=boundaries.size()-1;i>=0;i--)boundaries.get(i).close();state.close();MockBukkit.unmock();} @Test void startupWiresCommandsListenersConfigurationAndShutdown(){plugin.startRuntime();assertSame(plugin,RPCharacters.plugin);assertEquals(1,config.constructed().size());assertEquals(1,focus.constructed().size());verify(focus.constructed().getFirst()).start();assertTrue(commands.keySet().containsAll(List.of("focus","rpcharacter","roll","profession","channel","channeltoggle","pvp","duel","grave","players","will")));verify(plugins,atLeast(40)).registerEvents(any(),eq(plugin));verify(commands.get("rpcharacter")).setExecutor(any());verify(config.constructed().getFirst()).load(new File(plugin.getDataFolder(),"config.yml"));plugin.stopRuntime();verify(focus.constructed().getFirst()).shutdown();} + @Test void guiRootsExplicitlySuppressPlayerNameFallbackForEveryLabel() { + plugin.startRuntime(); + for (String root : List.of("class", "subclass", "players", "will")) { + var captured = org.mockito.ArgumentCaptor.forClass(org.bukkit.command.TabCompleter.class); + verify(commands.get(root)).setTabCompleter(captured.capture()); + for (String label : List.of(root, "rpcharacters:" + root, "c")) { + assertEquals(List.of(), captured.getValue().onTabComplete(server.getConsoleSender(), commands.get(root), label, new String[]{""})); + assertEquals(List.of(), captured.getValue().onTabComplete(server.getConsoleSender(), commands.get(root), label, new String[]{"I", ""})); + } + } + plugin.stopRuntime(); + } @Test void reloadReportsFocusFailureAndRecoveryWhileRefreshingOtherConfiguration()throws Exception{plugin.startRuntime();var module=focus.constructed().getFirst();var service=mock(FocusService.class);when(module.getService()).thenReturn(service);assertSame(service,RPCharacters.getFocusService());var sender=server.getConsoleSender();plugin.reloadConfigs(sender);when(module.reloadConfig()).thenReturn(true);assertTrue(plugin.reloadFocusConfig());plugin.reload();plugin.reloadConfigs(null);plugin.reloadConfigs(sender);verify(module,atLeast(4)).reloadConfig();doThrow(new IllegalStateException("broken config")).when(config.constructed().getFirst()).load(any());assertDoesNotThrow(()->plugin.reloadConfigs(sender));assertDoesNotThrow(()->plugin.reloadConfigs(null));plugin.stopRuntime();} @Test void repeatedStartupReusesDependencyComponentsAndOptionalIntegrationsRegister()throws Exception{when(plugins.isPluginEnabled("SimpleFactions")).thenReturn(true);when(plugins.isPluginEnabled("PlaceholderAPI")).thenReturn(true);var world=mock(World.class);File dataFolder=plugin.getDataFolder();when(world.getWorldFolder()).thenReturn(dataFolder);when(serverFacade.getWorlds()).thenReturn(List.of(world));plugin.createFolders();Files.writeString(plugin.getDataFolder().toPath().resolve("professions.yml"),"{}\n");Files.createDirectories(plugin.getDataFolder().toPath().resolve("traits/subdirectory"));plugin.startRuntime();plugin.stopRuntime();plugin.startRuntime();assertEquals(1,config.constructed().size());assertEquals(2,focus.constructed().size());plugin.stopRuntime();} @Test void unreadableDirectoryCannotRegisterTheIdentityProvider() throws Exception { diff --git a/src/test/java/net/tfminecraft/rpcharacters/chat/ChatCommandRegistryTest.java b/src/test/java/net/tfminecraft/rpcharacters/chat/ChatCommandRegistryTest.java index b0ea4101..b8fe3dd0 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/chat/ChatCommandRegistryTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/chat/ChatCommandRegistryTest.java @@ -60,6 +60,38 @@ class ChatCommandRegistryTest { assertNull(server.getCommandMap().getCommand("chattest:looc")); } + @Test void configuredPermissionAppliesToEveryAliasAndReloadRefreshesOnlineCommandTrees() { + var channel = mock(ChatChannel.class); when(channel.getUsePermission()).thenReturn("rpchar.chat.admin"); + channels.when(() -> ChatLoader.resolveChannelFromCommand(org.mockito.ArgumentMatchers.anyString())).thenReturn("admin"); + channels.when(() -> ChatLoader.getChannel("admin")).thenReturn(channel); + var player = mock(org.bukkit.entity.Player.class); + when(player.hasPermission("rpchar.chat.admin")).thenReturn(false); + labels.addAll(List.of("admin", "a", "narrate")); + try (var bukkit = mockStatic(org.bukkit.Bukkit.class, CALLS_REAL_METHODS)) { + bukkit.when(org.bukkit.Bukkit::getOnlinePlayers).thenReturn(List.of(player)); + ChatCommandRegistry.sync(plugin); + for (String label : labels) { + var command = server.getCommandMap().getCommand(label); + assertEquals("rpchar.chat.admin", command.getPermission()); + assertFalse(command.testPermissionSilent(player)); + assertSame(command, server.getCommandMap().getCommand("chattest:" + label)); + } + verify(player).updateCommands(); + when(channel.getUsePermission()).thenReturn("rpchar.chat.helper"); + when(player.hasPermission("rpchar.chat.helper")).thenReturn(true); + ChatCommandRegistry.sync(plugin); + assertEquals("rpchar.chat.helper", server.getCommandMap().getCommand("admin").getPermission()); + assertTrue(server.getCommandMap().getCommand("admin").testPermissionSilent(player)); + verify(player, times(2)).updateCommands(); + when(channel.getUsePermission()).thenReturn(""); + ChatCommandRegistry.sync(plugin); + assertNull(server.getCommandMap().getCommand("admin").getPermission()); + labels.clear(); ChatCommandRegistry.sync(plugin); + assertNull(server.getCommandMap().getCommand("admin")); + verify(player, times(4)).updateCommands(); + } + } + @Test void failedRegistrationDoesNotPreventALaterRetry() { labels.add("looc"); try (var bukkit = mockStatic(org.bukkit.Bukkit.class, CALLS_REAL_METHODS)) { diff --git a/src/test/java/net/tfminecraft/rpcharacters/chat/ChatManagerTest.java b/src/test/java/net/tfminecraft/rpcharacters/chat/ChatManagerTest.java index 1a1426c1..cb5294ae 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/chat/ChatManagerTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/chat/ChatManagerTest.java @@ -88,7 +88,48 @@ class ChatManagerTest { @Test void delayedPlainChatDoesNotBroadcastAfterTheSenderDisconnects(){new ChatManager().onPlainChat(chat(sender,"queued"));when(sender.isOnline()).thenReturn(false);scheduled.remove().run();assertTrue(events.isEmpty(),"A queued chat callback must not broadcast for a disconnected sender");assertTrue(text(near).isEmpty());} @Test void preferenceAndCooldownLifecycleKeepPlayersAndChannelsSeparate() throws Exception {var p=ChatChannelPreferenceManager.get();assertNull(p.getSwitchedChannelId(null));assertFalse(p.setSwitchedChannel(null,"rp"));assertFalse(p.setSwitchedChannel(sender,null));assertTrue(p.isChannelVisible(null,"rp"));assertTrue(p.isChannelVisible(sender,null));assertTrue(p.toggleChannelVisibility(null,"rp"));assertTrue(p.toggleChannelVisibility(sender,null));p.clear(null);assertSame(channels.get("rp"),p.getActiveChannel(sender));assertTrue(p.setSwitchedChannel(sender,"OOC"));assertSame(channels.get("ooc"),p.getActiveChannel(sender));p.setSwitchedChannel(sender,"gone");assertSame(channels.get("rp"),p.getActiveChannel(sender));assertFalse(p.toggleChannelVisibility(sender,"RP"));assertFalse(p.isChannelVisible(sender,"rp"));assertTrue(p.toggleChannelVisibility(sender,"rp"));assertTrue(p.isChannelVisible(sender,"rp"));p.onQuit(new PlayerQuitEvent(sender,"bye"));assertNull(p.getSwitchedChannelId(sender));var cd=ChatCooldownManager.get();assertFalse(cd.isOnCooldown(null,"rp",1));assertFalse(cd.isOnCooldown(sender,"rp",0));cd.applyCooldown(null,"rp",1);cd.applyCooldown(sender,"rp",0);cd.clear(null);assertEquals(0,cd.getRemainingSeconds(sender,"rp"));cd.applyCooldown(sender,"rp",1);assertEquals(0,cd.getRemainingSeconds(sender,"other"));assertFalse(cd.isOnCooldown(sender,"other",1));assertEquals(1,cd.getRemainingSeconds(sender,"rp"));Thread.sleep(1_025);assertEquals(0,cd.getRemainingSeconds(sender,"rp"));assertFalse(cd.isOnCooldown(sender,"rp",1));cd.onQuit(new PlayerQuitEvent(sender,"bye"));assertEquals(0,cd.getRemainingSeconds(sender,"rp"));} @Test void channelSwitchAndToggleCommandsUseConfiguredIdsAndAliases(){var h=new ChatChannelCommandHandler();var channel=command("CHANNEL");assertTrue(h.onCommand(mock(CommandSender.class),channel,"channel",null));h.onCommand(sender,channel,"channel",null);assertTrue(text(sender).contains("Current rp"));h.onCommand(sender,channel,"channel",new String[]{"rp"});assertTrue(text(sender).contains("Already switched"));h.onCommand(sender,channel,"channel",new String[]{"ooc"});assertEquals("ooc",ChatChannelPreferenceManager.get().getSwitchedChannelId(sender));assertTrue(text(sender).contains("Switched ooc"));h.onCommand(sender,channel,"channel",new String[]{"ooc"});h.onCommand(sender,channel,"channel",new String[]{"SAY"});assertEquals("rp",ChatChannelPreferenceManager.get().getSwitchedChannelId(sender));var toggle=command("channeltoggle");h.onCommand(sender,toggle,"ct",new String[]{"SAY"});assertFalse(ChatChannelPreferenceManager.get().isChannelVisible(sender,"rp"));assertTrue(text(sender).contains("Disabled rp"));h.onCommand(sender,toggle,"ct",new String[]{"rp"});assertTrue(ChatChannelPreferenceManager.get().isChannelVisible(sender,"rp"));assertTrue(text(sender).contains("Enabled rp"));} - @Test void channelCommandErrorsAndCompletionsDoNotChangePreferences(){var h=new ChatChannelCommandHandler();var c=command("channel");var t=command("channeltoggle");h.onCommand(sender,c,"ch",new String[]{"a","b"});h.onCommand(sender,t,"ct",null);h.onCommand(sender,t,"ct",new String[0]);aliases.put("removed","missing");for(String invalid:Arrays.asList(null," ","bad","missing","removed")){h.onCommand(sender,c,"ch",new String[]{invalid});h.onCommand(sender,t,"ct",new String[]{invalid});}assertTrue(text(sender).contains("Usage ch "));assertTrue(text(sender).contains("Invalid rp, ooc, missing"));Cache.chatChannelSwitcherEnabled=false;h.onCommand(sender,c,"ch",new String[]{"ooc"});Cache.chatChannelTogglerEnabled=false;h.onCommand(sender,t,"ct",new String[]{"rp"});assertTrue(text(sender).contains("Switch disabled"));assertTrue(text(sender).contains("Toggle disabled"));h.onCommand(sender,command("other"),"other",new String[0]);Cache.chatChannelSwitcherEnabled=true;channels.clear();h.onCommand(sender,c,"ch",new String[0]);assertTrue(text(sender).contains("Current rp"));Locale.setDefault(Locale.forLanguageTag("tr-TR"));assertEquals(List.of("missing"),h.onTabComplete(sender,c,"ch",new String[]{"MI"}));assertEquals(List.of("rp"),h.onTabComplete(sender,t,"ct",new String[]{"r"}));assertTrue(h.onTabComplete(sender,c,"ch",new String[0]).isEmpty());assertTrue(h.onTabComplete(sender,command("other"),"other",new String[]{""}).isEmpty());assertNull(ChatChannelPreferenceManager.get().getSwitchedChannelId(sender));} + @Test void channelCommandErrorsAndCompletionsDoNotChangePreferences(){var h=new ChatChannelCommandHandler();var c=command("channel");var t=command("channeltoggle");h.onCommand(sender,c,"ch",new String[]{"a","b"});h.onCommand(sender,t,"ct",null);h.onCommand(sender,t,"ct",new String[0]);aliases.put("removed","missing");for(String invalid:Arrays.asList(null," ","bad","missing","removed")){h.onCommand(sender,c,"ch",new String[]{invalid});h.onCommand(sender,t,"ct",new String[]{invalid});}assertTrue(text(sender).contains("Usage ch "));assertTrue(text(sender).contains("Invalid rp, ooc, missing"));Cache.chatChannelSwitcherEnabled=false;h.onCommand(sender,c,"ch",new String[]{"ooc"});Cache.chatChannelTogglerEnabled=false;h.onCommand(sender,t,"ct",new String[]{"rp"});assertTrue(text(sender).contains("Switch disabled"));assertTrue(text(sender).contains("Toggle disabled"));h.onCommand(sender,command("other"),"other",new String[0]);Cache.chatChannelSwitcherEnabled=true;channels.clear();h.onCommand(sender,c,"ch",new String[0]);assertTrue(text(sender).contains("Current rp"));Locale.setDefault(Locale.forLanguageTag("tr-TR"));assertEquals(List.of(),h.onTabComplete(sender,c,"ch",new String[]{"MI"}));assertEquals(List.of(),h.onTabComplete(sender,t,"ct",new String[]{"r"}));assertTrue(h.onTabComplete(sender,c,"ch",new String[0]).isEmpty());assertTrue(h.onTabComplete(sender,command("other"),"other",new String[]{""}).isEmpty());assertNull(ChatChannelPreferenceManager.get().getSwitchedChannelId(sender));} + @Test void restrictedChannelIdsAndAliasesCannotChangeTheSwitchedPreference() { + var handler = new ChatChannelCommandHandler(); var c = command("channel"); + channel("admin", "use-perm", "staff.chat"); aliases.put("staff", "admin"); + Cache.chatSwitchableChannels = List.of("rp", "ooc", "admin"); + Cache.chatToggleableChannels = Cache.chatSwitchableChannels; + assertEquals(List.of(), handler.onTabComplete(sender, c, "channel", new String[]{"Ad"})); + assertTrue(handler.onCommand(sender, c, "channel", new String[]{"AdMiN"})); + assertNull(ChatChannelPreferenceManager.get().getSwitchedChannelId(sender)); + handler.onCommand(sender, c, "channel", new String[]{"ooc"}); + for (String target : List.of("AdMiN", "STAFF")) { + messages.get(sender).clear(); + assertTrue(handler.onCommand(sender, c, "channel", new String[]{target})); + assertEquals("ooc", ChatChannelPreferenceManager.get().getSwitchedChannelId(sender)); + assertTrue(text(sender).contains("You cannot use this chat channel.")); + assertFalse(text(sender).contains("Switched admin")); + } + handler.onCommand(sender, command("channeltoggle"), "ct", new String[]{"STAFF"}); + assertFalse(ChatChannelPreferenceManager.get().isChannelVisible(sender, "admin")); + permissions.get(sender).add("staff.chat"); + assertEquals(List.of("admin"), handler.onTabComplete(sender, c, "channel", new String[]{"Ad"})); + handler.onCommand(sender, c, "channel", new String[]{"STAFF"}); + assertEquals("admin", ChatChannelPreferenceManager.get().getSwitchedChannelId(sender)); + assertTrue(text(sender).contains("Switched admin")); + } + + @Test void channelCompletionUsesEnabledLoadedAndPermittedTargetsWithoutChangingPreferences() { + var handler = new ChatChannelCommandHandler(); var c = command("channel"); var t = command("channeltoggle"); + channels.put("admin", channel("admin", "use-perm", "staff.chat")); + Cache.chatSwitchableChannels = List.of("rp", "ooc", "missing", "admin"); + Cache.chatToggleableChannels = Cache.chatSwitchableChannels; + assertEquals(List.of("rp", "ooc"), handler.onTabComplete(sender, c, "channel", new String[]{""})); + assertEquals(List.of("rp", "ooc", "admin"), handler.onTabComplete(sender, t, "channeltoggle", new String[]{""})); + assertEquals(List.of("ooc"), handler.onTabComplete(sender, c, "channel", new String[]{"Oo"})); + when(sender.hasPermission("staff.chat")).thenReturn(true); + assertEquals(List.of("admin"), handler.onTabComplete(sender, c, "channel", new String[]{"Ad"})); + Cache.chatChannelSwitcherEnabled = false; Cache.chatChannelTogglerEnabled = false; + assertEquals(List.of(), handler.onTabComplete(sender, c, "channel", new String[]{""})); + assertEquals(List.of(), handler.onTabComplete(sender, t, "channeltoggle", new String[]{""})); + assertEquals(List.of(), handler.onTabComplete(mock(CommandSender.class), c, "channel", new String[]{""})); + assertNull(ChatChannelPreferenceManager.get().getSwitchedChannelId(sender)); + } @Test void commandInterceptorDistinguishesCreationInputFromUnrelatedCommands(){var h=new ChatChannelCommandInterceptor();var invalid=mock(PlayerCommandPreprocessEvent.class);h.onPlayerCommand(invalid);var unknown=new PlayerCommandPreprocessEvent(sender,"/unknown x");h.onPlayerCommand(unknown);assertFalse(unknown.isCancelled());var usage=new PlayerCommandPreprocessEvent(sender,"/say");h.onPlayerCommand(usage);assertTrue(usage.isCancelled());assertTrue(text(sender).contains("Usage: /say"));CreationManager.activeCreators.put(sender,null);var blocked=new PlayerCommandPreprocessEvent(sender,"/say blocked");h.onPlayerCommand(blocked);creation.verify(()->CreationManager.sendChatBlockedDuringCreationHint(sender));assertTrue(events.isEmpty());creation.when(()->CreationManager.isChatInputStage(sender)).thenReturn(true);var allowed=new PlayerCommandPreprocessEvent(sender,"/say allowed");h.onPlayerCommand(allowed);assertTrue(allowed.isCancelled());assertEquals("allowed",events.getFirst().getMessage());assertTrue(events.getFirst().wasCommand());} @Test void commandExecutorSupportsConsoleUsageUnknownAndActualDelivery(){var h=new ChatChannelExecutor();var console=mock(CommandSender.class);assertTrue(h.onCommand(console,command("say"),"say",null));verify(console).sendMessage(contains("Players only"));assertTrue(h.onCommand(sender,command("other"),"unknown",new String[]{"x"}));assertTrue(h.onCommand(sender,command("say"),"say",null));assertTrue(text(sender).contains("Usage: /say"));assertTrue(h.onCommand(sender,command("say"),"say",new String[]{"hello","world"}));assertEquals("hello world",events.getFirst().getMessage());assertTrue(events.getFirst().wasCommand());} @Test void commandExecutorCannotBypassCreationRestrictions(){CreationManager.activeCreators.put(sender,null);new ChatChannelExecutor().onCommand(sender,command("say"),"say",new String[]{"blocked"});assertTrue(events.isEmpty(),"Bukkit dispatchCommand must enforce the same creation guard as preprocess events");creation.verify(()->CreationManager.sendChatBlockedDuringCreationHint(sender));} diff --git a/src/test/java/net/tfminecraft/rpcharacters/estate/EstateCommandTest.java b/src/test/java/net/tfminecraft/rpcharacters/estate/EstateCommandTest.java index d95811c3..0563ccc9 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/estate/EstateCommandTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/estate/EstateCommandTest.java @@ -5,6 +5,12 @@ import static org.mockito.Mockito.*; import java.util.List; +import java.util.Map; +import java.time.Instant; +import net.tfminecraft.tlibs.character.estate.EstateState; +import net.tfminecraft.rpcharacters.estate.EstateRecord.Part; +import net.tfminecraft.rpcharacters.estate.EstateRecord.Decision; +import net.tfminecraft.rpcharacters.estate.EstateRecord.Report; import java.util.Optional; import java.util.UUID; @@ -23,7 +29,8 @@ class EstateCommandTest { private CommandSender console; private final UUID current = UUID.randomUUID(); private Optional session = Optional.of(current); - private final EstateRecord record = new EstateRecord(UUID.randomUUID(), new CharacterRef(UUID.randomUUID(), UUID.randomUUID())); + private final EstateRecord record = new EstateRecord(UUID.fromString("d0000000-0000-0000-0000-000000000001"), + new CharacterRef(UUID.randomUUID(), UUID.randomUUID())); @BeforeEach void setUp() { @@ -35,6 +42,8 @@ void setUp() { console = mock(CommandSender.class); when(console.getName()).thenReturn("CONSOLE"); when(console.hasPermission(EstateCommand.PERMISSION)).thenReturn(true); + record.name = "Ilvaran Thorne"; + when(staff.records()).thenReturn(List.of(record)); when(staff.find(anyString())).thenReturn(Optional.empty()); for (String key : List.of("d", "Aria", "Ilvaran Thorne")) when(staff.find(key)).thenReturn(Optional.of(record)); } @@ -121,9 +130,136 @@ void staffSubcommandsReachTheirTools() { void staffTabCompletionOffersSubcommandsFiltersAndParts() { assertEquals(List.of("hold"), command.onTabComplete(console, null, "estate", new String[] {"ho"})); assertEquals(7, command.onTabComplete(console, null, "estate", new String[] {"list", ""}).size()); + record.state = EstateState.DECIDED; + record.decision = new Decision(Instant.EPOCH, 3, null, null, null, Part.FROZEN, Part.WAITING, Part.FROZEN, Part.SENDER); assertEquals(List.of("money", "property", "guildlocks", "all"), - command.onTabComplete(console, null, "estate", new String[] {"assign", "d", ""})); - assertEquals(List.of("--no-guild"), command.onTabComplete(console, null, "estate", new String[] {"decide", "d", ""})); - assertEquals(List.of(), command.onTabComplete(console, null, "estate", new String[] {"show", "d"})); + command.onTabComplete(console, null, "estate", new String[] {"assign", record.deathId.toString(), ""})); + record.state = EstateState.OPEN; + assertEquals(List.of("--no-guild"), command.onTabComplete(console, null, "estate", new String[] {"decide", record.deathId.toString(), ""})); + assertEquals(List.of(record.deathId.toString()), + command.onTabComplete(console, null, "estate", new String[] {"show", "d"})); + assertEquals(List.of(), command.onTabComplete(console, null, "estate", new String[] {"show", "g"})); } + private List tab(String... args) { + return command.onTabComplete(console, null, "estate", args); + } + + @Test void consoleCompletionExcludesCollectionWhileRetainingStaffTools() { + assertEquals(List.of("list", "show", "hold", "release", "decide", "assign", "retry", "waive", "revert"), tab("")); + assertEquals(List.of("show"), tab("Sh")); + assertEquals(List.of(), tab("Co")); + assertEquals(List.of(), tab("CoLlEcT", "")); + assertEquals(List.of(), tab("collect", "d")); + assertEquals(List.of(), tab("collect", "Ilvaran", "")); + assertEquals(List.of(), tab("collect", record.deathId.toString(), "")); + when(console.hasPermission(EstateCommand.PERMISSION)).thenReturn(false); + assertEquals(List.of(), tab("")); + assertEquals(List.of(), tab("Co")); + assertEquals(List.of(), tab("Sh")); + assertEquals(List.of(), tab("collect", "")); + verifyNoInteractions(staff, collections); + } + + @Test void staffPlayersCanCompleteCollectionEstateIdsAndNameSuffixes() { + when(player.hasPermission(EstateCommand.PERMISSION)).thenReturn(true); + assertTrue(command.onTabComplete(player, null, "estate", new String[]{""}).contains("collect")); + assertEquals(List.of("collect"), command.onTabComplete(player, null, "estate", new String[]{"Co"})); + assertEquals(List.of(record.deathId.toString(), "Ilvaran Thorne"), + command.onTabComplete(player, null, "estate", new String[]{"CoLlEcT", ""})); + assertEquals(List.of(record.deathId.toString()), + command.onTabComplete(player, null, "estate", new String[]{"collect", "D000"})); + assertEquals(List.of("Ilvaran Thorne"), + command.onTabComplete(player, null, "estate", new String[]{"collect", "iLv"})); + assertEquals(List.of("Ilvaran Thorne"), + command.onTabComplete(player, null, "estate", new String[]{"collect", "Ilvaran"})); + assertEquals(List.of("Thorne"), + command.onTabComplete(player, null, "estate", new String[]{"collect", "Ilvaran", ""})); + assertEquals(List.of("Thorne"), + command.onTabComplete(player, null, "estate", new String[]{"collect", "iLvArAn", "tH"})); + verify(staff, never()).find(anyString()); verifyNoInteractions(collections); + } + + @Test void rootsFiltersAndEveryArgumentRespectCaseAndCurrentTokenPrefixes() { + assertEquals(List.of(), tab()); assertEquals(List.of(), tab("unknown", "")); + assertEquals(List.of(), command.onTabComplete(player, null, "estate", new String[]{"x"})); + assertEquals(List.of("collect"), command.onTabComplete(player, null, "estate", new String[]{"Co"})); + assertEquals(List.of("frozen"), tab("LiSt", "Fr")); + assertEquals(List.of(), tab("list", "frozen", "")); + assertEquals(List.of(), tab("decide", "Ilvaran", "Thorne", "x")); + assertEquals(List.of("--no-guild"), tab("decide", "Ilvaran", "Thorne", "--n")); + assertEquals(List.of(), tab("decide", "Ilvaran", "Thorne", "--no-guild", "")); + assertEquals(List.of(), tab("show", "missing", "")); + } + + @Test void estateIdsAndMultiwordNameSuffixesAreUsableAtTheirActualBoundary() { + String id = record.deathId.toString(); + for (String verb : List.of("show", "hold", "release", "decide", "assign", "retry", "waive", "revert")) { + assertTrue(tab(verb, "").contains(id), verb); + assertEquals(List.of(id), tab(verb, id.substring(0, 8).toUpperCase(java.util.Locale.ROOT)), verb); + } + assertEquals(List.of("Ilvaran Thorne"), tab("assign", "ILv")); + assertEquals(List.of("Ilvaran Thorne"), tab("assign", "Ilvaran")); + assertEquals(List.of("Thorne"), tab("assign", "Ilvaran", "")); + assertEquals(List.of("Thorne"), tab("assign", "iLvArAn", "tH")); + assertEquals(List.of("Thorne"), tab("assign", "Ilvaran", "Thorne")); + assertEquals(List.of("--no-guild"), tab("decide", "Ilvaran", "Thorne", "")); + assertEquals(List.of("--no-guild"), tab("decide", record.testator.characterId().toString(), "")); + assertEquals(List.of(), tab("assign", "Ilvaran", "Thorne", "")); + verify(staff, never()).find(anyString()); verifyNoInteractions(collections); + } + + @Test void longestEstateNameWinsBeforePartsEvenWhenItsFirstWordIsAnEstate() { + var shortName = new EstateRecord(UUID.randomUUID(), new CharacterRef(UUID.randomUUID(), UUID.randomUUID())); + shortName.name = "Ilvaran"; + when(staff.records()).thenReturn(List.of(shortName, record)); + record.state = EstateState.DECIDED; + record.decision = new Decision(Instant.EPOCH, 3, null, null, null, Part.FROZEN, Part.WAITING, Part.SENDER, Part.SENDER); + assertEquals(List.of("Thorne"), tab("assign", "Ilvaran", "")); + assertEquals(List.of("property"), tab("assign", "Ilvaran", "Thorne", "Pr")); + assertEquals(List.of("money", "property", "all"), tab("assign", "Ilvaran", "Thorne", "")); + assertEquals(List.of("guild"), tab("assign", "Ilvaran", "Thorne", "MoNeY", "Gu")); + assertEquals(List.of(), tab("assign", "Ilvaran", "Thorne", "property", "")); + assertEquals(List.of(), tab("assign", "Ilvaran", "Thorne", "money", "guild", "")); + assertEquals(List.of(), tab("decide", "Ilvaran", "Thorne", "")); + assertEquals(List.of(), tab("show", "Ilvaran", "Thorne", "")); + when(staff.assign(record, "property", "Kira", "CONSOLE")).thenReturn("assigned"); + run(console, "assign", "Ilvaran", "Thorne", "property", "Kira"); + verify(staff).assign(record, "property", "Kira", "CONSOLE"); + } + + @Test void onlyAssignablePartsAndApplicableBlockedParticipantsAreSuggested() { + record.state = EstateState.DECIDED; + record.decision = new Decision(Instant.EPOCH, 3, null, null, null, Part.SENDER, Part.WAITING, Part.FROZEN, Part.SENDER); + record.participants.put("thievery", new Report("BLOCKED", 3, "blocked", Map.of(), List.of(), Instant.EPOCH)); + record.participants.put("old", new Report("BLOCKED", 2, "old", Map.of(), List.of(), Instant.EPOCH)); + record.participants.put("done", new Report("DONE", 3, null, Map.of(), List.of(), Instant.EPOCH)); + String id = record.deathId.toString(); + assertEquals(List.of("property", "guildlocks", "all"), tab("assign", id, "")); + assertEquals(List.of(), tab("assign", id, "money", "")); + assertEquals(List.of("old", "thievery"), tab("retry", id, "")); + assertEquals(List.of("thievery"), tab("retry", "Ilvaran", "Thorne", "Th")); + assertEquals(List.of("thievery"), tab("waive", id, "")); + assertEquals(List.of(), tab("waive", id, "thievery", "")); + record.decision = new Decision(Instant.EPOCH, 3, null, null, null, Part.SENDER, Part.SENDER, Part.SENDER, Part.SENDER); + assertEquals(List.of(), tab("assign", id, "")); + record.state = EstateState.REVERTED; + assertEquals(List.of(), tab("retry", id, "")); + assertEquals(List.of(), tab("waive", id, "")); + } + + @Test void realStaffCompletionUsesOnlyLoadedRecordsWithoutWorldOrPersistenceCalls() throws Exception { + EstateCoordinator coordinator = mock(EstateCoordinator.class); EstateBook book = mock(EstateBook.class); + EstateWorld world = mock(EstateWorld.class); + when(coordinator.book()).thenReturn(book); when(coordinator.world()).thenReturn(world); + when(book.all()).thenReturn(List.of(record)); + EstateStaff realStaff = new EstateStaff(coordinator, java.util.logging.Logger.getAnonymousLogger()); + command = new EstateCommand(realStaff, collections, p -> session); + assertTrue(tab("show", "").contains(record.deathId.toString())); + assertEquals(List.of("Thorne"), tab("assign", "Ilvaran", "Th")); + assertEquals(List.of("--no-guild"), tab("decide", "Ilvaran", "Thorne", "")); + assertEquals(List.of(), tab("decide", record.testator.characterId().toString(), "x")); + verifyNoInteractions(world, collections); + verify(book, never()).save(any()); verify(book, never()).log(anyString(), anyString()); + } + } diff --git a/src/test/java/net/tfminecraft/rpcharacters/permadeath/PermadeathAdminCommandsTest.java b/src/test/java/net/tfminecraft/rpcharacters/permadeath/PermadeathAdminCommandsTest.java index 66330bb0..14e61ecb 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/permadeath/PermadeathAdminCommandsTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/permadeath/PermadeathAdminCommandsTest.java @@ -33,4 +33,28 @@ class PermadeathAdminCommandsTest extends PermadeathRuntimeFixture { @Test void customArgumentOffsetsWorkForAnInjuryAndPermakill() throws Exception {pool(healing);assertTrue(PermadeathAdminCommands.handleInjure(sender,new String[]{"admin","injure","Aria"},2,"custom"));assertEquals(List.of(healing),character.getTraits());assertTrue(PermadeathAdminCommands.handlePermakill(sender,new String[]{"admin","kill","Aria"},2,"kill"));assertEquals(Status.DEAD,character.getStatus());assertTrue(reply().contains("Permanently killed Aria (Aria)"));assertEquals(PermakillCause.COMMAND,events.getFirst().getCause());} @Test void explicitPermakillUsesTheRequestedInactiveCharacter(){RPCharacter other=character("Bryn",false);data.addCharacter(other);kill("permakill","Aria",other.getId());assertEquals(Status.DEAD,other.getStatus());assertEquals(Status.ALIVE,character.getStatus());assertTrue(reply().contains("Permanently killed Bryn (Aria)"));assertTrue(tasks.isEmpty());} @Test void eventCancellationIsReportedAndLeavesTheCharacterAlive(){cancelEvents=true;kill("permakill","Aria");assertTrue(reply().contains("Permakill was cancelled"));assertEquals(Status.ALIVE,character.getStatus());assertTrue(character.isActive());} + @Test void completedMultiwordCharacterIdentifiersReachTheIntendedSameAccountCharacter() throws Exception { + pool(healing); + RPCharacter shorter = character("Bryn", false), intended = character("Bryn Stone", false); + data.addCharacter(shorter); data.addCharacter(intended); + when(sender.hasPermission(org.mockito.ArgumentMatchers.anyString())).thenReturn(true); + var command = mock(org.bukkit.command.Command.class); when(command.getName()).thenReturn("rpcharacter"); + var tabs = new net.tfminecraft.rpcharacters.utils.CommandTabCompleter(); + String slug = intended.getSlug(); + assertEquals(List.of(slug), tabs.onTabComplete(sender, command, "rpcharacter", + new String[]{"admin", "injure", "Aria", "bRyN_"})); + assertEquals(List.of(slug), tabs.onTabComplete(sender, command, "rpcharacter", + new String[]{"injure", "Aria", "bRyN_"})); + assertTrue(PermadeathAdminCommands.handleInjure(sender, new String[]{"admin", "injure", "Aria", slug}, 2, "usage")); + assertEquals(List.of(healing), intended.getTraits()); + assertTrue(shorter.getTraits().isEmpty()); assertTrue(character.getTraits().isEmpty()); + intended.setSlug(null); + assertEquals(List.of(intended.getId()), tabs.onTabComplete(sender, command, "rpcharacter", + new String[]{"admin", "permakill", "Aria", intended.getId().substring(0, 8)})); + assertTrue(PermadeathAdminCommands.handlePermakill(sender, + new String[]{"admin", "permakill", "Aria", intended.getId()}, 2, "permakill")); + assertEquals(Status.DEAD, intended.getStatus()); assertEquals(Status.ALIVE, shorter.getStatus()); + assertEquals(Status.ALIVE, character.getStatus()); + } + } diff --git a/src/test/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleterTest.java b/src/test/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleterTest.java index 5c9e1181..7160f00d 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleterTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleterTest.java @@ -8,6 +8,7 @@ import net.tfminecraft.rpcharacters.*; import net.tfminecraft.rpcharacters.command.CharCommand; import net.tfminecraft.rpcharacters.creation.*; +import net.tfminecraft.rpcharacters.enums.Status; import net.tfminecraft.rpcharacters.loaders.*; import net.tfminecraft.rpcharacters.managers.PlayerManager; import net.tfminecraft.rpcharacters.objects.*; @@ -29,6 +30,7 @@ class CommandTabCompleterTest { PlayerMock player, iris; Plugin plugin; RuntimeTestState state; + net.Indyuce.mmocore.MMOCore previousMmo; Command command; CommandTabCompleter tabs; PlayerData data; @@ -40,10 +42,13 @@ class CommandTabCompleterTest { MockedStatic boundary(Class type) { var mocked = mockStatic(type); mocks.add(mocked); return mocked; } @BeforeEach void setup() { + previousMmo = net.Indyuce.mmocore.MMOCore.plugin; server = MockBukkit.mock(); state = new RuntimeTestState(StageLoader.class, TraitLoader.class); plugin = MockBukkit.createMockPlugin(); player = server.addPlayer("Alex"); iris = server.addPlayer("Iris"); command = mock(Command.class); when(command.getName()).thenReturn("rpcharacter"); tabs = new CommandTabCompleter(); Cache.personaGenders = List.of("Female", "Male", "Other"); + grant(Cache.personaSetPermission, true); grant(Cache.profilePermission, true); + grant("rpchar.injure", false); for (String permission : List.of(Permissions.Permission_Admin, Cache.personaOverridePermission, Cache.personaTempaliasPermission, Cache.personaCharacterHiddenPermission)) grant(permission, false); data = mock(PlayerData.class); List characters = List.of(character("hero", "Hero"), character(null, null)); @@ -53,18 +58,20 @@ class CommandTabCompleterTest { boundary(SummaryEditSupport.class).when(SummaryEditSupport::getEditEntryKeys).thenReturn(List.of("name", "race")); boundary(TutorialLoader.class).when(TutorialLoader::getIds).thenReturn(List.of("permadeath-zone", "strikes")); groups = boundary(PermissionGroupService.class); groups.when(() -> PermissionGroupService.getNameColourStops(player)).thenReturn(4); + groups.when(() -> PermissionGroupService.canUseNameColour(player)).thenReturn(true); party = boundary(PartyCommand.class); wardrobe = boundary(WardrobeCommand.class); StageLoader.oList = new ArrayList<>(); TraitLoader.oList = new ArrayList<>(); } @AfterEach void restore() { for (int i = mocks.size() - 1; i >= 0; i--) mocks.get(i).close(); - state.close(); MockBukkit.unmock(); + net.Indyuce.mmocore.MMOCore.plugin = previousMmo; state.close(); MockBukkit.unmock(); } void grant(String permission, boolean value) { player.addAttachment(plugin, permission, value); } void admin() { grant(Permissions.Permission_Admin, true); } List complete(String... args) { return tabs.onTabComplete(player, command, "rpcharacter", args); } RPCharacter character(String slug, String name) { - RPCharacter character = mock(RPCharacter.class); when(character.getSlug()).thenReturn(slug); when(character.getName()).thenReturn(name); return character; + RPCharacter character = mock(RPCharacter.class); when(character.getSlug()).thenReturn(slug); when(character.getName()).thenReturn(name); + when(character.getStatus()).thenReturn(Status.ALIVE); return character; } @Test void selectIsSuggestedOnlyWhileCharacterBodiesAreEnabled() { @@ -116,7 +123,7 @@ RPCharacter character(String slug, String name) { } @Test void adminSuggestionsDisappearWithoutPermission() { - for (String verb : List.of("catalog", "pending", "wipe", "stage", "seteighteen", "resetkit", "reclaimkit", "admin", "clearclues", "placeclue", "adminmode", "discordgate")) + for (String verb : List.of("catalog", "pending", "wipe", "stage", "seteighteen", "resetkit", "reclaimkit", "admin", "clearclues", "placeclue", "adminmode", "discordgate", "setclass", "skipcooldown", "addtrait", "removetrait", "menu", "injure", "permakill")) assertEquals(List.of(), complete(verb, ""), verb); assertEquals(List.of(), complete("admin", "strikes", "")); assertEquals(List.of(), complete("admin", "resetclasses", "")); grant("rpchar.class.reset", true); assertEquals(List.of("resetclasses"), complete("admin", "")); @@ -133,7 +140,7 @@ RPCharacter character(String slug, String name) { assertEquals(List.of("intro"), complete("stage", "preview", "")); assertEquals(List.of("confirm"), complete("wipe", "website", "")); assertEquals(List.of("on", "off"), complete("discordgate", "Iris", "")); grant(Cache.personaCharacterHiddenPermission, true); assertEquals(List.of("clear"), complete("sethidden", "hero", "")); - assertEquals(List.of("className"), complete("setclass", "Iris", "")); assertEquals(List.of("true", "false"), complete("seteighteen", "Iris", "")); + assertEquals(List.of("true", "false"), complete("seteighteen", "Iris", "")); List characters = Arrays.asList(null, character(null, null), character(" ", null), character("hero", "Hero")); when(data.getCharacters()).thenReturn(characters); for (String verb : List.of("resetkit", "reclaimkit")) assertEquals(List.of("hero"), complete(verb, "Iris", "")); @@ -141,15 +148,18 @@ RPCharacter character(String slug, String name) { players.when(() -> PlayerManager.get(iris)).thenReturn(null); assertEquals(List.of(), complete("reclaimkit", "Iris", "")); players.when(() -> PlayerManager.get(iris)).thenReturn(data); Trait trait = mock(Trait.class); when(trait.getId()).thenReturn("brave"); TraitLoader.oList.add(trait); - for (String verb : List.of("addtrait", "removetrait")) assertEquals(List.of("brave"), complete(verb, "Iris", "")); + RPCharacter active = character("hero", "Hero"); when(active.getTraits()).thenReturn(List.of()); + when(data.hasActiveCharacter()).thenReturn(true); when(data.getActiveCharacter()).thenReturn(active); + assertEquals(List.of("brave"), complete("addtrait", "Iris", "")); + assertEquals(List.of(), complete("removetrait", "Iris", "")); for (String verb : List.of("injure", "permakill")) assertEquals(List.of("Iris"), complete("admin", verb, "I")); assertEquals(List.of(), complete("unknown", "x", "")); } @Test void injuryAndKitCompletionAtLaterPositionsUsesTargetCharacters() { admin(); - assertEquals(List.of("hero", "Hero", "permanent"), complete("injure", "Iris", "")); - assertEquals(List.of("hero", "Hero", "permanent"), complete("admin", "injure", "Iris", "")); + assertEquals(List.of("hero", "permanent"), complete("injure", "Iris", "")); + assertEquals(List.of("hero", "permanent"), complete("admin", "injure", "Iris", "")); assertEquals(List.of("permanent"), complete("injure", "offline", "")); assertEquals(List.of("permanent"), complete("admin", "injure", "offline", "")); players.when(() -> PlayerManager.get(iris)).thenReturn(null); assertEquals(List.of("permanent"), complete("injure", "Iris", "")); @@ -160,6 +170,101 @@ RPCharacter character(String slug, String name) { assertEquals(List.of(), complete("unknown", "a", "b", "c", "d", "e")); } + @Test void setclassUsesRegisteredIdsAndHandlesAnUnavailableRegistry() throws Exception { + admin(); net.Indyuce.mmocore.MMOCore.plugin = null; + assertEquals(List.of(), complete("setclass", "Iris", "")); + var core = mock(net.Indyuce.mmocore.MMOCore.class); net.Indyuce.mmocore.MMOCore.plugin = core; + assertEquals(List.of(), complete("setclass", "Iris", "")); + var classes = mock(net.Indyuce.mmocore.manager.ClassManager.class); + var field = net.Indyuce.mmocore.MMOCore.class.getField("classManager"); field.setAccessible(true); field.set(core, classes); + var warrior = mock(net.Indyuce.mmocore.api.player.profess.PlayerClass.class); + var mage = mock(net.Indyuce.mmocore.api.player.profess.PlayerClass.class); + when(warrior.getId()).thenReturn("WARRIOR"); when(mage.getId()).thenReturn("MAGE"); + when(classes.getAll()).thenReturn(List.of(warrior, mage)); + assertEquals(List.of("WARRIOR", "MAGE"), complete("setclass", "Iris", "")); + assertEquals(List.of("WARRIOR"), complete("setclass", "Iris", "wA")); + assertEquals(List.of(), complete("setclass", "Iris", "className")); + assertEquals(List.of(), complete("setclass", "Iris", "WARRIOR", "")); + grant(Permissions.Permission_Admin, false); + assertEquals(List.of(), complete("setclass", "Iris", "")); + } + + @Test void traitSuggestionsReflectTheSelectedActiveCharactersAssignments() { + admin(); Trait brave = mock(Trait.class), shy = mock(Trait.class), retired = mock(Trait.class); + when(brave.getId()).thenReturn("brave"); when(shy.getId()).thenReturn("shy"); when(retired.getId()).thenReturn("retired"); + TraitLoader.oList.addAll(List.of(brave, shy)); + RPCharacter active = character("hero", "Hero"); when(active.getTraits()).thenReturn(List.of(brave, retired)); + when(data.hasActiveCharacter()).thenReturn(true); when(data.getActiveCharacter()).thenReturn(active); + assertEquals(List.of("shy"), complete("addtrait", "Iris", "")); + assertEquals(List.of("brave"), complete("removetrait", "Iris", "Br")); + assertEquals(List.of("brave", "retired"), complete("removetrait", "Iris", "")); + assertEquals(List.of(), complete("addtrait", "offline", "")); + when(data.hasActiveCharacter()).thenReturn(false); + assertEquals(List.of(), complete("removetrait", "Iris", "")); + players.when(() -> PlayerManager.get(iris)).thenReturn(null); + assertEquals(List.of(), complete("addtrait", "Iris", "")); + } + + @Test void injuryAndPermakillOfferSafeIdentifiersWithoutPermakillFlags() { + admin(); RPCharacter spaced = character("ilvaran_thorne", "Ilvaran Thorne"); + RPCharacter reserved = character("permanent", "Permanent"), noSlug = character(null, "No Slug"); + RPCharacter badSlug = character("two words", "Two Words"), blankSlug = character(" ", "Blank"); + when(reserved.getId()).thenReturn("id-reserved"); when(noSlug.getId()).thenReturn("id-no-slug"); + when(badSlug.getId()).thenReturn("id-two-words"); when(blankSlug.getId()).thenReturn("id-blank"); + when(data.getCharacters()).thenReturn(List.of(spaced, reserved, noSlug, badSlug, blankSlug)); + assertEquals(List.of("ilvaran_thorne"), complete("admin", "injure", "Iris", "IlV")); + assertEquals(List.of("ilvaran_thorne", "id-reserved", "id-no-slug", "id-two-words", "id-blank"), + complete("admin", "permakill", "Iris", "")); + assertEquals(List.of("ilvaran_thorne"), complete("permakill", "Iris", "IL")); + assertEquals(List.of("Iris"), complete("permakill", "I")); + assertEquals(List.of(), complete("admin", "permakill", "Iris", "ilvaran_thorne", "")); + assertEquals(List.of(), complete("admin", "permakill", "offline", "")); + grant(Permissions.Permission_Admin, false); grant("rpchar.injure", true); + assertEquals(List.of("Iris"), complete("injure", "I")); + assertEquals(List.of(), complete("injure", "Iris", "")); + assertEquals(List.of(), complete("admin", "permakill", "Iris", "")); + } + + @Test void injuryAndPermakillExcludeNonlivingCharactersButKeepLivingSlugsAndIds() { + admin(); RPCharacter living = character("living_slug", "Living Name"), livingId = character(null, "Living Without Slug"); + RPCharacter dead = character("fallen_slug", "Fallen Name"), deadId = character(null, "Fallen Without Slug"); + RPCharacter missing = character("missing_slug", "Missing Name"); + when(livingId.getId()).thenReturn("id-living"); when(deadId.getId()).thenReturn("id-dead"); + when(dead.getStatus()).thenReturn(Status.DEAD); when(deadId.getStatus()).thenReturn(Status.DEAD); + when(missing.getStatus()).thenReturn(Status.MISSING); + when(data.getCharacters()).thenReturn(List.of(living, dead, livingId, deadId, missing)); + for (String verb : List.of("injure", "permakill")) { + List expected = verb.equals("injure") ? List.of("living_slug", "id-living", "permanent") + : List.of("living_slug", "id-living"); + assertEquals(expected, complete(verb, "Iris", ""), verb); + assertEquals(expected, complete("admin", verb, "Iris", ""), verb); + assertEquals(List.of("living_slug"), complete(verb, "Iris", "LiV"), verb); + assertEquals(List.of("living_slug"), complete("admin", verb, "Iris", "LiV"), verb); + assertEquals(List.of("id-living"), complete(verb, "Iris", "ID-LI"), verb); + assertEquals(List.of("id-living"), complete("admin", verb, "Iris", "ID-LI"), verb); + for (String prefix : List.of("Fa", "ID-DEAD", "Mi")) { + assertEquals(List.of(), complete(verb, "Iris", prefix), verb + " " + prefix); + assertEquals(List.of(), complete("admin", verb, "Iris", prefix), verb + " " + prefix); + } + } + } + + @Test void personaRootsAndNestedArgumentsUseTheExecutionPermissions() { + grant(Cache.personaSetPermission, false); grant(Cache.profilePermission, false); + groups.when(() -> PermissionGroupService.canUseNameColour(player)).thenReturn(false); + assertEquals(List.of(), complete("over")); + for (String verb : List.of("alias", "gender", "description", "birthday", "profile", "namecolour")) { + assertFalse(complete("").contains(verb), verb); + assertEquals(List.of(), complete(verb, ""), verb); + } + assertTrue(complete("").containsAll(List.of("menu", "mail"))); + grant(Cache.personaSetPermission, true); grant(Cache.profilePermission, true); + groups.when(() -> PermissionGroupService.canUseNameColour(player)).thenReturn(true); + assertTrue(complete("").containsAll(List.of("alias", "gender", "description", "birthday", "profile", "namecolour"))); + grant(Cache.personaOverridePermission, true); assertEquals(List.of("override"), complete("Ov")); + assertEquals(List.of(), tabs.onTabComplete(server.getConsoleSender(), command, "rpcharacter", new String[]{"profile", ""})); + } + @Test void adminStrikesAndTutorialCompletionsTrackActionArity() { admin(); assertEquals(List.of("reset"), complete("admin", "tutorial", "")); assertEquals(List.of("view", "add", "remove", "set", "startsession", "endsession"), complete("admin", "strikes", "")); diff --git a/src/test/java/net/tfminecraft/rpcharacters/wardrobe/WardrobeGuiTest.java b/src/test/java/net/tfminecraft/rpcharacters/wardrobe/WardrobeGuiTest.java index cc594d84..acb179d1 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/wardrobe/WardrobeGuiTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/wardrobe/WardrobeGuiTest.java @@ -79,7 +79,10 @@ class WardrobeGuiTest extends WardrobeFixture { } @Test void tabCompletionUsesOnlyUsableSlotsAndDeduplicatesNames() { - assertTrue(WardrobeCommand.tabComplete(player,new String[]{"wardrobe"}).isEmpty());assertEquals(List.of("base","extra_1","extra_2"),WardrobeCommand.tabComplete(player,new String[]{"wardrobe",""}));var slots=new LinkedHashMap();slots.put("base",slot("base",true,true,"Base"));slots.put("extra_1",slot("extra_1",true,true,"Summer Coat"));slots.put("extra_2",slot("extra_2",true,true,"Summer Coat"));cache(new WardrobeSnapshot("alpha",null,3,slots));assertEquals(List.of("base","extra_1","Summer_Coat","extra_2"),WardrobeCommand.tabComplete(player,new String[]{"wardrobe",""}));assertEquals(List.of("Summer_Coat"),WardrobeCommand.tabComplete(player,new String[]{"wardrobe","SUM"}));slots.put("extra_2",slot("extra_2",false,false,null));assertFalse(WardrobeCommand.tabComplete(player,new String[]{"wardrobe",""}).contains("extra_2")); + assertTrue(WardrobeCommand.tabComplete(player,new String[]{"wardrobe"}).isEmpty());assertEquals(List.of("base","extra_1","extra_2"),WardrobeCommand.tabComplete(player,new String[]{"wardrobe",""}));var slots=new LinkedHashMap();slots.put("base",slot("base",true,true,"Base"));slots.put("extra_1",slot("extra_1",true,true,"Summer Coat"));slots.put("extra_2",slot("extra_2",true,true,"Summer Coat"));cache(new WardrobeSnapshot("alpha",null,3,slots));assertEquals(List.of("base","extra_1","extra_2"),WardrobeCommand.tabComplete(player,new String[]{"wardrobe",""}));assertEquals(List.of(),WardrobeCommand.tabComplete(player,new String[]{"wardrobe","SUM"}));slots.put("extra_2",slot("extra_2",false,false,null));assertFalse(WardrobeCommand.tabComplete(player,new String[]{"wardrobe",""}).contains("extra_2"));assertEquals(List.of("Summer_Coat"),WardrobeCommand.tabComplete(player,new String[]{"wardrobe","SUM"})); + selected.clear();WardrobeCommand.handle(player,"rp",new String[]{"wardrobe","Summer_Coat"});assertEquals(List.of("extra_1"),selected); + slots.put("base",slot("base",true,true,"Summer__Coat"));assertEquals(List.of(),WardrobeCommand.tabComplete(player,new String[]{"wardrobe","SUM"})); + slots.put("base",slot("base",true,true,"skin2"));assertFalse(WardrobeCommand.tabComplete(player,new String[]{"wardrobe",""}).contains("skin2")); } @Test void pendingLoadingDoesNotOpenAfterTheActiveCharacterChanges() {