From 656e993c9de71a5cb9d6cb4c9c0700b2d37f833c Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 18 Oct 2026 07:55:44 +0200 Subject: [PATCH 1/4] feat: prison characters switch without the cooldown Staff mark an imprisoned character with /rpcharacter admin prison [character] [on|off] (rpchar.prison or rpcharacters.admin). Its player can switch into and back out of it at any time; the switch cooldown still holds between the account's other characters. The cooldown now counts from the last unmarked character the player entered (saved as last-free-character), so a prison character cannot carry a player from one free character to another. The selection card shows "In prison: no switch cooldown" and an open selection is redrawn when the mark changes. Co-Authored-By: Claude Opus 5.5 --- .../rpcharacters/body/CharacterBodies.java | 19 +-- .../body/CharacterSelectView.java | 1 + .../rpcharacters/database/Database.java | 15 ++ .../rpcharacters/managers/CommandManager.java | 9 +- .../rpcharacters/objects/PlayerData.java | 10 ++ .../rpcharacters/objects/RPCharacter.java | 10 ++ .../permadeath/PermadeathAdminCommands.java | 3 +- .../rpcharacters/prison/PrisonSwitch.java | 136 ++++++++++++++++ .../utils/CommandTabCompleter.java | 22 +++ src/main/resources/plugin.yml | 3 + .../body/CharacterBodiesTest.java | 16 ++ .../body/CharacterSelectViewTest.java | 7 + .../database/DatabaseLoadTest.java | 8 +- .../database/DatabasePersistenceTest.java | 10 +- .../managers/CommandManagerTest.java | 3 + .../rpcharacters/prison/PrisonSwitchTest.java | 150 ++++++++++++++++++ .../utils/CommandTabCompleterTest.java | 16 +- 17 files changed, 412 insertions(+), 26 deletions(-) create mode 100644 src/main/java/net/tfminecraft/rpcharacters/prison/PrisonSwitch.java create mode 100644 src/test/java/net/tfminecraft/rpcharacters/prison/PrisonSwitchTest.java diff --git a/src/main/java/net/tfminecraft/rpcharacters/body/CharacterBodies.java b/src/main/java/net/tfminecraft/rpcharacters/body/CharacterBodies.java index 7939b38f..3ac1d4cb 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/body/CharacterBodies.java +++ b/src/main/java/net/tfminecraft/rpcharacters/body/CharacterBodies.java @@ -30,6 +30,7 @@ import net.tfminecraft.rpcharacters.objects.PlayerData; import net.tfminecraft.rpcharacters.objects.RPCharacter; import net.tfminecraft.rpcharacters.persona.PermissionGroupService; +import net.tfminecraft.rpcharacters.prison.PrisonSwitch; import net.tfminecraft.rpcharacters.progress.CharacterProgressService; import net.tfminecraft.rpcharacters.professions.ProfessionIntegrator; import net.tfminecraft.rpcharacters.pvp.ArmourDonning; @@ -474,15 +475,13 @@ public String blockReason(PlayerData account, RPCharacter character) { BodyTag currentTag; try { currentTag = BodyTag.read(player); } catch (IOException error) { return "Your saved body needs recovery. Please contact staff."; } + // Prison-marked characters switch freely; the cooldown counts from the last unmarked character. if (currentTag instanceof BodyTag.Room tag && tag.lastCharacterId() != null - && !tag.lastCharacterId().toString().equals(character.getId())) { - RPCharacter previous = account.getCharacterById(tag.lastCharacterId().toString()); - if (previous != null && previous.getStatus() == Status.ALIVE - && !player.hasPermission("rpcharacters.no_cooldown") - && PermissionGroupService.hasCharacterSwitchCooldown(player, account)) { - long minutes = PermissionGroupService.getRemainingCooldownMinutes(player, account); - return "You can switch in " + (minutes / 1440) + "d " + (minutes / 60 % 24) + "h " + (minutes % 60) + "m. You can resume your current character."; - } + && PrisonSwitch.cooldownApplies(account, tag.lastCharacterId().toString(), character) + && !player.hasPermission("rpcharacters.no_cooldown") + && PermissionGroupService.hasCharacterSwitchCooldown(player, account)) { + long minutes = PermissionGroupService.getRemainingCooldownMinutes(player, account); + return "You can switch in " + (minutes / 1440) + "d " + (minutes / 60 % 24) + "h " + (minutes % 60) + "m. You can resume your current character."; } return null; } @@ -549,9 +548,7 @@ private void activate(PlayerData account, RPCharacter character, BodyRecord body MmoInventorySlots.refresh(player); progress.restoreResources(account, character, resources); body.body().applyHealth(player); - if (previous.lastCharacterId() != null && !previous.lastCharacterId().equals(body.characterId())) { - account.recordCharacterSwitch(); - } + PrisonSwitch.entered(account, previous.lastCharacterId() == null ? null : previous.lastCharacterId().toString(), character); BodyTag.live(player, body.characterId(), body.rev()); live.put(owner, new BodyTag.Live(body.characterId(), body.rev())); selecting.remove(owner); diff --git a/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java b/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java index c67620fa..017e238a 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java +++ b/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java @@ -596,6 +596,7 @@ static Component characterCard(RPCharacter character, String blockReason) { .append(Component.text(name(character), PALE, TextDecoration.BOLD)); line(card, joined(race(character), className(character), age(character)), TEAL); line(card, joined(guild(character), lastPlayed(character)), MUTED); + if (character.isPrisonSwitch()) line(card, "In prison: no switch cooldown", TEAL); if (blockReason != null) line(card, blockReason, WARN); return card.build(); } diff --git a/src/main/java/net/tfminecraft/rpcharacters/database/Database.java b/src/main/java/net/tfminecraft/rpcharacters/database/Database.java index 6cff6017..a618443c 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/database/Database.java +++ b/src/main/java/net/tfminecraft/rpcharacters/database/Database.java @@ -117,6 +117,9 @@ public PlayerData loadPlayer(Player p) { if (json.containsKey("account-attribute-points-total")) { pd.setAccountAttributePointsTotal(((Number) json.get("account-attribute-points-total")).intValue()); } + if (json.get("last-free-character") instanceof String lastFree) { + pd.setLastFreeCharacterId(lastFree); + } loadAccountProfessionPoints(pd, json); loadInvestigationPoints(pd, json); PlayerTutorialFields.load(pd, json); @@ -224,6 +227,9 @@ public PlayerData loadSavedAccount(SavedAccount saved) { if (json.containsKey("account-attribute-points-total")) { pd.setAccountAttributePointsTotal(((Number) json.get("account-attribute-points-total")).intValue()); } + if (json.get("last-free-character") instanceof String lastFree) { + pd.setLastFreeCharacterId(lastFree); + } loadAccountProfessionPoints(pd, json); loadInvestigationPoints(pd, json); PlayerTutorialFields.load(pd, json); @@ -448,6 +454,9 @@ public boolean trySavePlayer(PlayerData pd) { if (pd.getLastCharacterSwitchAtMs() != null) { defaults.put("last-character-switch-ms", pd.getLastCharacterSwitchAtMs()); } + if (pd.getLastFreeCharacterId() != null) { + defaults.put("last-free-character", pd.getLastFreeCharacterId()); + } int i = 0; JSONArray stageArray = new JSONArray(); while(i < pd.getCompletedStages().size()) { @@ -849,6 +858,9 @@ private void loadPersonaFields(RPCharacter character, JSONObject characterJson) if (characterJson.containsKey("hidden")) { character.setHidden(Boolean.parseBoolean(characterJson.get("hidden").toString())); } + if (characterJson.containsKey("prison-switch")) { + character.setPrisonSwitch(Boolean.parseBoolean(characterJson.get("prison-switch").toString())); + } if (characterJson.containsKey("kit-statuses")) { Object raw = characterJson.get("kit-statuses"); if (raw instanceof JSONObject statuses) { @@ -951,6 +963,9 @@ private void savePersonaFields(HashMap defaults, RPCharacter cha if (character.isHidden()) { defaults.put("hidden", "true"); } + if (character.isPrisonSwitch()) { + defaults.put("prison-switch", "true"); + } if (!character.getKitStatuses().isEmpty()) { JSONObject statuses = new JSONObject(); for (var entry : character.getKitStatuses().entrySet()) { diff --git a/src/main/java/net/tfminecraft/rpcharacters/managers/CommandManager.java b/src/main/java/net/tfminecraft/rpcharacters/managers/CommandManager.java index 9d5f7166..f705015b 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/managers/CommandManager.java +++ b/src/main/java/net/tfminecraft/rpcharacters/managers/CommandManager.java @@ -40,6 +40,7 @@ import net.tfminecraft.rpcharacters.identity.TempAliasService; import net.tfminecraft.rpcharacters.persona.CharacterSlotService; import net.tfminecraft.rpcharacters.evilrp.EvilRpCommands; +import net.tfminecraft.rpcharacters.prison.PrisonSwitch; import net.tfminecraft.rpcharacters.pvp.StrikeChoice; import net.tfminecraft.rpcharacters.party.PartyCommand; import net.tfminecraft.rpcharacters.tutorial.TutorialCommands; @@ -82,6 +83,10 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String && args[1].equalsIgnoreCase(ClassPickReset.SUBCOMMAND)) { return ClassPickReset.handle(sender, args, 2); } + if (args.length >= 2 && args[0].equalsIgnoreCase("admin") + && args[1].equalsIgnoreCase(PrisonSwitch.SUBCOMMAND)) { + return PrisonSwitch.handle(sender, args, 2); + } if (args.length >= 1 && args[0].equalsIgnoreCase("admin")) { return handleAdmin(sender, args); } @@ -724,7 +729,7 @@ private static boolean handleAdmin(CommandSender sender, String[] args) { return true; } if (args.length < 2) { - RPTexts.send(sender, RPTexts.ERROR + "Usage: /rpcharacter admin ..."); + RPTexts.send(sender, RPTexts.ERROR + "Usage: /rpcharacter admin ..."); return true; } if (args[1].equalsIgnoreCase("injure")) { @@ -743,7 +748,7 @@ private static boolean handleAdmin(CommandSender sender, String[] args) { if (args[1].equalsIgnoreCase(net.tfminecraft.rpcharacters.mmocore.CarriedExperience.SUBCOMMAND)) { return net.tfminecraft.rpcharacters.mmocore.CarriedExperience.handle(sender, args, 2); } - RPTexts.send(sender, RPTexts.ERROR + "Usage: /rpcharacter admin ..."); + RPTexts.send(sender, RPTexts.ERROR + "Usage: /rpcharacter admin ..."); return true; } diff --git a/src/main/java/net/tfminecraft/rpcharacters/objects/PlayerData.java b/src/main/java/net/tfminecraft/rpcharacters/objects/PlayerData.java index e65e2ec5..b35241bd 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/objects/PlayerData.java +++ b/src/main/java/net/tfminecraft/rpcharacters/objects/PlayerData.java @@ -20,6 +20,8 @@ public class PlayerData { private Player p; private final UUID uniqueId; private Long lastCharacterSwitchAtMs; + /** Last character entered that was not marked for prison; the switch cooldown counts from it. */ + private String lastFreeCharacterId; private boolean eighteen; private List characters = new ArrayList<>(); @@ -231,6 +233,14 @@ public void clearCharacterSwitchCooldown() { lastCharacterSwitchAtMs = null; } + public String getLastFreeCharacterId() { + return lastFreeCharacterId; + } + + public void setLastFreeCharacterId(String lastFreeCharacterId) { + this.lastFreeCharacterId = lastFreeCharacterId; + } + public Map getLastKitClaimAtMsMap() { return lastKitClaimAtMs; } diff --git a/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java b/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java index ac24dbce..3bffa060 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java +++ b/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java @@ -70,6 +70,8 @@ public class RPCharacter { private String alias; private String slug; private boolean hidden; + /** Staff mark for prison roleplay: switching into or out of this character skips the switch cooldown. */ + private boolean prisonSwitch; private boolean mailListed = true; /** Per grant-kit id. Empty/missing kit = legacy never claim for that kit. */ private final Map kitStatuses = @@ -689,6 +691,14 @@ public void setHidden(boolean hidden) { this.hidden = hidden; } + public boolean isPrisonSwitch() { + return prisonSwitch; + } + + public void setPrisonSwitch(boolean prisonSwitch) { + this.prisonSwitch = prisonSwitch; + } + public Map getKitStatuses() { return kitStatuses; } diff --git a/src/main/java/net/tfminecraft/rpcharacters/permadeath/PermadeathAdminCommands.java b/src/main/java/net/tfminecraft/rpcharacters/permadeath/PermadeathAdminCommands.java index 0a5ec084..e400438c 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/permadeath/PermadeathAdminCommands.java +++ b/src/main/java/net/tfminecraft/rpcharacters/permadeath/PermadeathAdminCommands.java @@ -151,7 +151,8 @@ private static ResolvedTarget resolveTarget(CommandSender sender, String[] args, return new ResolvedTarget(player, character); } - private static RPCharacter findCharacter(PlayerData pd, String query) { + /** By slug, then id, then name (ignoring case). */ + public static RPCharacter findCharacter(PlayerData pd, String query) { RPCharacter bySlug = pd.getCharacterBySlug(query); if (bySlug != null) { return bySlug; diff --git a/src/main/java/net/tfminecraft/rpcharacters/prison/PrisonSwitch.java b/src/main/java/net/tfminecraft/rpcharacters/prison/PrisonSwitch.java new file mode 100644 index 00000000..7031aec8 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/prison/PrisonSwitch.java @@ -0,0 +1,136 @@ +package net.tfminecraft.rpcharacters.prison; + +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import net.tfminecraft.rpcharacters.Permissions; +import net.tfminecraft.rpcharacters.RPCharacters; +import net.tfminecraft.rpcharacters.enums.Status; +import net.tfminecraft.rpcharacters.managers.PlayerManager; +import net.tfminecraft.rpcharacters.objects.PlayerData; +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import net.tfminecraft.rpcharacters.permadeath.PermadeathAdminCommands; +import net.tfminecraft.rpcharacters.utils.RPTexts; + +/** + * Prison roleplay: staff mark an imprisoned character with {@code /rpcharacter admin prison}, and its player + * can switch into it and back out again without waiting for the character switch cooldown. The cooldown + * still holds between the account's other characters. It counts from the last unmarked character the + * player entered, so a marked character cannot carry a player from one free character to another. + */ +public final class PrisonSwitch { + + public static final String SUBCOMMAND = "prison"; + public static final String PERMISSION = "rpchar.prison"; + static final String USAGE = "/rpcharacter admin " + SUBCOMMAND + " [character] [on|off]"; + + private static final Logger LOG = Logger.getLogger("RPCharacters"); + + private PrisonSwitch() {} + + /** Id of the character the switch cooldown counts from, or null when no cooldown can apply. */ + static String cooldownAnchor(PlayerData account, String lastId) { + String anchor = account.getLastFreeCharacterId() != null ? account.getLastFreeCharacterId() : lastId; + if (anchor == null) { + return null; + } + RPCharacter from = account.getCharacterById(anchor); + return from != null && from.isPrisonSwitch() ? null : anchor; + } + + /** + * Whether a running switch cooldown holds a move into {@code target} from {@code lastId}, the character + * the player last played: only a move away from a living unmarked character into another unmarked one. + */ + public static boolean cooldownApplies(PlayerData account, String lastId, RPCharacter target) { + if (target.isPrisonSwitch() || target.getId().equalsIgnoreCase(lastId)) { + return false; + } + String anchor = cooldownAnchor(account, lastId); + if (anchor == null || anchor.equalsIgnoreCase(target.getId())) { + return false; + } + RPCharacter from = account.getCharacterById(anchor); + return from != null && from.getStatus() == Status.ALIVE; + } + + /** Records an entered character: only a move between unmarked characters starts the cooldown. */ + public static void entered(PlayerData account, String lastId, RPCharacter target) { + String anchor = cooldownAnchor(account, lastId); + if (target.isPrisonSwitch() || target.getId().equalsIgnoreCase(lastId)) { + if (account.getLastFreeCharacterId() == null) { + account.setLastFreeCharacterId(anchor); + } + return; + } + if (lastId != null && anchor != null && !anchor.equalsIgnoreCase(target.getId())) { + account.recordCharacterSwitch(); + } + account.setLastFreeCharacterId(target.getId()); + } + + public static boolean canUse(CommandSender sender) { + return sender.hasPermission(PERMISSION) || Permissions.isAdmin(sender); + } + + /** {@code /rpcharacter admin prison [character] [on|off]}; without on/off the mark flips. */ + public static boolean handle(CommandSender sender, String[] args, int index) { + if (!canUse(sender)) { + RPTexts.send(sender, RPTexts.ERROR + "You do not have permission to use this command."); + return true; + } + int end = args.length; + Boolean wanted = null; + if (end > index + 1 && (args[end - 1].equalsIgnoreCase("on") || args[end - 1].equalsIgnoreCase("off"))) { + wanted = args[end - 1].equalsIgnoreCase("on"); + end--; + } + if (end <= index || end > index + 2) { + RPTexts.send(sender, RPTexts.ERROR + "Usage: " + USAGE); + return true; + } + Player player = Bukkit.getPlayerExact(args[index]); + PlayerData account = player == null ? null : PlayerManager.get(player); + if (account == null) { + RPTexts.send(sender, RPTexts.ERROR + "No online player with loaded characters named " + RPTexts.WARN + + args[index] + RPTexts.ERROR + "."); + return true; + } + RPCharacter character = end == index + 2 + ? PermadeathAdminCommands.findCharacter(account, args[index + 1]) + : account.getActiveCharacter(); + if (character == null || character.getStatus() != Status.ALIVE) { + RPTexts.send(sender, RPTexts.ERROR + (end == index + 2 + ? "No living character matching " + RPTexts.WARN + args[index + 1] + RPTexts.ERROR + " for " + : "No active character for ") + RPTexts.WARN + player.getName() + RPTexts.ERROR + + ". Name the character: " + USAGE); + return true; + } + boolean marked = wanted != null ? wanted : !character.isPrisonSwitch(); + character.setPrisonSwitch(marked); + RPCharacters.getPlayerManager().savePlayer(player); + // Redraws an open character selection so the cooldown notice matches the new mark. + if (RPCharacters.getCharacterBodies() != null) { + RPCharacters.getCharacterBodies().rosterChanged(account); + } + LOG.info("[RPCharacters] " + sender.getName() + " turned prison switching " + (marked ? "on" : "off") + + " for " + character.getName() + " (" + character.getId() + ") of " + player.getName() + "."); + if (marked) { + RPTexts.send(sender, RPTexts.SUCCESS + "Prison switching is on for " + RPTexts.WARN + character.getName() + + RPTexts.SUCCESS + " (" + RPTexts.WARN + player.getName() + RPTexts.SUCCESS + + "). They can switch into and out of this character without the switch cooldown."); + RPTexts.send(player, RPTexts.WARN + character.getName() + RPTexts.INFO + " is in prison. You can switch" + + " to and from them at any time without the character switch cooldown."); + } else { + RPTexts.send(sender, RPTexts.SUCCESS + "Prison switching is off for " + RPTexts.WARN + character.getName() + + RPTexts.SUCCESS + " (" + RPTexts.WARN + player.getName() + RPTexts.SUCCESS + + "). The normal switch cooldown applies again."); + RPTexts.send(player, RPTexts.WARN + character.getName() + RPTexts.INFO + " is out of prison. The" + + " character switch cooldown applies to them again."); + } + return true; + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleter.java b/src/main/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleter.java index 4b3f5223..67e916e8 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleter.java +++ b/src/main/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleter.java @@ -27,6 +27,7 @@ import net.tfminecraft.rpcharacters.command.CharCommand; import net.tfminecraft.rpcharacters.persona.PermissionGroupService; import net.tfminecraft.rpcharacters.party.PartyCommand; +import net.tfminecraft.rpcharacters.prison.PrisonSwitch; import net.tfminecraft.rpcharacters.wardrobe.WardrobeCommand; public class CommandTabCompleter implements TabCompleter { @@ -107,6 +108,10 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe && ClassPickReset.canUse(sender)) { return filter(List.of("confirm"), args[2]); } + if (sub.equals("admin") && args.length >= 3 && args[1].equalsIgnoreCase(PrisonSwitch.SUBCOMMAND) + && PrisonSwitch.canUse(sender)) { + return completePrison(args); + } if (sub.equals("admin") && args.length >= 3 && Permissions.isAdmin(sender) && (args[1].equalsIgnoreCase("strikes") || args[1].equalsIgnoreCase("tutorial"))) { return completeAdminEvilRp(args); @@ -173,6 +178,9 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe if (ClassPickReset.canUse(sender)) { completions.add(ClassPickReset.SUBCOMMAND); } + if (PrisonSwitch.canUse(sender)) { + completions.add(PrisonSwitch.SUBCOMMAND); + } } else if (args[0].equalsIgnoreCase("addtrait") || args[0].equalsIgnoreCase("removetrait") || args[0].equalsIgnoreCase("injure") || sub.equals("permakill")) { for (Player online : Bukkit.getOnlinePlayers()) { @@ -310,6 +318,20 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe return Collections.emptyList(); } + /** {@code admin prison [character] [on|off]}. */ + private List completePrison(String[] args) { + String last = args[args.length - 1]; + if (args.length == 3) { + return filter(Bukkit.getOnlinePlayers().stream().map(Player::getName).collect(Collectors.toList()), last); + } + if (args.length == 4) { + List completions = new ArrayList<>(characterIds(args[2])); + completions.addAll(List.of("on", "off")); + return filter(completions, last); + } + return args.length == 5 ? filter(List.of("on", "off"), last) : Collections.emptyList(); + } + /** {@code admin strikes ...} and {@code admin tutorial reset [tutorial]}. */ private List completeAdminEvilRp(String[] args) { String last = args[args.length - 1]; diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index e774a4f3..3bd934a8 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -83,6 +83,9 @@ permissions: rpchar.estate.admin: description: Staff estate tools (/estate list, show, hold, assign and the rest) default: op + rpchar.prison: + description: Mark imprisoned characters to switch in and out without the switch cooldown (/rpcharacter admin prison) + default: op rpchar.class.reset: description: Reset class picks for every character (class-selection.reset-permission) default: op diff --git a/src/test/java/net/tfminecraft/rpcharacters/body/CharacterBodiesTest.java b/src/test/java/net/tfminecraft/rpcharacters/body/CharacterBodiesTest.java index 78e3a001..ca8661d8 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/body/CharacterBodiesTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/body/CharacterBodiesTest.java @@ -577,6 +577,22 @@ void useRealLeases() throws Exception { when(player.hasPermission("rpcharacters.no_cooldown")).thenReturn(true);assertNull(bodies.blockReason(account,b)); when(player.hasPermission("rpcharacters.no_cooldown")).thenReturn(false);when(a.getStatus()).thenReturn(Status.DEAD);assertNull(bodies.blockReason(account,b)); } + void backToRoom() {bodies.requestSelection(player);countdown(5);drain();} + @Test void prisonCharacterSwitchesInAndOutFreelyButCannotCarryTheCooldownToAnotherCharacter() throws Exception { + UUID third=UUID.randomUUID();RPCharacter c=character(third);account.addCharacter(c);when(store.read(owner,third)).thenReturn(Optional.of(record(third,1))); + when(b.isPrisonSwitch()).thenReturn(true);leaveRunsOutgoing(); + groups.when(()->PermissionGroupService.hasCharacterSwitchCooldown(player,account)).thenReturn(true); + groups.when(()->PermissionGroupService.getRemainingCooldownMinutes(player,account)).thenReturn(60); + play();assertEquals(first.toString(),account.getLastFreeCharacterId());backToRoom(); + assertNull(bodies.blockReason(account,b));assertTrue(bodies.blockReason(account,c).contains("0d 1h 0m")); + actions.play(b);drain();assertEquals(second,bodies.liveCharacter(player));backToRoom();assertEquals(new BodyTag.Room(second),tag); + // Leaving prison returns freely to the character it was entered from, never to a third one. + assertNull(bodies.blockReason(account,a));assertTrue(bodies.blockReason(account,c).contains("0d 1h 0m")); + actions.play(a);drain();assertEquals(first,bodies.liveCharacter(player));assertNull(account.getLastCharacterSwitchAtMs()); + backToRoom();groups.when(()->PermissionGroupService.hasCharacterSwitchCooldown(player,account)).thenReturn(false); + actions.play(b);drain();backToRoom();assertNull(account.getLastCharacterSwitchAtMs()); + actions.play(c);drain();assertEquals(third,bodies.liveCharacter(player));assertNotNull(account.getLastCharacterSwitchAtMs());assertEquals(third.toString(),account.getLastFreeCharacterId()); + } @Test void selectionRequiresReadyLivingOwnedCharacterAndLinkUnlessBypassed() throws Exception { bodies.join(player);assertTrue(bodies.blockReason(account,a).contains("loading"));bodies.prepare(account);drain(); assertTrue(bodies.blockReason(account,character(UUID.randomUUID())).contains("cannot be played")); diff --git a/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java b/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java index 55b1cc34..3ed336f7 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java @@ -1041,6 +1041,13 @@ void balancesSayUnavailableInsteadOfZeroAndTheCardOmitsAMissingGuild() { assertNull(CharacterSelectView.guild(cora)); } + @Test + void aPrisonMarkedCardSaysItSwitchesWithoutTheCooldown() { + RPCharacter c = character("c", "Cora", Status.ALIVE); + when(c.isPrisonSwitch()).thenReturn(true); + assertEquals("Cora\nIn prison: no switch cooldown", text(CharacterSelectView.characterCard(c, null))); + } + @Test void lastPlayedRoundsUnderAnHourAndTheCardOmitsAnEmptyThirdLine() { RPCharacter c = character("c", "Cora", Status.ALIVE); diff --git a/src/test/java/net/tfminecraft/rpcharacters/database/DatabaseLoadTest.java b/src/test/java/net/tfminecraft/rpcharacters/database/DatabaseLoadTest.java index 478758d7..e8e246fb 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/database/DatabaseLoadTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/database/DatabaseLoadTest.java @@ -139,10 +139,10 @@ class DatabaseLoadTest extends DatabaseFixture { } private void assertComplete(PlayerData loaded) { - assertNotNull(loaded); assertEquals(owner,loaded.getUniqueId()); assertTrue(loaded.isEighteen()); assertEquals(List.of("intro","race"),loaded.getCompletedStages()); assertEquals(1234567890123L,loaded.getLastCharacterSwitchAtMs()); assertEquals(1700000000,loaded.getCreatedAtEpochSeconds()); + assertNotNull(loaded); assertEquals(owner,loaded.getUniqueId()); assertTrue(loaded.isEighteen()); assertEquals(List.of("intro","race"),loaded.getCompletedStages()); assertEquals(1234567890123L,loaded.getLastCharacterSwitchAtMs()); assertEquals("alpha",loaded.getLastFreeCharacterId()); assertEquals(1700000000,loaded.getCreatedAtEpochSeconds()); assertEquals(12,loaded.getAccountSkillPointsTotal()); assertEquals(6,loaded.getAccountAttributePointsTotal()); assertTrue(loaded.isProfessionPointsInitialized()); assertEquals(3,loaded.getAccountProfessionPoints("forager")); assertEquals(7,loaded.getInvestigationPoints()); assertEquals(1234567890000L,loaded.getLastInvestigationRegenMs()); assertTrue(loaded.hasDismissedTutorial("intro")); assertEquals(2000L,loaded.getLastKitClaimAtMs("extra")); assertEquals(1,loaded.getCharacters().size()); var c=loaded.getCharacters().getFirst(); assertEquals("alpha",c.getId()); assertEquals("Aria",c.getName()); assertEquals(77,c.getOnlinePlaytimeSeconds()); assertEquals(3,c.getConversationCount("friend")); assertEquals(9876543210123L,c.getConversationLastAtMs().get("friend")); - assertEquals("Alias",c.getAlias()); assertEquals("woman",c.getGender()); assertEquals("Character description",c.getPersonaDescription()); assertEquals(List.of("#ffffff","#112233"),c.getNameColour().getHexCodes()); assertTrue(c.isNameColourStaffOverride()); assertEquals("1/1/1700",c.getBirthday()); assertEquals("aria-custom",c.getSlug()); assertFalse(c.isMailListed()); assertTrue(c.isHidden()); + assertEquals("Alias",c.getAlias()); assertEquals("woman",c.getGender()); assertEquals("Character description",c.getPersonaDescription()); assertEquals(List.of("#ffffff","#112233"),c.getNameColour().getHexCodes()); assertTrue(c.isNameColourStaffOverride()); assertEquals("1/1/1700",c.getBirthday()); assertEquals("aria-custom",c.getSlug()); assertFalse(c.isMailListed()); assertTrue(c.isHidden()); assertTrue(c.isPrisonSwitch()); assertEquals(KitStatus.GRANTED,c.getKitStatus()); assertEquals(KitStatus.ELIGIBLE,c.getKitStatus("extra")); var kit=c.getKitCustomisations().get("sword"); assertEquals("Blade",kit.getDisplayName()); assertEquals(List.of("Personal lore"),kit.getLore()); assertEquals("blade-skin",kit.getSkinSlug()); assertEquals("items/blade",kit.getPath()); assertEquals("staff",kit.getIaNamespace()); assertEquals(List.of("#ffffff"),kit.getNameColours()); assertEquals(List.of("bold"),kit.getNameStyles()); assertEquals("tfmc_submissions",c.getKitCustomisations().get("minimal").getIaNamespace()); assertEquals(2,c.getForfeitedProfessionPoints().get("forager")); assertEquals(List.of("upgrade-1"),c.getProfessionUpgrades()); assertEquals(Map.of("strength",3),c.getExtraAttributeAllocation()); assertEquals("world",c.getLastLocationWorld()); assertEquals(12.5,c.getLastLocationX()); assertFalse(c.isPvpLethal()); assertEquals(123,c.getFoodValue()); assertEquals(21,c.getDietScore()); assertEquals(30,c.getRawDietScore()); assertEquals("healthy",c.getLastDietTierId()); assertEquals(1,c.getEvilRpStrikes()); assertEquals(20D,c.getFuel("fuel")); assertTrue(c.getDurationRemainingMs("healing")>0); } @@ -177,9 +177,9 @@ abstract class DatabaseFixture { @SuppressWarnings("unchecked") static JSONObject obj(Object... pairs) {var result=new JSONObject();for(int i=0;i(TraitLoader.oList),"warrior",List.of("Blue coat"));} diff --git a/src/test/java/net/tfminecraft/rpcharacters/database/DatabasePersistenceTest.java b/src/test/java/net/tfminecraft/rpcharacters/database/DatabasePersistenceTest.java index b199f263..bf785b85 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/database/DatabasePersistenceTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/database/DatabasePersistenceTest.java @@ -29,10 +29,10 @@ class DatabasePersistenceTest extends DatabaseFixture { db.savePlayer(loaded); assertEquals(1,integrations.constructed().size()); } JSONObject playerJson=read(playerFile),characterJson=read(characterFile("alpha")); - assertEquals("true",playerJson.get("eighteen")); assertEquals(1234567890123L,playerJson.get("last-character-switch-ms")); assertEquals(array("strength.4","dexterity.2"),playerJson.get("to remove")); + assertEquals("true",playerJson.get("eighteen")); assertEquals(1234567890123L,playerJson.get("last-character-switch-ms")); assertEquals("alpha",playerJson.get("last-free-character")); assertEquals(array("strength.4","dexterity.2"),playerJson.get("to remove")); assertEquals(12,((Number)playerJson.get("account-skill-points-total")).intValue()); assertEquals(6,((Number)playerJson.get("account-attribute-points-total")).intValue()); assertEquals(3,((Number)((JSONObject)playerJson.get("account-profession-points")).get("forager")).intValue()); - assertEquals("alpha",characterJson.get("id")); assertEquals("Aria",characterJson.get("name")); assertEquals(77,((Number)characterJson.get("online-playtime-seconds")).intValue()); assertEquals("false",characterJson.get("mail-listed")); assertFalse(characterJson.containsKey("pvp-lethal"),"Lethal PvP is never saved"); - var fresh=new Database().loadPlayer(player); assertNotNull(fresh); var restored=fresh.getCharacters().getFirst(); assertFalse(restored.isPvpLethal()); + assertEquals("alpha",characterJson.get("id")); assertEquals("Aria",characterJson.get("name")); assertEquals(77,((Number)characterJson.get("online-playtime-seconds")).intValue()); assertEquals("false",characterJson.get("mail-listed")); assertEquals("true",characterJson.get("prison-switch")); assertFalse(characterJson.containsKey("pvp-lethal"),"Lethal PvP is never saved"); + var fresh=new Database().loadPlayer(player); assertNotNull(fresh); var restored=fresh.getCharacters().getFirst(); assertFalse(restored.isPvpLethal()); assertTrue(restored.isPrisonSwitch()); assertEquals("alpha",fresh.getLastFreeCharacterId()); assertEquals(character.getId(),restored.getId()); assertEquals(character.getRace().getId(),restored.getRace().getId()); assertEquals(character.getConversationCounts(),restored.getConversationCounts()); assertEquals(character.getConversationLastAtMs(),restored.getConversationLastAtMs()); assertEquals(character.getNameColour().getHexCodes(),restored.getNameColour().getHexCodes()); assertEquals(character.getPersonaDescription(),restored.getPersonaDescription()); assertEquals(character.getExtraAttributeAllocation(),restored.getExtraAttributeAllocation()); assertEquals(character.getProfessionUpgrades(),restored.getProfessionUpgrades()); assertEquals(character.getForfeitedProfessionPoints(),restored.getForfeitedProfessionPoints()); assertEquals(character.getLastLocationX(),restored.getLastLocationX()); assertEquals(20D,restored.getFuel("fuel")); assertEquals(2,restored.getPaidChangeCount("class")); assertEquals(3,restored.getStageRevision("class")); assertEquals(123,restored.getFoodValue()); playtime.verify(() -> CharacterPlaytimeDirectory.upsert(owner,character)); @@ -65,10 +65,10 @@ class DatabasePersistenceTest extends DatabaseFixture { @Test void emptyAccountsAndMinimalCharactersPersistWithoutOptionalFields() throws Exception { var data=new PlayerData(owner); data.setCreatedAtEpochSeconds(0); db.savePlayer(data); - var account=read(playerFile); assertEquals("false",account.get("eighteen")); assertEquals(array(),account.get("completed stages")); assertFalse(account.containsKey("account-skill-points-total")); assertFalse(account.containsKey("created-at")); + var account=read(playerFile); assertEquals("false",account.get("eighteen")); assertEquals(array(),account.get("completed stages")); assertFalse(account.containsKey("account-skill-points-total")); assertFalse(account.containsKey("created-at")); assertFalse(account.containsKey("last-free-character")); var character=new RPCharacter(null); character.setId("minimal"); character.setName("Minimal"); character.setRace(race); character.setCreatedAtEpochSeconds(0); db.saveCharacter(data,character); var json=read(characterFile("minimal")); - assertEquals("minimal",json.get("id")); assertFalse(json.containsKey("class")); assertFalse(json.containsKey("created-at")); assertFalse(json.containsKey("trait-state")); assertFalse(json.containsKey("last-location")); assertFalse(json.containsKey("profession-upgrades")); assertEquals(RPCharacter.MAX_FOOD_VALUE,((Number)json.get("food-value")).intValue()); + assertEquals("minimal",json.get("id")); assertFalse(json.containsKey("class")); assertFalse(json.containsKey("created-at")); assertFalse(json.containsKey("trait-state")); assertFalse(json.containsKey("last-location")); assertFalse(json.containsKey("profession-upgrades")); assertFalse(json.containsKey("prison-switch")); assertEquals(RPCharacter.MAX_FOOD_VALUE,((Number)json.get("food-value")).intValue()); data.addCharacter(character); db.savePlayer(data); assertTrue(Files.exists(characterFile("minimal"))); playtime.verify(() -> CharacterPlaytimeDirectory.upsert(owner,character),times(2)); } diff --git a/src/test/java/net/tfminecraft/rpcharacters/managers/CommandManagerTest.java b/src/test/java/net/tfminecraft/rpcharacters/managers/CommandManagerTest.java index b3f152a7..1e55d76d 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/managers/CommandManagerTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/managers/CommandManagerTest.java @@ -14,6 +14,7 @@ import net.tfminecraft.rpcharacters.catalog.CreationCatalogSyncService; import net.tfminecraft.rpcharacters.classpick.ClassPickGui; import net.tfminecraft.rpcharacters.classpick.ClassPickReset; +import net.tfminecraft.rpcharacters.prison.PrisonSwitch; import net.tfminecraft.rpcharacters.clues.discovery.*; import net.tfminecraft.rpcharacters.command.CharCommand; import net.tfminecraft.rpcharacters.creation.*; @@ -158,6 +159,8 @@ PlayerClass knownClass(ClassManager manager, String id) { resets.verify(() -> ClassPickReset.handle(player, reset, 2)); var carried = boundary(net.tfminecraft.rpcharacters.mmocore.CarriedExperience.class); String[] carry = {"admin", "CarryExp", "Iris", "40"}; assertFalse(run(carry)); carried.verify(() -> net.tfminecraft.rpcharacters.mmocore.CarriedExperience.handle(player, carry, 2)); + var prisons = boundary(PrisonSwitch.class); String[] prison = {"admin", "Prison", "Iris"}; assertFalse(run(prison)); + prisons.verify(() -> PrisonSwitch.handle(player, prison, 2)); String[] directKill = {"permakill", "Iris"}; assertFalse(run(directKill)); death.verify(() -> PermadeathAdminCommands.handlePermakill(player, directKill, 1, "permakill")); String[] directInjure = {"injure", "Iris", "permanent"}; assertFalse(run(directInjure)); death.verify(() -> PermadeathAdminCommands.handleInjure(eq(player), same(directInjure), eq(1), anyString())); run("reload"); verify(plugin).reloadConfigs(player); diff --git a/src/test/java/net/tfminecraft/rpcharacters/prison/PrisonSwitchTest.java b/src/test/java/net/tfminecraft/rpcharacters/prison/PrisonSwitchTest.java new file mode 100644 index 00000000..628dd8bb --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/prison/PrisonSwitchTest.java @@ -0,0 +1,150 @@ +package net.tfminecraft.rpcharacters.prison; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; + +import org.bukkit.ChatColor; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockito.MockedStatic; + +import net.tfminecraft.rpcharacters.Permissions; +import net.tfminecraft.rpcharacters.RPCharacters; +import net.tfminecraft.rpcharacters.enums.Status; +import net.tfminecraft.rpcharacters.managers.PlayerManager; +import net.tfminecraft.rpcharacters.objects.PlayerData; +import net.tfminecraft.rpcharacters.objects.RPCharacter; + +class PrisonSwitchTest { + final PlayerData account = new PlayerData(UUID.randomUUID()); + RPCharacter a, b, c; + + RPCharacter character(String id, String name) { + RPCharacter character = mock(RPCharacter.class); when(character.getId()).thenReturn(id); when(character.getName()).thenReturn(name); + when(character.getStatus()).thenReturn(Status.ALIVE); + doAnswer(call -> { when(character.isPrisonSwitch()).thenReturn(call.getArgument(0)); return null; }).when(character).setPrisonSwitch(anyBoolean()); + return character; + } + + @BeforeEach void roster() { + a = character("a", "Aria"); b = character("b", "Bram"); c = character("c", "Cora"); + account.addCharacter(a); account.addCharacter(b); account.addCharacter(c); + } + + @Test void unmarkedCharactersKeepTheCooldownAndResumingIsFree() { + assertTrue(PrisonSwitch.cooldownApplies(account, "a", b)); assertFalse(PrisonSwitch.cooldownApplies(account, "a", a)); + assertFalse(PrisonSwitch.cooldownApplies(account, null, b)); + when(a.getStatus()).thenReturn(Status.DEAD); assertFalse(PrisonSwitch.cooldownApplies(account, "a", b)); + assertFalse(PrisonSwitch.cooldownApplies(account, "gone", b)); + PrisonSwitch.entered(account, null, a); assertNull(account.getLastCharacterSwitchAtMs()); assertEquals("a", account.getLastFreeCharacterId()); + PrisonSwitch.entered(account, "a", a); assertNull(account.getLastCharacterSwitchAtMs()); + PrisonSwitch.entered(account, "a", b); assertNotNull(account.getLastCharacterSwitchAtMs()); assertEquals("b", account.getLastFreeCharacterId()); + } + + @Test void idsMatchWhateverCaseTheyWereStoredIn() { + RPCharacter upper = character("D1", "Dara"); account.addCharacter(upper); + assertFalse(PrisonSwitch.cooldownApplies(account, "d1", upper)); assertFalse(PrisonSwitch.cooldownApplies(account, "a", a)); + account.setLastFreeCharacterId("d1"); assertFalse(PrisonSwitch.cooldownApplies(account, "b", upper)); + PrisonSwitch.entered(account, "d1", upper); PrisonSwitch.entered(account, "b", upper); assertNull(account.getLastCharacterSwitchAtMs()); + } + + @Test void markedCharacterIsEnteredAndLeftFreelyWithoutBridgingTwoOthers() { + b.setPrisonSwitch(true); + assertFalse(PrisonSwitch.cooldownApplies(account, "a", b)); + PrisonSwitch.entered(account, "a", b); + assertNull(account.getLastCharacterSwitchAtMs()); assertEquals("a", account.getLastFreeCharacterId()); + assertFalse(PrisonSwitch.cooldownApplies(account, "b", a)); assertTrue(PrisonSwitch.cooldownApplies(account, "b", c)); + PrisonSwitch.entered(account, "b", a); assertNull(account.getLastCharacterSwitchAtMs()); + PrisonSwitch.entered(account, "a", b); PrisonSwitch.entered(account, "b", c); + assertNotNull(account.getLastCharacterSwitchAtMs()); assertEquals("c", account.getLastFreeCharacterId()); + } + + @Test void releasedPrisonerResumesAndReturnsFreelyButStillWaitsForAThirdCharacter() { + b.setPrisonSwitch(true); PrisonSwitch.entered(account, "a", b); b.setPrisonSwitch(false); + assertFalse(PrisonSwitch.cooldownApplies(account, "b", b)); assertFalse(PrisonSwitch.cooldownApplies(account, "b", a)); + assertTrue(PrisonSwitch.cooldownApplies(account, "b", c)); + PrisonSwitch.entered(account, "b", b); assertEquals("a", account.getLastFreeCharacterId()); assertNull(account.getLastCharacterSwitchAtMs()); + } + + @Test void markingTheCurrentCharacterLetsThePlayerLeaveIt() { + PrisonSwitch.entered(account, null, a); a.setPrisonSwitch(true); + assertFalse(PrisonSwitch.cooldownApplies(account, "a", b)); + PrisonSwitch.entered(account, "a", b); assertNull(account.getLastCharacterSwitchAtMs()); assertEquals("b", account.getLastFreeCharacterId()); + } + + @Test void accountsFromBeforeTheMarkCountFromTheirLastCharacter() { + b.setPrisonSwitch(true); + assertTrue(PrisonSwitch.cooldownApplies(account, "a", c)); assertFalse(PrisonSwitch.cooldownApplies(account, "b", c)); + PrisonSwitch.entered(account, "a", b); assertEquals("a", account.getLastFreeCharacterId()); + PlayerData fresh = new PlayerData(UUID.randomUUID()); fresh.addCharacter(b); + PrisonSwitch.entered(fresh, null, b); assertNull(fresh.getLastFreeCharacterId()); + PlayerData marked = new PlayerData(UUID.randomUUID()); marked.addCharacter(b); marked.addCharacter(c); + PrisonSwitch.entered(marked, "b", c); assertNull(marked.getLastCharacterSwitchAtMs()); assertEquals("c", marked.getLastFreeCharacterId()); + } + + @Nested class Command { + ServerMock server; PlayerMock staff, iris; PlayerManager players; MockedStatic root; final List> mocks = new ArrayList<>(); + + @BeforeEach void setup() { + server = MockBukkit.mock(); staff = server.addPlayer("Staff"); iris = server.addPlayer("Iris"); server.addPlayer("Loading"); + players = mock(PlayerManager.class); + root = mockStatic(RPCharacters.class); mocks.add(root); root.when(RPCharacters::getPlayerManager).thenReturn(players); + var data = mockStatic(PlayerManager.class); mocks.add(data); data.when(() -> PlayerManager.get(iris)).thenReturn(account); + when(a.getSlug()).thenReturn("aria"); + } + @AfterEach void cleanup() { for (int i = mocks.size() - 1; i >= 0; i--) mocks.get(i).close(); MockBukkit.unmock(); } + + void grant(String permission) { staff.addAttachment(MockBukkit.createMockPlugin(), permission, true); } + boolean run(String... args) { return PrisonSwitch.handle(staff, args, 2); } + String messages(PlayerMock player) { + List out = new ArrayList<>(); String message; + while ((message = player.nextMessage()) != null) out.add(ChatColor.stripColor(message)); + return String.join("\n", out); + } + + @Test void staffNeedThePrisonOrAdminPermission() { + assertFalse(PrisonSwitch.canUse(staff)); assertTrue(run("admin", "prison", "Iris")); assertTrue(messages(staff).contains("permission")); + grant(PrisonSwitch.PERMISSION); assertTrue(PrisonSwitch.canUse(staff)); + PlayerMock admin = server.addPlayer("Admin"); admin.addAttachment(MockBukkit.createMockPlugin(), Permissions.Permission_Admin, true); + assertTrue(PrisonSwitch.canUse(admin)); + } + + @Test void badTargetsExplainTheUsageAndChangeNothing() { + grant(PrisonSwitch.PERMISSION); + for (String[] args : List.of(new String[] {"admin", "prison"}, + new String[] {"admin", "prison", "Iris", "aria", "extra"}, new String[] {"admin", "prison", "Iris", "aria", "x", "on"})) { + assertTrue(run(args)); assertTrue(messages(staff).contains("Usage: " + PrisonSwitch.USAGE)); + } + assertTrue(run("admin", "prison", "Nobody")); assertTrue(messages(staff).contains("No online player with loaded characters named Nobody")); + assertTrue(run("admin", "prison", "Loading")); assertTrue(messages(staff).contains("named Loading")); + assertTrue(run("admin", "prison", "Iris")); assertTrue(messages(staff).contains("No active character for Iris")); + assertTrue(run("admin", "prison", "Iris", "zed")); assertTrue(messages(staff).contains("No living character matching zed for Iris")); + when(c.getStatus()).thenReturn(Status.DEAD); + assertTrue(run("admin", "prison", "Iris", "Cora", "on")); assertTrue(messages(staff).contains("No living character matching Cora")); + verify(c, never()).setPrisonSwitch(anyBoolean()); verifyNoInteractions(players); + } + + @Test void markFlipsOrIsSetExplicitlyAndBothSidesAreTold() { + grant(PrisonSwitch.PERMISSION); when(a.isActive()).thenReturn(true); + assertTrue(run("admin", "prison", "Iris")); + assertTrue(a.isPrisonSwitch()); assertTrue(messages(staff).contains("Prison switching is on for Aria (Iris)")); + assertTrue(messages(iris).contains("Aria is in prison")); + assertTrue(run("admin", "prison", "Iris", "ON")); assertTrue(a.isPrisonSwitch()); messages(staff); messages(iris); + assertTrue(run("admin", "prison", "Iris", "aria")); assertFalse(a.isPrisonSwitch()); + assertTrue(messages(staff).contains("Prison switching is off for Aria")); assertTrue(messages(iris).contains("Aria is out of prison")); + assertTrue(run("admin", "prison", "Iris", "Bram", "on")); assertTrue(b.isPrisonSwitch()); messages(staff); + assertTrue(run("admin", "prison", "Iris", "b", "off")); assertFalse(b.isPrisonSwitch()); + verify(players, times(5)).savePlayer(iris); + } + + @Test void anOpenCharacterSelectionIsRedrawnWithTheNewMark() { + grant(PrisonSwitch.PERMISSION); var bodies = mock(net.tfminecraft.rpcharacters.body.CharacterBodies.class); + root.when(RPCharacters::getCharacterBodies).thenReturn(bodies); + assertTrue(run("admin", "prison", "Iris", "aria", "on")); verify(bodies).rosterChanged(account); + } + } +} diff --git a/src/test/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleterTest.java b/src/test/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleterTest.java index 5684cbd3..7d30cf22 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleterTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleterTest.java @@ -15,6 +15,7 @@ import net.tfminecraft.rpcharacters.objects.trait.Trait; import net.tfminecraft.rpcharacters.party.PartyCommand; import net.tfminecraft.rpcharacters.persona.PermissionGroupService; +import net.tfminecraft.rpcharacters.prison.PrisonSwitch; import net.tfminecraft.rpcharacters.tutorial.TutorialLoader; import net.tfminecraft.rpcharacters.wardrobe.WardrobeCommand; import org.bukkit.command.*; @@ -114,8 +115,8 @@ RPCharacter character(String slug, String name) { for (String verb : List.of("menu", "clues", "setclass", "seteighteen", "resetkit", "reclaimkit", "skipcooldown", "addtrait", "removetrait", "injure", "discordgate")) assertEquals(List.of("Iris"), complete(verb, "IR"), verb); assertEquals(List.of("name", "race"), complete("edit", "")); - assertEquals(List.of("injure", "permakill", "strikes", "tutorial", "carryexp"), complete("admin", "")); - grant("rpchar.class.reset", true); assertEquals(List.of("injure", "permakill", "strikes", "tutorial", "carryexp", "resetclasses"), complete("admin", "")); + assertEquals(List.of("injure", "permakill", "strikes", "tutorial", "carryexp", "prison"), complete("admin", "")); + grant("rpchar.class.reset", true); assertEquals(List.of("injure", "permakill", "strikes", "tutorial", "carryexp", "resetclasses", "prison"), complete("admin", "")); assertEquals(List.of("confirm"), complete("admin", "RESETCLASSES", "c")); assertEquals(List.of(), complete("admin", "resetclasses", "confirm", "")); assertEquals(List.of("5", "10", "25", "50"), complete("clearclues", "")); assertEquals(List.of(), complete("placeclue", "")); assertEquals(List.of("on", "off"), complete("adminmode", "")); @@ -127,8 +128,17 @@ 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", "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", "")); + assertEquals(List.of(), complete("admin", "strikes", "")); assertEquals(List.of(), complete("admin", "resetclasses", "")); assertEquals(List.of(), complete("admin", "prison", "")); grant("rpchar.class.reset", true); assertEquals(List.of("resetclasses"), complete("admin", "")); + grant(PrisonSwitch.PERMISSION, true); assertEquals(List.of("resetclasses", "prison"), complete("admin", "")); + } + + @Test void prisonCompletionOffersPlayersTheirLivingCharactersAndTheSwitch() { + grant(PrisonSwitch.PERMISSION, true); + assertEquals(List.of("Iris"), complete("admin", "prison", "I")); + assertEquals(List.of("hero", "on", "off"), complete("admin", "prison", "Iris", "")); + assertEquals(List.of("on", "off"), complete("admin", "PRISON", "Iris", "hero", "")); + assertEquals(List.of(), complete("admin", "prison", "Iris", "hero", "on", "")); } @Test void hiddenCharacterSuggestionsWaitForPlayerDataToLoad() { From 3d189e4ab678307158d799f8eddbedeafbdc7b4d Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 18 Oct 2026 08:10:42 +0200 Subject: [PATCH 2/4] fix: show the prison note inside character selection Chat waits for Play while a player is in character selection, so the prison mark note goes through CharacterBodies.feedback: in selection it appears in the redrawn selection screen, in play it is sent as chat. Co-Authored-By: Claude Opus 5.5 --- .../rpcharacters/prison/PrisonSwitch.java | 19 +++++++++++-------- .../rpcharacters/prison/PrisonSwitchTest.java | 9 +++++++-- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/main/java/net/tfminecraft/rpcharacters/prison/PrisonSwitch.java b/src/main/java/net/tfminecraft/rpcharacters/prison/PrisonSwitch.java index 7031aec8..47b9d09d 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/prison/PrisonSwitch.java +++ b/src/main/java/net/tfminecraft/rpcharacters/prison/PrisonSwitch.java @@ -112,24 +112,27 @@ public static boolean handle(CommandSender sender, String[] args, int index) { boolean marked = wanted != null ? wanted : !character.isPrisonSwitch(); character.setPrisonSwitch(marked); RPCharacters.getPlayerManager().savePlayer(player); - // Redraws an open character selection so the cooldown notice matches the new mark. - if (RPCharacters.getCharacterBodies() != null) { - RPCharacters.getCharacterBodies().rosterChanged(account); - } LOG.info("[RPCharacters] " + sender.getName() + " turned prison switching " + (marked ? "on" : "off") + " for " + character.getName() + " (" + character.getId() + ") of " + player.getName() + "."); + String note; if (marked) { RPTexts.send(sender, RPTexts.SUCCESS + "Prison switching is on for " + RPTexts.WARN + character.getName() + RPTexts.SUCCESS + " (" + RPTexts.WARN + player.getName() + RPTexts.SUCCESS + "). They can switch into and out of this character without the switch cooldown."); - RPTexts.send(player, RPTexts.WARN + character.getName() + RPTexts.INFO + " is in prison. You can switch" - + " to and from them at any time without the character switch cooldown."); + note = RPTexts.WARN + character.getName() + RPTexts.INFO + " is in prison. You can switch" + + " to and from them at any time without the character switch cooldown."; } else { RPTexts.send(sender, RPTexts.SUCCESS + "Prison switching is off for " + RPTexts.WARN + character.getName() + RPTexts.SUCCESS + " (" + RPTexts.WARN + player.getName() + RPTexts.SUCCESS + "). The normal switch cooldown applies again."); - RPTexts.send(player, RPTexts.WARN + character.getName() + RPTexts.INFO + " is out of prison. The" - + " character switch cooldown applies to them again."); + note = RPTexts.WARN + character.getName() + RPTexts.INFO + " is out of prison. The" + + " character switch cooldown applies to them again."; + } + // Chat waits for Play in character selection, so there the note goes into the selection, which redraws. + if (RPCharacters.getCharacterBodies() != null) { + RPCharacters.getCharacterBodies().feedback(player, RPTexts.formatDisplay(note)); + } else { + RPTexts.send(player, note); } return true; } diff --git a/src/test/java/net/tfminecraft/rpcharacters/prison/PrisonSwitchTest.java b/src/test/java/net/tfminecraft/rpcharacters/prison/PrisonSwitchTest.java index 628dd8bb..3cbca938 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/prison/PrisonSwitchTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/prison/PrisonSwitchTest.java @@ -1,6 +1,7 @@ package net.tfminecraft.rpcharacters.prison; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; import java.util.*; @@ -141,10 +142,14 @@ String messages(PlayerMock player) { verify(players, times(5)).savePlayer(iris); } - @Test void anOpenCharacterSelectionIsRedrawnWithTheNewMark() { + @Test void withCharacterBodiesThePlayerIsToldThroughSelectionFeedback() { grant(PrisonSwitch.PERMISSION); var bodies = mock(net.tfminecraft.rpcharacters.body.CharacterBodies.class); root.when(RPCharacters::getCharacterBodies).thenReturn(bodies); - assertTrue(run("admin", "prison", "Iris", "aria", "on")); verify(bodies).rosterChanged(account); + assertTrue(run("admin", "prison", "Iris", "aria", "on")); + verify(bodies).feedback(eq(iris), argThat(m -> ChatColor.stripColor(m).equals("Aria is in prison. You can switch to and from them at any time without the character switch cooldown."))); + assertTrue(run("admin", "prison", "Iris", "aria", "off")); + verify(bodies).feedback(eq(iris), argThat(m -> ChatColor.stripColor(m).startsWith("Aria is out of prison."))); + assertNull(iris.nextMessage()); } } } From d41a7fdb3b694d3f3dd329965eeef5045a398d99 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 18 Oct 2026 08:58:44 +0200 Subject: [PATCH 3/4] fix: report a failed prison mark save The prison command now writes the character file with saveCharacterNow and checks the result. If the save fails, the mark goes back to its old value and staff are told, instead of seeing success for a mark that may be lost at the next restart. Co-Authored-By: Claude Opus 5.5 --- .../rpcharacters/prison/PrisonSwitch.java | 10 ++++++++-- .../rpcharacters/prison/PrisonSwitchTest.java | 13 ++++++++++--- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/main/java/net/tfminecraft/rpcharacters/prison/PrisonSwitch.java b/src/main/java/net/tfminecraft/rpcharacters/prison/PrisonSwitch.java index 47b9d09d..05bf6fa3 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/prison/PrisonSwitch.java +++ b/src/main/java/net/tfminecraft/rpcharacters/prison/PrisonSwitch.java @@ -109,9 +109,15 @@ public static boolean handle(CommandSender sender, String[] args, int index) { + ". Name the character: " + USAGE); return true; } - boolean marked = wanted != null ? wanted : !character.isPrisonSwitch(); + boolean before = character.isPrisonSwitch(); + boolean marked = wanted != null ? wanted : !before; character.setPrisonSwitch(marked); - RPCharacters.getPlayerManager().savePlayer(player); + if (!RPCharacters.getPlayerManager().saveCharacterNow(player.getUniqueId(), character)) { + character.setPrisonSwitch(before); + RPTexts.send(sender, RPTexts.ERROR + "Could not save " + RPTexts.WARN + character.getName() + RPTexts.ERROR + + ", so prison switching was not changed. See the console."); + return true; + } LOG.info("[RPCharacters] " + sender.getName() + " turned prison switching " + (marked ? "on" : "off") + " for " + character.getName() + " (" + character.getId() + ") of " + player.getName() + "."); String note; diff --git a/src/test/java/net/tfminecraft/rpcharacters/prison/PrisonSwitchTest.java b/src/test/java/net/tfminecraft/rpcharacters/prison/PrisonSwitchTest.java index 3cbca938..34db051e 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/prison/PrisonSwitchTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/prison/PrisonSwitchTest.java @@ -92,7 +92,7 @@ RPCharacter character(String id, String name) { @BeforeEach void setup() { server = MockBukkit.mock(); staff = server.addPlayer("Staff"); iris = server.addPlayer("Iris"); server.addPlayer("Loading"); - players = mock(PlayerManager.class); + players = mock(PlayerManager.class); when(players.saveCharacterNow(any(), any())).thenReturn(true); root = mockStatic(RPCharacters.class); mocks.add(root); root.when(RPCharacters::getPlayerManager).thenReturn(players); var data = mockStatic(PlayerManager.class); mocks.add(data); data.when(() -> PlayerManager.get(iris)).thenReturn(account); when(a.getSlug()).thenReturn("aria"); @@ -126,7 +126,7 @@ String messages(PlayerMock player) { assertTrue(run("admin", "prison", "Iris", "zed")); assertTrue(messages(staff).contains("No living character matching zed for Iris")); when(c.getStatus()).thenReturn(Status.DEAD); assertTrue(run("admin", "prison", "Iris", "Cora", "on")); assertTrue(messages(staff).contains("No living character matching Cora")); - verify(c, never()).setPrisonSwitch(anyBoolean()); verifyNoInteractions(players); + verify(c, never()).setPrisonSwitch(anyBoolean()); verify(players, never()).saveCharacterNow(any(), any()); } @Test void markFlipsOrIsSetExplicitlyAndBothSidesAreTold() { @@ -139,7 +139,14 @@ String messages(PlayerMock player) { assertTrue(messages(staff).contains("Prison switching is off for Aria")); assertTrue(messages(iris).contains("Aria is out of prison")); assertTrue(run("admin", "prison", "Iris", "Bram", "on")); assertTrue(b.isPrisonSwitch()); messages(staff); assertTrue(run("admin", "prison", "Iris", "b", "off")); assertFalse(b.isPrisonSwitch()); - verify(players, times(5)).savePlayer(iris); + verify(players, times(3)).saveCharacterNow(iris.getUniqueId(), a); verify(players, times(2)).saveCharacterNow(iris.getUniqueId(), b); + } + + @Test void aFailedSaveKeepsTheOldMarkAndSaysSo() { + grant(PrisonSwitch.PERMISSION); when(players.saveCharacterNow(iris.getUniqueId(), a)).thenReturn(false); + assertTrue(run("admin", "prison", "Iris", "aria", "on")); + assertFalse(a.isPrisonSwitch()); assertTrue(messages(staff).contains("Could not save Aria, so prison switching was not changed")); + assertNull(iris.nextMessage()); } @Test void withCharacterBodiesThePlayerIsToldThroughSelectionFeedback() { From 288a4b6dd063022189f4a5283b3f1f7381f9030a Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:21:20 +0200 Subject: [PATCH 4/4] fix: write the old prison mark back after a failed save A durable save can fail after the character file was already replaced, for example when the directory sync fails. The command now saves the old mark again before telling staff nothing changed, and says the saved mark is uncertain if that second save fails too. Co-Authored-By: Claude Opus 5.5 --- .../rpcharacters/prison/PrisonSwitch.java | 6 +++++- .../rpcharacters/prison/PrisonSwitchTest.java | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/main/java/net/tfminecraft/rpcharacters/prison/PrisonSwitch.java b/src/main/java/net/tfminecraft/rpcharacters/prison/PrisonSwitch.java index 05bf6fa3..025b4098 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/prison/PrisonSwitch.java +++ b/src/main/java/net/tfminecraft/rpcharacters/prison/PrisonSwitch.java @@ -114,8 +114,12 @@ public static boolean handle(CommandSender sender, String[] args, int index) { character.setPrisonSwitch(marked); if (!RPCharacters.getPlayerManager().saveCharacterNow(player.getUniqueId(), character)) { character.setPrisonSwitch(before); + // A failed save may already have replaced the file, so write the old mark back before saying so. + boolean restored = RPCharacters.getPlayerManager().saveCharacterNow(player.getUniqueId(), character); RPTexts.send(sender, RPTexts.ERROR + "Could not save " + RPTexts.WARN + character.getName() + RPTexts.ERROR - + ", so prison switching was not changed. See the console."); + + (restored ? ", so prison switching was not changed." + : ". Its saved prison switching may be either value until it saves again.") + + " See the console."); return true; } LOG.info("[RPCharacters] " + sender.getName() + " turned prison switching " + (marked ? "on" : "off") diff --git a/src/test/java/net/tfminecraft/rpcharacters/prison/PrisonSwitchTest.java b/src/test/java/net/tfminecraft/rpcharacters/prison/PrisonSwitchTest.java index 34db051e..fef83601 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/prison/PrisonSwitchTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/prison/PrisonSwitchTest.java @@ -142,10 +142,20 @@ String messages(PlayerMock player) { verify(players, times(3)).saveCharacterNow(iris.getUniqueId(), a); verify(players, times(2)).saveCharacterNow(iris.getUniqueId(), b); } - @Test void aFailedSaveKeepsTheOldMarkAndSaysSo() { + @Test void aFailedSaveWritesTheOldMarkBackAndSaysSo() { + grant(PrisonSwitch.PERMISSION); List saved = new ArrayList<>(); + when(players.saveCharacterNow(iris.getUniqueId(), a)).thenAnswer(call -> { saved.add(a.isPrisonSwitch()); return saved.size() > 1; }); + assertTrue(run("admin", "prison", "Iris", "aria", "on")); + assertFalse(a.isPrisonSwitch()); assertEquals(List.of(true, false), saved); + assertTrue(messages(staff).contains("Could not save Aria, so prison switching was not changed. See the console.")); + assertNull(iris.nextMessage()); + } + + @Test void aSaveThatFailsTwiceSaysTheSavedMarkIsUncertain() { grant(PrisonSwitch.PERMISSION); when(players.saveCharacterNow(iris.getUniqueId(), a)).thenReturn(false); assertTrue(run("admin", "prison", "Iris", "aria", "on")); - assertFalse(a.isPrisonSwitch()); assertTrue(messages(staff).contains("Could not save Aria, so prison switching was not changed")); + assertFalse(a.isPrisonSwitch()); verify(players, times(2)).saveCharacterNow(iris.getUniqueId(), a); + assertTrue(messages(staff).contains("Could not save Aria. Its saved prison switching may be either value until it saves again.")); assertNull(iris.nextMessage()); }