From 46cbd515989cfcd84a122d83eba593b565b98881 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:11:29 +0100 Subject: [PATCH 1/6] Warn before a character switch that starts the cooldown Clicking Play on a character that would start the switch cooldown now opens a "Switch to ?" page that states the rank's cooldown length and that no other character can be played for that time. Switch enters the character after rechecking it; Back returns to the roster. The Play tooltip and the New character page say the same ahead of time. PrisonSwitch.cooldownStartsFrom is the one rule for when entering a character starts the cooldown; entered() and the warning both use it. No warning for staff (cooldown 0), no_cooldown, resuming the last character, or prison-marked moves. Co-Authored-By: Claude Opus 5.5 --- .../rpcharacters/body/CharacterBodies.java | 24 +++++ .../body/CharacterSelectView.java | 74 +++++++++++++- .../rpcharacters/prison/PrisonSwitch.java | 21 +++- .../body/CharacterBodiesTest.java | 17 ++++ .../body/CharacterSelectViewTest.java | 98 +++++++++++++++++++ .../rpcharacters/prison/PrisonSwitchTest.java | 20 ++++ 6 files changed, 248 insertions(+), 6 deletions(-) diff --git a/src/main/java/net/tfminecraft/rpcharacters/body/CharacterBodies.java b/src/main/java/net/tfminecraft/rpcharacters/body/CharacterBodies.java index f95f67f..7ca3bb3 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); @@ -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..f84539f 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 span() { + return days + "-day"; + } } /** Loads one character's 16x32 front view, completing with null when there is none. */ @@ -150,6 +172,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 +272,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 +305,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.span() + " character switch cooldown.", WARN))); + } } else { reason = session.actions.blockReason(selected); body.add(DialogBody.plainMessage(portrait(session, selected), SkinPortrait.ROW_WIDTH)); @@ -293,8 +323,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.span() + " switch cooldown"; buttons.add(button(label, action + "\n" + details(player.getUniqueId(), selected), 110, session, - s -> play(s, id))); + s -> play(s, id, false))); } else { String createBlock = createBlockReason(player, data); @@ -378,6 +410,34 @@ 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 ? null + : session.actions.switchCooldown(character); + if (cooldown == null) { + session.confirming = null; + renderRoster(session); + return; + } + String id = character.getId(); + String name = name(character); + String others = cooldown.leaving() == null ? "any other character" + : "any other character, " + cooldown.leaving() + " included,"; + List body = new ArrayList<>(); + body.add(text(Component.text("⚠ Switching starts your " + cooldown.span() + " character switch cooldown.", + WARN, TextDecoration.BOLD))); + body.add(text(Component.text("Once you play " + name + ", you can't switch to " + others + " for " + + cooldown.length() + ".", 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, true)), + 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()); + } + private static ActionButton leave(Session session) { return button(Component.text("Leave", MUTED), "Disconnect from the server", 80, session, s -> { end(s); @@ -712,9 +772,13 @@ 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 confirmed} it there. + */ + private static void play(Session session, String characterId, boolean confirmed) { 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 +788,10 @@ private static void play(Session session, String characterId) { notice(session, reason); return; } + if (!confirmed && session.actions.switchCooldown(character) != null) { + navigate(session, v -> v.confirming = characterId); + 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..34308b0 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/body/CharacterBodiesTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/body/CharacterBodiesTest.java @@ -578,6 +578,23 @@ 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)); + } 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..c1eb228 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java @@ -701,6 +701,104 @@ 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 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" + + "Once you play Thalrix, you can't switch to any other character, Miras included, for 7 days.", + 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" + + "Once you play Wren, you can't switch to any other character for 1 day.", 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 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..7202446 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/prison/PrisonSwitchTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/prison/PrisonSwitchTest.java @@ -46,6 +46,26 @@ 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)); + for (String last : List.of("a", "b", "gone")) for (RPCharacter target : List.of(a, b, c)) { + PlayerData copy = new PlayerData(UUID.randomUUID()); copy.addCharacter(a); copy.addCharacter(b); copy.addCharacter(c); + boolean warns = PrisonSwitch.cooldownStartsFrom(copy, last, target) != null; + PrisonSwitch.entered(copy, last, target); + assertEquals(warns, copy.getLastCharacterSwitchAtMs() != null, last + " -> " + target.getId()); + } + } + @Test void idsMatchWhateverCaseTheyWereStoredIn() { RPCharacter upper = character("D1", "Dara"); account.addCharacter(upper); assertFalse(PrisonSwitch.cooldownApplies(account, "d1", upper)); assertFalse(PrisonSwitch.cooldownApplies(account, "a", a)); From 48d7dc80fd7092458056ca8f6be12256b7c8443d Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:20:57 +0100 Subject: [PATCH 2/6] Clearer cooldown warning wording, wider rule and refresh tests Co-Authored-By: Claude Opus 5.5 --- .../body/CharacterSelectView.java | 14 ++++---- .../body/CharacterSelectViewTest.java | 32 +++++++++++++++++-- .../rpcharacters/prison/PrisonSwitchTest.java | 22 ++++++++++--- 3 files changed, 53 insertions(+), 15 deletions(-) diff --git a/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java b/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java index f84539f..f745fff 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java +++ b/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java @@ -109,7 +109,7 @@ String length() { } /** "7-day", before the word cooldown. */ - String span() { + String hyphenated() { return days + "-day"; } } @@ -308,7 +308,7 @@ private static void renderRoster(Session session) { else { SwitchCooldown cooldown = session.actions.switchCooldown(null); if (cooldown != null) body.add(text(Component.text("Playing your new character starts your " - + cooldown.span() + " character switch cooldown.", WARN))); + + cooldown.hyphenated() + " character switch cooldown.", WARN))); } } else { reason = session.actions.blockReason(selected); @@ -324,7 +324,7 @@ private static void renderRoster(Session session) { : 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.span() + " switch cooldown"; + 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, false))); } @@ -422,12 +422,12 @@ private static void renderSwitchWarning(Session session) { } String id = character.getId(); String name = name(character); - String others = cooldown.leaving() == null ? "any other character" - : "any other character, " + cooldown.leaving() + " included,"; + String others = cooldown.leaving() == null ? "another character" + : "another character (" + cooldown.leaving() + " included)"; List body = new ArrayList<>(); - body.add(text(Component.text("⚠ Switching starts your " + cooldown.span() + " character switch cooldown.", + body.add(text(Component.text("⚠ Switching starts your " + cooldown.hyphenated() + " character switch cooldown.", WARN, TextDecoration.BOLD))); - body.add(text(Component.text("Once you play " + name + ", you can't switch to " + others + " for " + body.add(text(Component.text("Once you play " + name + ", you can't switch to " + others + " or create a new one for " + cooldown.length() + ".", MUTED))); List buttons = List.of( button(Component.text("Switch", WARN, TextDecoration.BOLD), "Play as " + name + " and start the cooldown", diff --git a/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java b/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java index c1eb228..f93d3ab 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java @@ -706,14 +706,14 @@ 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 switch cooldown", + 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" - + "Once you play Thalrix, you can't switch to any other character, Miras included, for 7 days.", + + "Once you play Thalrix, you can't switch to another character (Miras included) or create a new one for 7 days.", ui.last().bodyText()); assertEquals(List.of("Switch", "Back"), labels()); assertEquals(2, columns()); @@ -746,7 +746,7 @@ void theWarningNamesNobodyForADeadCharacterAndUsesOneDay() { CharacterSelectView.open(player, data, actions); click("▶ Play"); assertEquals("⚠ Switching starts your 1-day character switch cooldown.\n" - + "Once you play Wren, you can't switch to any other character for 1 day.", ui.last().bodyText()); + + "Once you play Wren, you can't switch to another character or create a new one for 1 day.", ui.last().bodyText()); } @Test @@ -770,6 +770,32 @@ void theWarningPageStepsBackWhenItNoLongerApplies() { verify(actions, never()).play(any()); } + @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); diff --git a/src/test/java/net/tfminecraft/rpcharacters/prison/PrisonSwitchTest.java b/src/test/java/net/tfminecraft/rpcharacters/prison/PrisonSwitchTest.java index 7202446..0092187 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/prison/PrisonSwitchTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/prison/PrisonSwitchTest.java @@ -58,12 +58,24 @@ RPCharacter character(String id, String name) { 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)); - for (String last : List.of("a", "b", "gone")) for (RPCharacter target : List.of(a, b, c)) { - PlayerData copy = new PlayerData(UUID.randomUUID()); copy.addCharacter(a); copy.addCharacter(b); copy.addCharacter(c); - boolean warns = PrisonSwitch.cooldownStartsFrom(copy, last, target) != null; - PrisonSwitch.entered(copy, last, target); - assertEquals(warns, copy.getLastCharacterSwitchAtMs() != null, last + " -> " + target.getId()); + } + + @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() { From 93c777581ec9d82d4f287b9679c7fa9ca33fc42a Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:29:33 +0100 Subject: [PATCH 3/6] Re-ask when the cooldown terms changed, mention characters in prison Switch now carries the terms the player saw; if the cooldown that would start differs (a rank change while the page was open), the page shows the new terms with a notice instead of switching. The warning names any living prison-marked characters, which stay open during the cooldown. Co-Authored-By: Claude Opus 5.5 --- .../body/CharacterSelectView.java | 23 +++++++--- .../body/CharacterSelectViewTest.java | 44 +++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java b/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java index f745fff..0d67952 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java +++ b/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java @@ -326,7 +326,7 @@ private static void renderRoster(Session session) { 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, false))); + s -> play(s, id, null))); } else { String createBlock = createBlockReason(player, data); @@ -424,14 +424,20 @@ private static void renderSwitchWarning(Session session) { String name = name(character); String others = cooldown.leaving() == null ? "another character" : "another character (" + cooldown.leaving() + " included)"; + // 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("Once you play " + name + ", you can't switch to " + others + " or create a new one for " + cooldown.length() + ".", MUTED))); + if (!jailed.isEmpty()) body.add(text(Component.text(String.join(", ", jailed) + + (jailed.size() == 1 ? " is" : " are") + " in prison, so you can still switch to them.", 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, true)), + 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, @@ -774,9 +780,10 @@ static String formatPlaytime(int seconds) { /** * 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 confirmed} it there. + * starts the cooldown opens the warning page first, unless the player {@code accepted} exactly the + * terms it would start now; changed terms (a rank change while the page was open) are shown again. */ - private static void play(Session session, String characterId, boolean confirmed) { + 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) { @@ -788,8 +795,12 @@ private static void play(Session session, String characterId, boolean confirmed) notice(session, reason); return; } - if (!confirmed && session.actions.switchCooldown(character) != null) { - navigate(session, v -> v.confirming = characterId); + SwitchCooldown current = session.actions.switchCooldown(character); + if (current != null && !current.equals(accepted)) { + session.revision++; + session.confirming = characterId; + session.notice = accepted == null ? null : "Your cooldown has changed. Check it before switching."; + render(session); return; } end(session); diff --git a/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java b/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java index f93d3ab..6e2f989 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java @@ -770,6 +770,50 @@ void theWarningPageStepsBackWhenItNoLongerApplies() { 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.")); + 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()); + 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 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("for 7 days.\nAria is in prison, so you can still switch to them."), 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().matches("(?s).*\n(Aria, Bram|Bram, Aria) are in prison, so you can still switch to them\\."), ui.last().bodyText()); + } + @Test void aRefreshKeepsTheWarningUsableAndADeathBeforeConfirmIsReported() { RPCharacter wren = character("wren", "Wren", Status.ALIVE); From 220785fff0f669cd7bbf375670b9c085b82685a0 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Thu, 12 Nov 2026 22:47:29 +0100 Subject: [PATCH 4/6] Dual review round 1 fixes for the switch warning - Ask again only when the cooldown length changed, not when the name of the character left behind changed. - Drop the changed-terms notice when the warning page closes itself. - Fall back to the roster when a block appears while the page is open. - Join prison names with "and"; clearer main sentence ('For 7 days after you play X, ...'); prison line says 'during the cooldown'. - Use the no_cooldown permission constant in blockReason too. Co-Authored-By: Claude Opus 5.5 --- .../rpcharacters/body/CharacterBodies.java | 2 +- .../body/CharacterSelectView.java | 29 +++++-- .../body/CharacterBodiesTest.java | 8 ++ .../body/CharacterSelectViewTest.java | 84 +++++++++++++++++-- 4 files changed, 108 insertions(+), 15 deletions(-) diff --git a/src/main/java/net/tfminecraft/rpcharacters/body/CharacterBodies.java b/src/main/java/net/tfminecraft/rpcharacters/body/CharacterBodies.java index 7ca3bb3..7fc0a02 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/body/CharacterBodies.java +++ b/src/main/java/net/tfminecraft/rpcharacters/body/CharacterBodies.java @@ -484,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."; diff --git a/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java b/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java index 0d67952..d65ad74 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java +++ b/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java @@ -128,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); @@ -413,17 +415,19 @@ private static void renderEnding(Session session) { /** 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 ? null - : session.actions.switchCooldown(character); + 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 (" + cooldown.leaving() + " included)"; + : "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(); @@ -431,10 +435,10 @@ private static void renderSwitchWarning(Session session) { 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("Once you play " + name + ", you can't switch to " + others + " or create a new one for " - + cooldown.length() + ".", MUTED))); - if (!jailed.isEmpty()) body.add(text(Component.text(String.join(", ", jailed) - + (jailed.size() == 1 ? " is" : " are") + " in prison, so you can still switch to them.", MUTED))); + body.add(text(Component.text("For " + cooldown.length() + " after you play " + 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)), @@ -444,6 +448,12 @@ private static void renderSwitchWarning(Session session) { 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); @@ -796,10 +806,11 @@ private static void play(Session session, String characterId, SwitchCooldown acc return; } SwitchCooldown current = session.actions.switchCooldown(character); - if (current != null && !current.equals(accepted)) { + // 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 : "Your cooldown has changed. Check it before switching."; + session.notice = accepted == null ? null : TERMS_CHANGED; render(session); return; } diff --git a/src/test/java/net/tfminecraft/rpcharacters/body/CharacterBodiesTest.java b/src/test/java/net/tfminecraft/rpcharacters/body/CharacterBodiesTest.java index 34308b0..71e937a 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/body/CharacterBodiesTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/body/CharacterBodiesTest.java @@ -595,6 +595,14 @@ void useRealLeases() throws Exception { 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 6e2f989..fa96d50 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java @@ -713,7 +713,7 @@ void aSwitchThatStartsTheCooldownWarnsOnceAndOnlyConfirmPlays() { verify(actions, never()).play(any()); assertEquals("Switch to Thalrix?", text(ui.last().base().title())); assertEquals("⚠ Switching starts your 7-day character switch cooldown.\n" - + "Once you play Thalrix, you can't switch to another character (Miras included) or create a new one for 7 days.", + + "For 7 days after you play 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()); @@ -746,7 +746,7 @@ void theWarningNamesNobodyForADeadCharacterAndUsesOneDay() { CharacterSelectView.open(player, data, actions); click("▶ Play"); assertEquals("⚠ Switching starts your 1-day character switch cooldown.\n" - + "Once you play Wren, you can't switch to another character or create a new one for 1 day.", ui.last().bodyText()); + + "For 1 day after you play Wren, you can't switch to another character or create a new one.", ui.last().bodyText()); } @Test @@ -777,13 +777,17 @@ void changedTermsAreShownAgainBeforeSwitchingAndDroppedTermsSwitchAtOnce() { when(actions.switchCooldown(wren)).thenAnswer(call -> cooldown.get()); CharacterSelectView.open(player, data, actions); click("▶ Play"); - assertTrue(ui.last().bodyText().contains("for 1 day.")); + 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); @@ -794,6 +798,68 @@ void changedTermsAreShownAgainBeforeSwitchingAndDroppedTermsSwitchAtOnce() { 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); @@ -803,7 +869,7 @@ void theWarningSaysPrisonMarkedCharactersStayOpen() { when(actions.switchCooldown(wren)).thenReturn(new CharacterSelectView.SwitchCooldown(7, "Miras")); CharacterSelectView.open(player, data, actions); click("▶ Play"); - assertTrue(ui.last().bodyText().endsWith("for 7 days.\nAria is in prison, so you can still switch to them."), ui.last().bodyText()); + 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); @@ -811,7 +877,15 @@ void theWarningSaysPrisonMarkedCharactersStayOpen() { when(gone.isPrisonSwitch()).thenReturn(true); CharacterSelectView.open(player, data, actions); click("▶ Play"); - assertTrue(ui.last().bodyText().matches("(?s).*\n(Aria, Bram|Bram, Aria) are in prison, so you can still switch to them\\."), ui.last().bodyText()); + 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 From 3558876d6e91fa913baa15874da64ccc4d0139d4 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Thu, 12 Nov 2026 22:51:52 +0100 Subject: [PATCH 5/6] Warning wording: 'after you switch to', play() Javadoc matches the length-only check Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/rpcharacters/body/CharacterSelectView.java | 6 +++--- .../rpcharacters/body/CharacterSelectViewTest.java | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java b/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java index d65ad74..e387a82 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java +++ b/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java @@ -435,7 +435,7 @@ private static void renderSwitchWarning(Session session) { 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 play " + name + ", you can't switch to " + 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))); @@ -790,8 +790,8 @@ static String formatPlaytime(int seconds) { /** * 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} exactly the - * terms it would start now; changed terms (a rank change while the page was open) are shown again. + * 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); diff --git a/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java b/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java index fa96d50..0180c19 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java @@ -713,7 +713,7 @@ void aSwitchThatStartsTheCooldownWarnsOnceAndOnlyConfirmPlays() { 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 play Thalrix, you can't switch to another character, including Miras, or create a new one.", + + "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()); @@ -746,7 +746,7 @@ void theWarningNamesNobodyForADeadCharacterAndUsesOneDay() { CharacterSelectView.open(player, data, actions); click("▶ Play"); assertEquals("⚠ Switching starts your 1-day character switch cooldown.\n" - + "For 1 day after you play Wren, you can't switch to another character or create a new one.", ui.last().bodyText()); + + "For 1 day after you switch to Wren, you can't switch to another character or create a new one.", ui.last().bodyText()); } @Test From 384e338d12aa101c137fa5418257aeaa506555e8 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Thu, 12 Nov 2026 22:58:23 +0100 Subject: [PATCH 6/6] Cover the default Actions.switchCooldown (line coverage gate) Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/rpcharacters/body/CharacterSelectViewTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java b/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java index 0180c19..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));