Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -481,14 +484,35 @@ 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.";
}
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));
Expand All @@ -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);
Expand Down Expand Up @@ -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<String> jailed = alive(session.data).stream()
.filter(c -> c.isPrisonSwitch() && !c.getId().equalsIgnoreCase(id)).map(CharacterSelectView::name).toList();
List<DialogBody> 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<ActionButton> 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<String> 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);
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
Expand Down
Loading
Loading