diff --git a/src/main/java/net/tfminecraft/rpcharacters/body/CharacterBodies.java b/src/main/java/net/tfminecraft/rpcharacters/body/CharacterBodies.java index 7939b38..3ac1d4c 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 c67620f..017e238 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 6cff601..a618443 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 9d5f716..f705015 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 e65e2ec..b35241b 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 ac24dbc..3bffa06 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 0a5ec08..e400438 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 0000000..025b409 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/prison/PrisonSwitch.java @@ -0,0 +1,149 @@ +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 before = character.isPrisonSwitch(); + boolean marked = wanted != null ? wanted : !before; + 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 + + (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") + + " 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."); + 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."); + 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/main/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleter.java b/src/main/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleter.java index 4b3f522..67e916e 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 e774a4f..3bd934a 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 78e3a00..ca8661d 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 55b1cc3..3ed336f 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 478758d..e8e246f 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 b199f26..bf785b8 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 b3f152a..1e55d76 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 0000000..fef8360 --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/prison/PrisonSwitchTest.java @@ -0,0 +1,172 @@ +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.*; + +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); 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"); + } + @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()); verify(players, never()).saveCharacterNow(any(), any()); + } + + @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(3)).saveCharacterNow(iris.getUniqueId(), a); verify(players, times(2)).saveCharacterNow(iris.getUniqueId(), b); + } + + @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()); 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()); + } + + @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).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()); + } + } +} diff --git a/src/test/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleterTest.java b/src/test/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleterTest.java index 5684cbd..7d30cf2 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() {