diff --git a/src/main/java/net/tfminecraft/rpcharacters/body/CharacterBodies.java b/src/main/java/net/tfminecraft/rpcharacters/body/CharacterBodies.java index f95f67f..7fc0a02 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/body/CharacterBodies.java +++ b/src/main/java/net/tfminecraft/rpcharacters/body/CharacterBodies.java @@ -406,6 +406,9 @@ public boolean isCurrent(Player viewer, PlayerData data) { return viewer == player && data == account && isReadyToChoose(player); } public String blockReason(RPCharacter character) { return CharacterBodies.this.blockReason(account, character); } + public CharacterSelectView.SwitchCooldown switchCooldown(RPCharacter character) { + return CharacterBodies.this.switchCooldown(account, character); + } public void play(RPCharacter character) { select(account, character); } public void create() { CreationManager.initiateCreation(player); @@ -481,7 +484,7 @@ public String blockReason(PlayerData account, RPCharacter character) { // Prison-marked characters switch freely; the cooldown counts from the last unmarked character. if (currentTag instanceof BodyTag.Room tag && tag.lastCharacterId() != null && PrisonSwitch.cooldownApplies(account, tag.lastCharacterId().toString(), character) - && !player.hasPermission("rpcharacters.no_cooldown") + && !player.hasPermission(CharacterSelectView.NO_COOLDOWN_PERMISSION) && 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."; @@ -489,6 +492,27 @@ public String blockReason(PlayerData account, RPCharacter character) { return null; } + /** + * The cooldown entering {@code character} (null: a character not created yet) would start, by the + * same rule {@link PrisonSwitch#entered} applies; null when it starts none or the player has none. + */ + CharacterSelectView.SwitchCooldown switchCooldown(PlayerData account, RPCharacter character) { + Player player = account.getPlayer(); + if (player == null || player.hasPermission(CharacterSelectView.NO_COOLDOWN_PERMISSION)) return null; + int days = PermissionGroupService.getCharacterSwitchCooldownDays(player); + if (days <= 0) return null; + BodyTag currentTag; + try { currentTag = BodyTag.read(player); } + catch (IOException error) { return null; } + if (!(currentTag instanceof BodyTag.Room tag) || tag.lastCharacterId() == null) return null; + String from = PrisonSwitch.cooldownStartsFrom(account, tag.lastCharacterId().toString(), character); + if (from == null) return null; + RPCharacter leaving = account.getCharacterById(from); + boolean named = leaving != null && leaving.getStatus() == Status.ALIVE + && leaving.getName() != null && !leaving.getName().isBlank(); + return new CharacterSelectView.SwitchCooldown(days, named ? leaving.getName() : null); + } + public void select(PlayerData account, RPCharacter character) { Player player = account.getPlayer(); String blocked = blockReason(account, character); diff --git a/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java b/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java index 017e238..e387a82 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java +++ b/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java @@ -90,6 +90,28 @@ public interface Actions { default boolean needsDiscordLink() { return false; } default void linkDiscord() {} + + /** + * The switch cooldown that playing {@code character} would start, or null when it starts none. + * A null {@code character} asks about a character that is not created yet. + */ + default SwitchCooldown switchCooldown(RPCharacter character) { return null; } + } + + /** + * A cooldown that entering a character would start: its length in days and the name of the living + * character being left behind, or null when that one has died or cannot be found. + */ + public record SwitchCooldown(int days, String leaving) { + /** "7 days", for how long it lasts. */ + String length() { + return days + (days == 1 ? " day" : " days"); + } + + /** "7-day", before the word cooldown. */ + String hyphenated() { + return days + "-day"; + } } /** Loads one character's 16x32 front view, completing with null when there is none. */ @@ -106,6 +128,8 @@ public interface PortraitLoader { static final int BUST_ROWS = 14; static final int TAB_NAME = 11; static final String NO_COOLDOWN_PERMISSION = "rpcharacters.no_cooldown"; + /** Shown when Switch was clicked on a warning whose cooldown length has since changed. */ + static final String TERMS_CHANGED = "Your cooldown has changed. Check it before switching."; public static final String LOADING = "Loading portrait…"; public static final String NO_PORTRAIT = "No portrait"; public static final TextColor TEAL = TextColor.color(0x66F0E2); @@ -150,6 +174,8 @@ static final class Session { /** The character on the end page, or null. */ String ending; boolean sure; + /** The character on the switch cooldown warning page, or null. */ + String confirming; String notice; Session(Player player, PlayerData data, Actions actions, SkinTexture accountSkin) { @@ -248,6 +274,7 @@ private static void render(Session session) { session.painting = true; try { if (session.ending != null) renderEnding(session); + else if (session.confirming != null) renderSwitchWarning(session); else if (session.reading != null) renderBackground(session); else if (session.memorials) renderMemorials(session); else renderRoster(session); @@ -280,6 +307,11 @@ private static void renderRoster(Session session) { body.add(text(Component.text("Choose a name, class and race.\nReview your choices before you begin.", MUTED))); String blocked = createBlockReason(player, data); if (blocked != null) body.add(text(Component.text(blocked, WARN))); + else { + SwitchCooldown cooldown = session.actions.switchCooldown(null); + if (cooldown != null) body.add(text(Component.text("Playing your new character starts your " + + cooldown.hyphenated() + " character switch cooldown.", WARN))); + } } else { reason = session.actions.blockReason(selected); body.add(DialogBody.plainMessage(portrait(session, selected), SkinPortrait.ROW_WIDTH)); @@ -293,8 +325,10 @@ private static void renderRoster(Session session) { Component label = reason == null ? Component.text("▶ Play", TEAL, TextDecoration.BOLD) : Component.text("▶ Play", DIM); String action = reason == null ? "Play as " + name(selected) : reason; + SwitchCooldown cooldown = reason == null ? session.actions.switchCooldown(selected) : null; + if (cooldown != null) action += "\nStarts your " + cooldown.hyphenated() + " character switch cooldown"; buttons.add(button(label, action + "\n" + details(player.getUniqueId(), selected), 110, session, - s -> play(s, id))); + s -> play(s, id, null))); } else { String createBlock = createBlockReason(player, data); @@ -378,6 +412,48 @@ private static void renderEnding(Session session) { show(session, Component.text("End " + name + "?", PALE, TextDecoration.BOLD), body, buttons, null, buttons.size()); } + /** Warns before a switch that starts the cooldown; one confirmation, since it is no permanent loss. */ + private static void renderSwitchWarning(Session session) { + RPCharacter character = session.data.getCharacterById(session.confirming); + SwitchCooldown cooldown = character == null || character.getStatus() != Status.ALIVE + || session.actions.blockReason(character) != null ? null : session.actions.switchCooldown(character); + if (cooldown == null) { + // Nothing left to confirm here; the roster shows why the character can't be played, if it can't. + session.confirming = null; + if (TERMS_CHANGED.equals(session.notice)) session.notice = null; + renderRoster(session); + return; + } + String id = character.getId(); + String name = name(character); + String others = cooldown.leaving() == null ? "another character" + : "another character, including " + cooldown.leaving() + ","; + // Prison-marked characters never fall under the cooldown, so the warning must not promise otherwise. + List jailed = alive(session.data).stream() + .filter(c -> c.isPrisonSwitch() && !c.getId().equalsIgnoreCase(id)).map(CharacterSelectView::name).toList(); + List body = new ArrayList<>(); + if (session.notice != null) body.add(text(Component.text(session.notice, WARN))); + body.add(text(Component.text("⚠ Switching starts your " + cooldown.hyphenated() + " character switch cooldown.", + WARN, TextDecoration.BOLD))); + body.add(text(Component.text("For " + cooldown.length() + " after you switch to " + name + ", you can't switch to " + + others + " or create a new one.", MUTED))); + if (!jailed.isEmpty()) body.add(text(Component.text(listed(jailed) + (jailed.size() == 1 ? " is" : " are") + + " in prison, so you can still switch to them during the cooldown.", MUTED))); + List buttons = List.of( + button(Component.text("Switch", WARN, TextDecoration.BOLD), "Play as " + name + " and start the cooldown", + 100, session, s -> play(s, id, cooldown)), + button(Component.text("Back", TEAL, TextDecoration.BOLD), "Back to your characters", 100, session, + s -> navigate(s, v -> v.confirming = null))); + show(session, Component.text("Switch to " + name + "?", PALE, TextDecoration.BOLD), body, buttons, null, + buttons.size()); + } + + /** "Aria", "Aria and Bram", "Aria, Bram and Cole". */ + static String listed(List names) { + if (names.size() < 2) return String.join("", names); + return String.join(", ", names.subList(0, names.size() - 1)) + " and " + names.getLast(); + } + private static ActionButton leave(Session session) { return button(Component.text("Leave", MUTED), "Disconnect from the server", 80, session, s -> { end(s); @@ -712,9 +788,14 @@ static String formatPlaytime(int seconds) { return (minutes / 1440) + "d " + (minutes / 60 % 24) + "h " + (minutes % 60) + "m"; } - /** Re-resolves by id so a stale button can't play a character that has since changed. */ - private static void play(Session session, String characterId) { + /** + * Re-resolves by id so a stale button can't play a character that has since changed. A switch that + * starts the cooldown opens the warning page first, unless the player {@code accepted} the cooldown + * length it would start now; a changed length (a rank change while the page was open) is shown again. + */ + private static void play(Session session, String characterId, SwitchCooldown accepted) { RPCharacter character = session.data.getCharacterById(characterId); + session.confirming = null; if (character == null || character.getStatus() != Status.ALIVE) { notice(session, "That character can no longer be played."); return; @@ -724,6 +805,15 @@ private static void play(Session session, String characterId) { notice(session, reason); return; } + SwitchCooldown current = session.actions.switchCooldown(character); + // Only the length is agreed to; a renamed or fallen character left behind is no reason to ask again. + if (current != null && (accepted == null || current.days() != accepted.days())) { + session.revision++; + session.confirming = characterId; + session.notice = accepted == null ? null : TERMS_CHANGED; + render(session); + return; + } end(session); session.player.closeDialog(); session.actions.play(character); diff --git a/src/main/java/net/tfminecraft/rpcharacters/prison/PrisonSwitch.java b/src/main/java/net/tfminecraft/rpcharacters/prison/PrisonSwitch.java index 025b409..880fb1f 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/prison/PrisonSwitch.java +++ b/src/main/java/net/tfminecraft/rpcharacters/prison/PrisonSwitch.java @@ -57,16 +57,31 @@ public static boolean cooldownApplies(PlayerData account, String lastId, RPChara return from != null && from.getStatus() == Status.ALIVE; } + /** + * Id of the character a move into {@code target} would start the switch cooldown from, or null when + * entering it starts none. A null {@code target} stands for a character that does not exist yet. + * {@link #entered} starts the cooldown exactly when this is not null. + */ + public static String cooldownStartsFrom(PlayerData account, String lastId, RPCharacter target) { + if (lastId == null) { + return null; + } + if (target != null && (target.isPrisonSwitch() || target.getId().equalsIgnoreCase(lastId))) { + return null; + } + String anchor = cooldownAnchor(account, lastId); + return anchor == null || (target != null && anchor.equalsIgnoreCase(target.getId())) ? null : anchor; + } + /** 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); + account.setLastFreeCharacterId(cooldownAnchor(account, lastId)); } return; } - if (lastId != null && anchor != null && !anchor.equalsIgnoreCase(target.getId())) { + if (cooldownStartsFrom(account, lastId, target) != null) { account.recordCharacterSwitch(); } account.setLastFreeCharacterId(target.getId()); diff --git a/src/test/java/net/tfminecraft/rpcharacters/body/CharacterBodiesTest.java b/src/test/java/net/tfminecraft/rpcharacters/body/CharacterBodiesTest.java index 6ada55f..71e937a 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/body/CharacterBodiesTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/body/CharacterBodiesTest.java @@ -578,6 +578,31 @@ 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)); } + @Test void switchWarningFollowsTheCooldownRuleAndTheRanksLength() throws Exception { + ready();when(a.getName()).thenReturn("Aria"); + groups.when(()->PermissionGroupService.getCharacterSwitchCooldownDays(player)).thenReturn(7); + var fromAria=new CharacterSelectView.SwitchCooldown(7,"Aria"); + assertNull(actions.switchCooldown(a));assertEquals(fromAria,actions.switchCooldown(b));assertEquals(fromAria,actions.switchCooldown(null)); + when(b.isPrisonSwitch()).thenReturn(true);assertNull(actions.switchCooldown(b));when(b.isPrisonSwitch()).thenReturn(false); + when(a.getName()).thenReturn(" ");assertEquals(new CharacterSelectView.SwitchCooldown(7,null),actions.switchCooldown(b));when(a.getName()).thenReturn("Aria"); + // A dead previous character still starts the cooldown (as entered() does), but is not named. + when(a.getStatus()).thenReturn(Status.DEAD);assertEquals(new CharacterSelectView.SwitchCooldown(7,null),actions.switchCooldown(b));when(a.getStatus()).thenReturn(Status.ALIVE); + when(player.hasPermission("rpcharacters.no_cooldown")).thenReturn(true);assertNull(actions.switchCooldown(b));when(player.hasPermission("rpcharacters.no_cooldown")).thenReturn(false); + groups.when(()->PermissionGroupService.getCharacterSwitchCooldownDays(player)).thenReturn(0);assertNull(actions.switchCooldown(b)); + groups.when(()->PermissionGroupService.getCharacterSwitchCooldownDays(player)).thenReturn(1); + assertEquals(new CharacterSelectView.SwitchCooldown(1,"Aria"),actions.switchCooldown(b)); + tag=new BodyTag.Room(null);assertNull(actions.switchCooldown(b));assertNull(actions.switchCooldown(null)); + tag=new BodyTag.Live(first,1);assertNull(actions.switchCooldown(b)); + tags.when(()->BodyTag.read(player)).thenThrow(new IOException("unreadable"));assertNull(actions.switchCooldown(b)); + } + @Test void leavingAPrisonCharacterWarnsFromTheCharacterPlayedBeforePrison() throws Exception { + ready();UUID third=UUID.randomUUID();RPCharacter c=character(third);account.addCharacter(c); + when(b.getName()).thenReturn("Bram");when(a.isPrisonSwitch()).thenReturn(true);account.setLastFreeCharacterId(second.toString()); + groups.when(()->PermissionGroupService.getCharacterSwitchCooldownDays(player)).thenReturn(7); + assertEquals(new BodyTag.Room(first),tag); + assertEquals(new CharacterSelectView.SwitchCooldown(7,"Bram"),actions.switchCooldown(c)); + assertNull(actions.switchCooldown(b));assertNull(actions.switchCooldown(a)); + } 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))); diff --git a/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java b/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java index 3ed336f..f6102e6 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java @@ -199,6 +199,7 @@ public void leave() {} }; assertFalse(owner.needsDiscordLink()); owner.linkDiscord(); + assertNull(owner.switchCooldown(null), "selector owners without a cooldown never warn"); } private static java.util.stream.Stream components(Component c) { return java.util.stream.Stream.concat(java.util.stream.Stream.of(c), c.children().stream().flatMap(CharacterSelectViewTest::components)); @@ -701,6 +702,248 @@ void newCharacterFollowsSlotsAndCooldownAndRechecksWhenClicked() { verify(player, never()).closeDialog(); } + @Test + void aSwitchThatStartsTheCooldownWarnsOnceAndOnlyConfirmPlays() { + RPCharacter thalrix = character("thalrix", "Thalrix", Status.ALIVE); + when(actions.switchCooldown(thalrix)).thenReturn(new CharacterSelectView.SwitchCooldown(7, "Miras")); + CharacterSelectView.open(player, data, actions); + assertEquals("Play as Thalrix\nStarts your 7-day character switch cooldown", + String.join("\n", text(button("▶ Play").tooltip()).lines().limit(2).toList())); + + click("▶ Play"); + verify(actions, never()).play(any()); + assertEquals("Switch to Thalrix?", text(ui.last().base().title())); + assertEquals("⚠ Switching starts your 7-day character switch cooldown.\n" + + "For 7 days after you switch to Thalrix, you can't switch to another character, including Miras, or create a new one.", + ui.last().bodyText()); + assertEquals(List.of("Switch", "Back"), labels()); + assertEquals(2, columns()); + assertEquals("Play as Thalrix and start the cooldown", text(button("Switch").tooltip())); + ActionButton staleSwitch = button("Switch"); + click("Back"); + assertEquals("Choose your character", text(ui.last().base().title())); + ui.click(staleSwitch, player); + verify(actions, never()).play(any()); + + click("▶ Play"); + when(actions.blockReason(thalrix)).thenReturn("Your current roleplay situation must finish first."); + click("Switch"); + verify(actions, never()).play(any()); + assertEquals("Choose your character", text(ui.last().base().title())); + assertTrue(ui.last().bodyText().startsWith("Your current roleplay situation must finish first.\n")); + + when(actions.blockReason(thalrix)).thenReturn(null); + click("▶ Play"); + click("Switch"); + verify(actions).play(thalrix); + verify(player).closeDialog(); + assertNull(CharacterSelectView.session(OWNER)); + } + + @Test + void theWarningNamesNobodyForADeadCharacterAndUsesOneDay() { + RPCharacter wren = character("wren", "Wren", Status.ALIVE); + when(actions.switchCooldown(wren)).thenReturn(new CharacterSelectView.SwitchCooldown(1, null)); + CharacterSelectView.open(player, data, actions); + click("▶ Play"); + assertEquals("⚠ Switching starts your 1-day character switch cooldown.\n" + + "For 1 day after you switch to Wren, you can't switch to another character or create a new one.", ui.last().bodyText()); + } + + @Test + void theWarningPageStepsBackWhenItNoLongerApplies() { + RPCharacter wren = character("wren", "Wren", Status.ALIVE); + var cooldown = new java.util.concurrent.atomic.AtomicReference<>(new CharacterSelectView.SwitchCooldown(3, "Miras")); + when(actions.switchCooldown(wren)).thenAnswer(call -> cooldown.get()); + CharacterSelectView.open(player, data, actions); + click("▶ Play"); + assertEquals("Switch to Wren?", text(ui.last().base().title())); + cooldown.set(null); + CharacterSelectView.refreshAll(); + assertEquals("Choose your character", text(ui.last().base().title())); + assertEquals("Play as Wren", text(button("▶ Play").tooltip()).lines().findFirst().orElseThrow()); + + cooldown.set(new CharacterSelectView.SwitchCooldown(3, "Miras")); + click("▶ Play"); + when(wren.getStatus()).thenReturn(Status.DEAD); + CharacterSelectView.refreshAll(); + assertEquals("Choose your character", text(ui.last().base().title())); + verify(actions, never()).play(any()); + } + + @Test + void changedTermsAreShownAgainBeforeSwitchingAndDroppedTermsSwitchAtOnce() { + RPCharacter wren = character("wren", "Wren", Status.ALIVE); + var cooldown = new java.util.concurrent.atomic.AtomicReference<>(new CharacterSelectView.SwitchCooldown(1, "Miras")); + when(actions.switchCooldown(wren)).thenAnswer(call -> cooldown.get()); + CharacterSelectView.open(player, data, actions); + click("▶ Play"); + assertTrue(ui.last().bodyText().contains("For 1 day after")); + assertFalse(ui.last().bodyText().contains(CharacterSelectView.TERMS_CHANGED)); + cooldown.set(new CharacterSelectView.SwitchCooldown(7, "Miras")); + click("Switch"); + verify(actions, never()).play(any()); + assertEquals("Switch to Wren?", text(ui.last().base().title())); + assertTrue(ui.last().bodyText().startsWith("Your cooldown has changed. Check it before switching.\n" + + "⚠ Switching starts your 7-day character switch cooldown."), ui.last().bodyText()); + // A refresh keeps the re-asked page and its notice, and its new Switch still works. + CharacterSelectView.refreshAll(); + assertTrue(ui.last().bodyText().startsWith(CharacterSelectView.TERMS_CHANGED + "\n"), ui.last().bodyText()); + click("Switch"); + verify(actions).play(wren); + + CharacterSelectView.open(player, data, actions); + click("▶ Play"); + cooldown.set(null); + click("Switch"); + verify(actions, times(2)).play(wren); + } + + @Test + void onlyALengthChangeAsksAgainAndBackFromTheReaskedPageClearsItsNotice() { + RPCharacter wren = character("wren", "Wren", Status.ALIVE); + var cooldown = new java.util.concurrent.atomic.AtomicReference<>(new CharacterSelectView.SwitchCooldown(7, "Miras")); + when(actions.switchCooldown(wren)).thenAnswer(call -> cooldown.get()); + CharacterSelectView.open(player, data, actions); + click("▶ Play"); + cooldown.set(new CharacterSelectView.SwitchCooldown(7, null)); + click("Switch"); + verify(actions).play(wren); + + cooldown.set(new CharacterSelectView.SwitchCooldown(7, "Miras")); + CharacterSelectView.open(player, data, actions); + click("▶ Play"); + cooldown.set(new CharacterSelectView.SwitchCooldown(3, "Miras")); + click("Switch"); + assertTrue(ui.last().bodyText().startsWith(CharacterSelectView.TERMS_CHANGED + "\n"), ui.last().bodyText()); + click("Back"); + assertEquals("Choose your character", text(ui.last().base().title())); + assertFalse(ui.last().bodyText().contains(CharacterSelectView.TERMS_CHANGED), ui.last().bodyText()); + verify(actions, times(1)).play(wren); + } + + @Test + void aWarningThatStopsApplyingDropsItsChangedNoticeButKeepsOtherNotices() { + RPCharacter wren = character("wren", "Wren", Status.ALIVE); + var cooldown = new java.util.concurrent.atomic.AtomicReference<>(new CharacterSelectView.SwitchCooldown(7, "Miras")); + when(actions.switchCooldown(wren)).thenAnswer(call -> cooldown.get()); + CharacterSelectView.open(player, data, actions); + click("▶ Play"); + cooldown.set(new CharacterSelectView.SwitchCooldown(2, "Miras")); + click("Switch"); + cooldown.set(null); + CharacterSelectView.refreshAll(); + assertEquals("Choose your character", text(ui.last().base().title())); + assertFalse(ui.last().bodyText().contains(CharacterSelectView.TERMS_CHANGED), ui.last().bodyText()); + + cooldown.set(new CharacterSelectView.SwitchCooldown(7, "Miras")); + click("▶ Play"); + CharacterSelectView.notice(player, "Staff marked Aria for prison."); + assertEquals("Switch to Wren?", text(ui.last().base().title())); + assertTrue(ui.last().bodyText().startsWith("Staff marked Aria for prison.\n"), ui.last().bodyText()); + cooldown.set(null); + CharacterSelectView.refreshAll(); + assertEquals("Choose your character", text(ui.last().base().title())); + assertTrue(ui.last().bodyText().startsWith("Staff marked Aria for prison.\n"), ui.last().bodyText()); + } + + @Test + void aBlockThatAppearsWhileTheWarningIsOpenReturnsToTheRoster() { + RPCharacter wren = character("wren", "Wren", Status.ALIVE); + when(actions.switchCooldown(wren)).thenReturn(new CharacterSelectView.SwitchCooldown(7, "Miras")); + CharacterSelectView.open(player, data, actions); + click("▶ Play"); + when(actions.blockReason(wren)).thenReturn("Your current roleplay situation must finish first."); + CharacterSelectView.refreshAll(); + assertEquals("Choose your character", text(ui.last().base().title())); + assertTrue(ui.last().bodyText().contains("Your current roleplay situation must finish first."), ui.last().bodyText()); + assertEquals(CharacterSelectView.DIM, button("▶ Play").label().color()); + verify(actions, never()).play(any()); + } + + @Test + void theWarningSaysPrisonMarkedCharactersStayOpen() { + RPCharacter wren = character("wren", "Wren", Status.ALIVE); + RPCharacter aria = character("aria", "Aria", Status.ALIVE); + when(aria.isPrisonSwitch()).thenReturn(true); + when(wren.getLastPlayedAt()).thenReturn(Instant.now()); + when(actions.switchCooldown(wren)).thenReturn(new CharacterSelectView.SwitchCooldown(7, "Miras")); + CharacterSelectView.open(player, data, actions); + click("▶ Play"); + assertTrue(ui.last().bodyText().endsWith("or create a new one.\nAria is in prison, so you can still switch to them during the cooldown."), ui.last().bodyText()); + + RPCharacter bram = character("bram", "Bram", Status.ALIVE); + when(bram.isPrisonSwitch()).thenReturn(true); + RPCharacter gone = character("gone", "Gone", Status.DEAD); + when(gone.isPrisonSwitch()).thenReturn(true); + CharacterSelectView.open(player, data, actions); + click("▶ Play"); + assertTrue(ui.last().bodyText().endsWith("or create a new one.\nAria and Bram are in prison, so you can still switch to them during the cooldown."), ui.last().bodyText()); + } + + @Test + void listedJoinsNamesWithCommasAndAFinalAnd() { + assertEquals("", CharacterSelectView.listed(List.of())); + assertEquals("Aria", CharacterSelectView.listed(List.of("Aria"))); + assertEquals("Aria and Bram", CharacterSelectView.listed(List.of("Aria", "Bram"))); + assertEquals("Aria, Bram and Cole", CharacterSelectView.listed(List.of("Aria", "Bram", "Cole"))); + } + + @Test + void aRefreshKeepsTheWarningUsableAndADeathBeforeConfirmIsReported() { + RPCharacter wren = character("wren", "Wren", Status.ALIVE); + when(actions.switchCooldown(wren)).thenReturn(new CharacterSelectView.SwitchCooldown(3, "Miras")); + CharacterSelectView.open(player, data, actions); + click("▶ Play"); + ActionButton beforeRefresh = button("Switch"); + CharacterSelectView.refreshAll(); + assertEquals("Switch to Wren?", text(ui.last().base().title())); + ui.click(beforeRefresh, player); + verify(actions, never()).play(any()); + + when(wren.getStatus()).thenReturn(Status.DEAD); + click("Switch"); + verify(actions, never()).play(any()); + assertEquals("Choose your character", text(ui.last().base().title())); + assertTrue(ui.last().bodyText().startsWith("That character can no longer be played.\n")); + + when(wren.getStatus()).thenReturn(Status.ALIVE); + CharacterSelectView.open(player, data, actions); + click("▶ Play"); + CharacterSelectView.refreshAll(); + click("Switch"); + verify(actions).play(wren); + } + + @Test + void resumingOrAWarningFreeSwitchPlaysAtOnceAndBlockedPlayShowsNoCooldownLine() { + RPCharacter wren = character("wren", "Wren", Status.ALIVE); + CharacterSelectView.open(player, data, actions); + click("▶ Play"); + verify(actions).play(wren); + + when(actions.switchCooldown(wren)).thenReturn(new CharacterSelectView.SwitchCooldown(7, "Miras")); + when(actions.blockReason(wren)).thenReturn("You can switch in 1d 0h 0m. You can resume your current character."); + CharacterSelectView.open(player, data, actions); + assertFalse(text(button("▶ Play").tooltip()).contains("switch cooldown")); + } + + @Test + void newCharacterPageSaysPlayingItStartsTheCooldownUnlessCreationIsBlocked() { + character("wren", "Wren", Status.ALIVE); + when(actions.switchCooldown(null)).thenReturn(new CharacterSelectView.SwitchCooldown(5, "Wren")); + CharacterSelectView.open(player, data, actions); + link("New character"); + assertTrue(ui.last().bodyText().endsWith("Review your choices before you begin.\n" + + "Playing your new character starts your 5-day character switch cooldown."), ui.last().bodyText()); + + groups.when(() -> PermissionGroupService.hasCharacterSwitchCooldown(player, data)).thenReturn(true); + groups.when(() -> PermissionGroupService.getRemainingCooldownMinutes(player, data)).thenReturn(60); + CharacterSelectView.open(player, data, actions); + link("New character"); + assertFalse(ui.last().bodyText().contains("Playing your new character"), ui.last().bodyText()); + } + @Test void firstCharacterIgnoresSwitchCooldown() { groups.when(() -> PermissionGroupService.hasCharacterSwitchCooldown(player, data)).thenReturn(true); diff --git a/src/test/java/net/tfminecraft/rpcharacters/prison/PrisonSwitchTest.java b/src/test/java/net/tfminecraft/rpcharacters/prison/PrisonSwitchTest.java index fef8360..0092187 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/prison/PrisonSwitchTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/prison/PrisonSwitchTest.java @@ -46,6 +46,38 @@ RPCharacter character(String id, String name) { PrisonSwitch.entered(account, "a", b); assertNotNull(account.getLastCharacterSwitchAtMs()); assertEquals("b", account.getLastFreeCharacterId()); } + @Test void cooldownStartsFromNamesTheCharacterLeftExactlyWhenEnteringRecordsASwitch() { + assertEquals("a", PrisonSwitch.cooldownStartsFrom(account, "a", b)); assertEquals("a", PrisonSwitch.cooldownStartsFrom(account, "a", null)); + assertNull(PrisonSwitch.cooldownStartsFrom(account, "a", a)); assertNull(PrisonSwitch.cooldownStartsFrom(account, null, b)); + assertNull(PrisonSwitch.cooldownStartsFrom(account, null, null)); + // A dead or missing previous character still starts it, as entered() records it. + when(a.getStatus()).thenReturn(Status.DEAD); assertEquals("a", PrisonSwitch.cooldownStartsFrom(account, "a", b)); + assertEquals("gone", PrisonSwitch.cooldownStartsFrom(account, "gone", b)); + b.setPrisonSwitch(true); assertNull(PrisonSwitch.cooldownStartsFrom(account, "a", b)); + // From prison the anchor is the character entered before it, and going back there is free. + account.setLastFreeCharacterId("a"); assertNull(PrisonSwitch.cooldownStartsFrom(account, "b", a)); + assertEquals("a", PrisonSwitch.cooldownStartsFrom(account, "b", c)); assertEquals("a", PrisonSwitch.cooldownStartsFrom(account, "b", null)); + account.setLastFreeCharacterId("b"); assertNull(PrisonSwitch.cooldownStartsFrom(account, "b", c)); + } + + @Test void theWarningRuleAndEnteredAgreeForEveryStartingState() { + List ids = Arrays.asList(null, "a", "b", "gone"); + int cases = 0, warned = 0; + for (boolean prisonB : List.of(false, true)) for (Status statusA : List.of(Status.ALIVE, Status.DEAD)) { + b.setPrisonSwitch(prisonB); when(a.getStatus()).thenReturn(statusA); + for (String lastFree : ids) for (String last : ids) for (RPCharacter target : List.of(a, b, c)) { + PlayerData copy = new PlayerData(UUID.randomUUID()); copy.addCharacter(a); copy.addCharacter(b); copy.addCharacter(c); + copy.setLastFreeCharacterId(lastFree); + boolean warns = PrisonSwitch.cooldownStartsFrom(copy, last, target) != null; + PrisonSwitch.entered(copy, last, target); + assertEquals(warns, copy.getLastCharacterSwitchAtMs() != null, + "prisonB=" + prisonB + " a=" + statusA + " lastFree=" + lastFree + " last=" + last + " -> " + target.getId()); + cases++; if (warns) warned++; + } + } + assertEquals(192, cases); assertTrue(warned > 0 && warned < cases); + } + @Test void idsMatchWhateverCaseTheyWereStoredIn() { RPCharacter upper = character("D1", "Dara"); account.addCharacter(upper); assertFalse(PrisonSwitch.cooldownApplies(account, "d1", upper)); assertFalse(PrisonSwitch.cooldownApplies(account, "a", a));