From 93f014928304f0ab1061b310a65b5c9d3212d469 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:34:41 +0100 Subject: [PATCH 1/3] Free the first subclass after a class move, add per-character class pick admin A character's free subclass was a once-ever flag. After a base-class move the new class's first subclass was priced as a second paid change, so one class switch outside the free window cost 100 + 1000 denars. The first subclass after any base-class pick is now free; characters already stuck on a base class get it too. Sibling subclass switches stay paid changes. /rpcharacter admin classpicks [reset|freesubclass|paid ] shows or changes one online character's class pick state (resetclasses for one character, a free next subclass, or the paid change count). It never changes the class and uses the resetclasses permission. Co-Authored-By: Claude Opus 5.5 --- .../classpick/ClassPickAdminCommand.java | 172 ++++++++++++++++++ .../classpick/ClassPickService.java | 27 ++- .../rpcharacters/managers/CommandManager.java | 9 +- .../utils/CommandTabCompleter.java | 24 +++ .../classpick/ClassPickAdminCommandTest.java | 157 ++++++++++++++++ .../rpcharacters/classpick/ClassPickTest.java | 26 ++- .../utils/CommandTabCompleterTest.java | 10 +- 7 files changed, 411 insertions(+), 14 deletions(-) create mode 100644 src/main/java/net/tfminecraft/rpcharacters/classpick/ClassPickAdminCommand.java create mode 100644 src/test/java/net/tfminecraft/rpcharacters/classpick/ClassPickAdminCommandTest.java diff --git a/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassPickAdminCommand.java b/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassPickAdminCommand.java new file mode 100644 index 00000000..45ab183e --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassPickAdminCommand.java @@ -0,0 +1,172 @@ +package net.tfminecraft.rpcharacters.classpick; + +import java.time.Instant; +import java.util.List; +import java.util.Locale; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import net.tfminecraft.rpcharacters.RPCharacters; +import net.tfminecraft.rpcharacters.creation.Stage; +import net.tfminecraft.rpcharacters.creation.StageEditLock; +import net.tfminecraft.rpcharacters.creation.StageRevisions; +import net.tfminecraft.rpcharacters.identity.DisplayIdentityService; +import net.tfminecraft.rpcharacters.managers.PlayerManager; +import net.tfminecraft.rpcharacters.objects.PlayerData; +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import net.tfminecraft.rpcharacters.paidchange.PaidChangeRule; +import net.tfminecraft.rpcharacters.paidchange.PaidChangeService; +import net.tfminecraft.rpcharacters.utils.AgeFormatter; +import net.tfminecraft.rpcharacters.utils.RPTexts; + +/** + * {@code /rpcharacter admin classpicks [reset|freesubclass|paid ]}: staff read or + * change the class pick state of the character another online player is playing. + *
    + *
  • no action: show the class, the free window, the paid change count and what picks cost;
  • + *
  • {@code reset}: what {@code resetclasses} does, for this character only. Its class stage + * window starts again now, paid change prices start over and its next subclass is free;
  • + *
  • {@code freesubclass}: only make the next subclass pick free;
  • + *
  • {@code paid }: set how many paid class changes it has made, which picks the next + * price from the class paid-change rule.
  • + *
+ * The class itself is never changed. Uses the {@code resetclasses} permission. + */ +public final class ClassPickAdminCommand { + + public static final String SUBCOMMAND = "classpicks"; + public static final List ACTIONS = List.of("reset", "freesubclass", "paid"); + static final String USAGE = "Usage: /rpcharacter admin classpicks [reset|freesubclass|paid ]"; + + private static final Logger LOG = Logger.getLogger("RPCharacters"); + + private ClassPickAdminCommand() {} + + public static boolean canUse(CommandSender sender) { + return ClassPickReset.canUse(sender); + } + + public static boolean handle(CommandSender sender, String[] args, int index) { + if (!canUse(sender)) { + RPTexts.send(sender, RPTexts.ERROR + "You do not have permission to use this command."); + return true; + } + if (args.length <= index || args.length > index + 3) { + RPTexts.send(sender, RPTexts.ERROR + USAGE); + return true; + } + Player target = Bukkit.getPlayerExact(args[index]); + PlayerData data = target == null ? null : PlayerManager.get(target); + RPCharacter character = data == null ? null : data.getActiveCharacter(); + if (character == null) { + RPTexts.send(sender, RPTexts.ERROR + (target == null ? "No online player called " : "No active character for ") + + RPTexts.WARN + args[index] + RPTexts.ERROR + "."); + return true; + } + Stage stage = ClassPickService.classStage(); + PaidChangeRule rule = PaidChangeService.ruleFor(stage); + String who = RPTexts.MUTED + "Character " + RPTexts.WARN + DisplayIdentityService.resolveCharacterName(character) + + RPTexts.MUTED + " (" + target.getName() + ")"; + if (args.length == index + 1) { + sendInfo(sender, who, character, stage, rule); + return true; + } + String action = args[index + 1].toLowerCase(Locale.ROOT); + boolean paid = action.equals("paid"); + if (!ACTIONS.contains(action) || paid != (args.length == index + 3)) { + RPTexts.send(sender, RPTexts.ERROR + USAGE); + return true; + } + if (character.getPendingClassPick() != null || character.getPendingPaidChange() != null) { + RPTexts.send(sender, who + RPTexts.ERROR + " has a class payment still being settled. Try again in a moment."); + return true; + } + String done; + switch (action) { + case "reset" -> { + if (stage == null) { + RPTexts.send(sender, RPTexts.ERROR + "There is no class stage in stages.yml to reset."); + return true; + } + reset(character, stage, rule, Instant.now().getEpochSecond()); + done = " can change class and subclass for free " + window(stage) + ", paid change prices start over" + + " and the next subclass is free."; + } + case "freesubclass" -> { + character.setSubclassPicked(false); + done = " gets its next subclass pick for free."; + } + default -> { + if (rule == null) { + RPTexts.send(sender, RPTexts.ERROR + "There is no class paid-change rule in config.yml, so class" + + " changes always cost " + ClassPickService.priceText(ClassPickService.settings().changeCost()) + "."); + return true; + } + Integer count = parseCount(args[index + 2]); + if (count == null) { + RPTexts.send(sender, RPTexts.ERROR + "The count must be a whole number from 0 up."); + return true; + } + character.setPaidChangeCount(rule.getStageId(), count); + done = " has " + count + " paid class changes. The next paid change costs " + + ClassPickService.priceText(rule.costAfter(count)) + "."; + } + } + RPCharacters.getPlayerManager().savePlayer(target); + LOG.info("[RPCharacters] " + sender.getName() + " ran classpicks " + action + + (paid ? " " + args[index + 2] : "") + " for " + target.getName() + " (character " + character.getId() + ")."); + RPTexts.send(sender, who + RPTexts.SUCCESS + done); + if (!target.equals(sender) && !action.equals("paid")) { + RPTexts.sendPrefixed(target, RPTexts.SUCCESS + (action.equals("reset") + ? "Staff reset your class picks: class and subclass changes are free " + window(stage) + "." + : "Staff made your next subclass pick free.")); + } + return true; + } + + /** One character's {@code resetclasses}: a fresh class window from now, no paid changes, a free subclass. */ + static void reset(RPCharacter character, Stage stage, PaidChangeRule rule, long nowEpochSeconds) { + String key = StageRevisions.key(stage); + character.setStageRevision(key, Math.max(stage.getRevision(), character.getStageRevision(key)), nowEpochSeconds); + character.setPaidChangeCount(key, 0); + if (rule != null) { + character.setPaidChangeCount(rule.getStageId(), 0); + } + character.setSubclassPicked(false); + } + + private static void sendInfo(CommandSender sender, String who, RPCharacter character, Stage stage, PaidChangeRule rule) { + String className = character.hasMMOClass() ? character.getMMOClass() : "none"; + RPTexts.send(sender, who + RPTexts.MUTED + ": class " + RPTexts.WARN + className); + long freeFor = stage == null ? 0L : StageEditLock.lockRemainingMs(stage, character); + boolean open = stage != null && StageEditLock.canEdit(stage, character); + RPTexts.send(sender, RPTexts.MUTED + "Free class changes: " + RPTexts.WARN + (!open ? "no, window closed" + : freeFor > 0 ? "for " + AgeFormatter.formatCountdown(freeFor) : "always")); + RPTexts.send(sender, RPTexts.MUTED + "Next subclass pick free: " + RPTexts.WARN + + (ClassPickService.freeSubclassOpen(character) ? "yes" : "no")); + if (rule == null) { + RPTexts.send(sender, RPTexts.MUTED + "Class changes cost: " + RPTexts.WARN + + ClassPickService.priceText(ClassPickService.settings().changeCost())); + return; + } + int paid = character.getPaidChangeCount(rule.getStageId()); + RPTexts.send(sender, RPTexts.MUTED + "Paid class changes: " + RPTexts.WARN + paid + RPTexts.MUTED + + ", next paid change costs " + RPTexts.WARN + ClassPickService.priceText(rule.costAfter(paid))); + } + + private static String window(Stage stage) { + return stage.getLockTimeMs() < 0 ? "always" : "for " + AgeFormatter.formatCountdown(stage.getLockTimeMs()); + } + + private static Integer parseCount(String token) { + try { + int count = Integer.parseInt(token); + return count < 0 ? null : count; + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassPickService.java b/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassPickService.java index 7c6e7d2e..6ff56fd7 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassPickService.java +++ b/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassPickService.java @@ -40,9 +40,11 @@ /** * Class and subclass picks for the active character, in place of MMOCore class points. A - * character's first class and first subclass are free. Other picks are class changes, priced like - * the class creation stage: free while its lock window is open, then the class paid-change rule - * (change-cost when there is no rule). Infinite points make every pick free. + * character's first class is free, and so is its first subclass after each base class pick: a + * move to another class costs one class change, not a second one for its subclass. Other picks + * are class changes, priced like the class creation stage: free while its lock window is open, + * then the class paid-change rule (change-cost when there is no rule). Infinite points make every + * pick free. */ public final class ClassPickService { @@ -157,18 +159,31 @@ public static PlayerClass subclassOf(RPCharacter character, PlayerClass base) { : null; } - /** Free for the first class, the first subclass in the current class family, and on infinite points. */ + /** Free for the first class, a free subclass pick in the current class family, and on infinite points. */ public static BigDecimal price(RPCharacter character, Option option) { if (settings.infinitePoints() || !character.hasMMOClass()) { return BigDecimal.ZERO; } - if (option.isSubclass() && !character.hasPickedSubclass() + if (option.isSubclass() && freeSubclassOpen(character) && option.base().getId().equalsIgnoreCase(baseClassId(character))) { return BigDecimal.ZERO; } return changePrice(character); } + /** + * True while the character's next subclass pick is free: it has never picked one, staff reset + * its picks, or it is on a base class now. A character only gets back to a base class through + * a class change, so a later subclass of that class must not be priced as a second change. + */ + public static boolean freeSubclassOpen(RPCharacter character) { + if (!character.hasPickedSubclass()) { + return true; + } + Option current = option(character.getMMOClass()); + return current == null || !current.isSubclass(); + } + /** The character's class, or the base class of its subclass. */ private static String baseClassId(RPCharacter character) { Option current = option(character.getMMOClass()); @@ -403,7 +418,7 @@ public static List pricingLore(RPCharacter character, String heading) { lines.add(RPTexts.MUTED + "Class picks: " + RPTexts.WARN + "Free"); return lines; } - if (!character.hasPickedSubclass()) { + if (freeSubclassOpen(character)) { lines.add(RPTexts.MUTED + "First subclass: " + RPTexts.WARN + "Free"); } Stage stage = classStage(); diff --git a/src/main/java/net/tfminecraft/rpcharacters/managers/CommandManager.java b/src/main/java/net/tfminecraft/rpcharacters/managers/CommandManager.java index 2268d935..de223a3f 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/managers/CommandManager.java +++ b/src/main/java/net/tfminecraft/rpcharacters/managers/CommandManager.java @@ -16,6 +16,7 @@ import net.Indyuce.mmocore.api.player.profess.PlayerClass; import net.tfminecraft.rpcharacters.Cache; import net.tfminecraft.rpcharacters.classpick.ClassPickGui; +import net.tfminecraft.rpcharacters.classpick.ClassPickAdminCommand; import net.tfminecraft.rpcharacters.classpick.ClassPickReset; import net.tfminecraft.rpcharacters.loaders.ChatLoader; import net.tfminecraft.rpcharacters.loaders.TraitLoader; @@ -85,6 +86,10 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String && args[1].equalsIgnoreCase(ClassPickReset.SUBCOMMAND)) { return ClassPickReset.handle(sender, args, 2); } + if (args.length >= 2 && args[0].equalsIgnoreCase("admin") + && args[1].equalsIgnoreCase(ClassPickAdminCommand.SUBCOMMAND)) { + return ClassPickAdminCommand.handle(sender, args, 2); + } if (args.length >= 2 && args[0].equalsIgnoreCase("admin") && args[1].equalsIgnoreCase(PrisonSwitch.SUBCOMMAND)) { return PrisonSwitch.handle(sender, args, 2); @@ -758,7 +763,7 @@ private static boolean handleAdmin(CommandSender sender, String[] args) { return true; } if (args.length < 2) { - RPTexts.send(sender, RPTexts.ERROR + "Usage: /rpcharacter admin ..."); + RPTexts.send(sender, RPTexts.ERROR + "Usage: /rpcharacter admin ..."); return true; } if (args[1].equalsIgnoreCase("injure")) { @@ -777,7 +782,7 @@ private static boolean handleAdmin(CommandSender sender, String[] args) { if (args[1].equalsIgnoreCase(net.tfminecraft.rpcharacters.mmocore.CarriedExperience.SUBCOMMAND)) { return net.tfminecraft.rpcharacters.mmocore.CarriedExperience.handle(sender, args, 2); } - RPTexts.send(sender, RPTexts.ERROR + "Usage: /rpcharacter admin ..."); + RPTexts.send(sender, RPTexts.ERROR + "Usage: /rpcharacter admin ..."); return true; } diff --git a/src/main/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleter.java b/src/main/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleter.java index f85ce9e2..85a55a3e 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleter.java +++ b/src/main/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleter.java @@ -16,6 +16,7 @@ import org.bukkit.entity.Player; import net.tfminecraft.rpcharacters.Cache; +import net.tfminecraft.rpcharacters.classpick.ClassPickAdminCommand; import net.tfminecraft.rpcharacters.classpick.ClassPickReset; import net.tfminecraft.rpcharacters.enums.Status; import net.tfminecraft.rpcharacters.loaders.TraitLoader; @@ -109,6 +110,10 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe && ClassPickReset.canUse(sender)) { return filter(List.of("confirm"), args[2]); } + if (sub.equals("admin") && args.length >= 3 && args[1].equalsIgnoreCase(ClassPickAdminCommand.SUBCOMMAND) + && ClassPickAdminCommand.canUse(sender)) { + return completeClassPicks(args); + } if (sub.equals("admin") && args.length >= 3 && args[1].equalsIgnoreCase(PrisonSwitch.SUBCOMMAND) && PrisonSwitch.canUse(sender)) { return completePrison(args); @@ -182,6 +187,7 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe } if (ClassPickReset.canUse(sender)) { completions.add(ClassPickReset.SUBCOMMAND); + completions.add(ClassPickAdminCommand.SUBCOMMAND); } if (HiddenCharacterAdminCommand.canUse(sender)) { completions.add(HiddenCharacterAdminCommand.SUBCOMMAND); @@ -326,6 +332,24 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe return Collections.emptyList(); } + /** {@code /rpcharacter admin classpicks [reset|freesubclass|paid ]}. */ + private List completeClassPicks(String[] args) { + if (args.length == 3) { + List names = new ArrayList<>(); + for (Player online : Bukkit.getOnlinePlayers()) { + names.add(online.getName()); + } + return filter(names, args[2]); + } + if (args.length == 4) { + return filter(ClassPickAdminCommand.ACTIONS, args[3]); + } + if (args.length == 5 && args[3].equalsIgnoreCase("paid")) { + return filter(List.of("0", "1", "2"), args[4]); + } + return Collections.emptyList(); + } + /** {@code /rpcharacter admin hide [on|off]}. */ private List completeHide(String[] args) { String last = args[args.length - 1]; diff --git a/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassPickAdminCommandTest.java b/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassPickAdminCommandTest.java new file mode 100644 index 00000000..7416b8dc --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassPickAdminCommandTest.java @@ -0,0 +1,157 @@ +package net.tfminecraft.rpcharacters.classpick; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.*; + +import org.bukkit.ChatColor; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockito.MockedStatic; + +import net.Indyuce.mmocore.MMOCore; +import net.Indyuce.mmocore.manager.ClassManager; +import net.tfminecraft.rpcharacters.RPCharacters; +import net.tfminecraft.rpcharacters.RuntimeTestState; +import net.tfminecraft.rpcharacters.creation.Stage; +import net.tfminecraft.rpcharacters.creation.StageEditLock; +import net.tfminecraft.rpcharacters.identity.DisplayIdentityService; +import net.tfminecraft.rpcharacters.loaders.StageLoader; +import net.tfminecraft.rpcharacters.managers.PlayerManager; +import net.tfminecraft.rpcharacters.mmocore.MmoCoreClassGuiHelper; +import net.tfminecraft.rpcharacters.objects.PlayerData; +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import net.tfminecraft.rpcharacters.paidchange.PaidChangeRule; +import net.tfminecraft.rpcharacters.paidchange.PaidChangeService; +import net.tfminecraft.rpcharacters.paidchange.Payment; +import net.tfminecraft.rpcharacters.paidchange.DenarWallet.Account; + +class ClassPickAdminCommandTest { + static final String STAGE = "class_selection_stage"; + @TempDir Path directory; + ServerMock server; PlayerMock staff, target, classless; RuntimeTestState state; PlayerManager playerManager; RPCharacter character; + MMOCore previousMmo; + final List> mocks = new ArrayList<>(); + MockedStatic boundary(Class type) { var result = mockStatic(type); mocks.add(result); return result; } + + @BeforeEach void setup() throws Exception { + server = MockBukkit.mock(); state = new RuntimeTestState(RPCharacters.class, StageLoader.class, ClassPickReset.class, PaidChangeService.class); + ClassPickService.configure(ClassPickService.Settings.DEFAULTS); previousMmo = MMOCore.plugin; + MMOCore.plugin = mock(MMOCore.class); var classes = mock(ClassManager.class); + var field = MMOCore.class.getField("classManager"); field.setAccessible(true); field.set(MMOCore.plugin, classes); + when(classes.getAll()).thenReturn(List.of()); + boundary(MmoCoreClassGuiHelper.class).when(() -> MmoCoreClassGuiHelper.buildClassOptions(anyInt(), anyMap())) + .thenReturn(new MmoCoreClassGuiHelper.ClassGuiData(new ArrayList<>(), List.of())); + RPCharacters.plugin = mock(RPCharacters.class); when(RPCharacters.plugin.getDataFolder()).thenReturn(directory.toFile()); + playerManager = mock(PlayerManager.class); boundary(RPCharacters.class).when(RPCharacters::getPlayerManager).thenReturn(playerManager); + boundary(DisplayIdentityService.class).when(() -> DisplayIdentityService.resolveCharacterName(any(RPCharacter.class))).thenReturn("Belisarius"); + new StageLoader().load(Files.writeString(directory.resolve("stages.yml"), """ + class_selection_stage: {type: SELECTION, target: class, lock-time: 5d, revision: 2} + """).toFile()); + PaidChangeService.configure(List.of(new PaidChangeRule("class", STAGE, "class", + List.of(new BigDecimal("100"), new BigDecimal("1000"), new BigDecimal("3000")))), List.of()); + staff = server.addPlayer("Staff"); target = server.addPlayer("Target"); classless = server.addPlayer("Classless"); + character = new RPCharacter(target); character.setMMOClass("paladin"); character.setCreatedAtEpochSeconds(1); + character.setStageRevision(STAGE, 2, 5L); character.setSubclassPicked(true); character.setPaidChangeCount(STAGE, 2); character.setPaidClassPicks(2); + var account = mock(PlayerData.class); when(account.getActiveCharacter()).thenReturn(character); + var empty = mock(PlayerData.class); + var players = boundary(PlayerManager.class); + players.when(() -> PlayerManager.get(target)).thenReturn(account); players.when(() -> PlayerManager.get(classless)).thenReturn(empty); + } + + @AfterEach void cleanup() { + for (int i = mocks.size() - 1; i >= 0; i--) mocks.get(i).close(); + MMOCore.plugin = previousMmo; ClassPickService.configure(ClassPickService.Settings.DEFAULTS); state.close(); MockBukkit.unmock(); + } + + void grant() { staff.addAttachment(MockBukkit.createMockPlugin(), "rpchar.class.reset", true); } + boolean run(String... args) { + String[] full = new String[args.length + 2]; full[0] = "admin"; full[1] = "classpicks"; + System.arraycopy(args, 0, full, 2, args.length); return ClassPickAdminCommand.handle(staff, full, 2); + } + String messages(PlayerMock who) { + List out = new ArrayList<>(); String message; + while ((message = who.nextMessage()) != null) out.add(ChatColor.stripColor(message)); + return String.join("\n", out); + } + Stage stage() { return StageLoader.getById(STAGE); } + + @Test void needsTheResetPermissionAndAnOnlinePlayerWithACharacter() { + assertTrue(run("Target", "reset")); assertTrue(messages(staff).contains("permission")); assertTrue(character.hasPickedSubclass()); + assertFalse(ClassPickAdminCommand.canUse(staff)); grant(); assertTrue(ClassPickAdminCommand.canUse(staff)); + assertTrue(run()); assertTrue(messages(staff).contains("Usage: /rpcharacter admin classpicks")); + assertTrue(run("Target", "paid", "1", "extra")); assertTrue(messages(staff).contains("Usage")); + assertTrue(run("Nobody")); assertTrue(messages(staff).contains("No online player called Nobody")); + assertTrue(run("Classless")); assertTrue(messages(staff).contains("No active character for Classless")); + assertTrue(run("Target", "wipe")); assertTrue(messages(staff).contains("Usage")); + assertTrue(run("Target", "paid")); assertTrue(messages(staff).contains("Usage")); + assertTrue(run("Target", "reset", "now")); assertTrue(messages(staff).contains("Usage")); + verify(playerManager, never()).savePlayer(any()); + } + + @Test void withoutAnActionItShowsTheCharactersPickState() { + grant(); assertTrue(run("Target")); String info = messages(staff); + assertTrue(info.contains("Character Belisarius (Target): class PALADIN"), info); + assertTrue(info.contains("Free class changes: no, window closed"), info); + assertTrue(info.contains("Next subclass pick free: yes"), info); + assertTrue(info.contains("Paid class changes: 2, next paid change costs 3,000 denars"), info); + character.setMMOClass(null); character.setCreatedAtEpochSeconds((int) Instant.now().getEpochSecond()); + PaidChangeService.configure(List.of(), List.of()); + assertTrue(run("Target")); info = messages(staff); + assertTrue(info.contains("class none"), info); assertTrue(info.contains("Free class changes: for "), info); + assertTrue(info.contains("Class changes cost: 200 denars"), info); + verify(playerManager, never()).savePlayer(any()); + } + + @Test void resetGivesOnlyThisCharacterAFreshWindowAndFreeSubclass() { + grant(); long before = Instant.now().getEpochSecond(); assertFalse(StageEditLock.canEdit(stage(), character)); + assertTrue(run("Target", "RESET")); assertTrue(messages(staff).contains("can change class and subclass for free for 5d")); + assertTrue(StageEditLock.canEdit(stage(), character)); assertTrue(character.getStageRevisionSince(STAGE) >= before); + assertEquals(2, character.getStageRevision(STAGE)); assertEquals(0, character.getPaidChangeCount(STAGE)); assertFalse(character.hasPickedSubclass()); + assertEquals("PALADIN", character.getMMOClass(), "The class itself is kept"); assertEquals(2, character.getPaidClassPicks()); + verify(playerManager).savePlayer(target); assertTrue(messages(target).contains("Staff reset your class picks")); + assertEquals(2, stage().getRevision(), "The stage revision for everyone else is untouched"); + } + + @Test void resetNeverLowersARevisionTheCharacterAlreadyReached() { + grant(); character.setStageRevision(STAGE, 7, 5L); + assertTrue(run("Target", "reset")); assertEquals(7, character.getStageRevision(STAGE)); + assertTrue(StageEditLock.canEdit(stage(), character)); + StageLoader.oList = new ArrayList<>(); assertTrue(run("Target", "reset")); assertTrue(messages(staff).contains("no class stage")); + } + + @Test void freeSubclassOnlyClearsTheUsedSubclass() { + grant(); assertTrue(run("Target", "freesubclass")); + assertTrue(messages(staff).contains("gets its next subclass pick for free")); assertFalse(character.hasPickedSubclass()); + assertEquals(2, character.getPaidChangeCount(STAGE)); verify(playerManager).savePlayer(target); + assertTrue(messages(target).contains("Staff made your next subclass pick free.")); + } + + @Test void paidSetsTheCountThatPicksTheNextPrice() { + grant(); assertTrue(run("Target", "paid", "0")); + assertTrue(messages(staff).contains("has 0 paid class changes. The next paid change costs 100 denars.")); + assertEquals(0, character.getPaidChangeCount(STAGE)); assertEquals("", messages(target), "A price change is not announced to the player"); + assertTrue(run("Target", "paid", "1")); assertEquals(1, character.getPaidChangeCount(STAGE)); assertTrue(messages(staff).contains("1,000 denars")); + for (String bad : List.of("-1", "two", "1.5")) { + assertTrue(run("Target", "paid", bad)); assertTrue(messages(staff).contains("whole number")); + } + assertEquals(1, character.getPaidChangeCount(STAGE)); verify(playerManager, times(2)).savePlayer(target); + PaidChangeService.configure(List.of(), List.of()); assertTrue(run("Target", "paid", "0")); + assertTrue(messages(staff).contains("no class paid-change rule")); assertEquals(1, character.getPaidChangeCount(STAGE)); + } + + @Test void aHeldPaymentBlocksChanges() { + grant(); character.setPendingClassPick(new PendingClassPick("CRUSADER", Payment.legacy(UUID.randomUUID(), Account.BANK, 300_000L, "refund"), false)); + assertTrue(run("Target", "reset")); assertTrue(messages(staff).contains("still being settled")); + assertTrue(character.hasPickedSubclass()); assertEquals(2, character.getPaidChangeCount(STAGE)); verify(playerManager, never()).savePlayer(any()); + } +} diff --git a/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassPickTest.java b/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassPickTest.java index 01cae1bf..65951c81 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassPickTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassPickTest.java @@ -173,7 +173,8 @@ Settings settings(boolean enabled, boolean infinite) { character.setMMOClass("berserker"); var spellbladePick = ClassPickService.option("spellblade"); assertEquals(BigDecimal.ZERO, ClassPickService.price(character, spellbladePick), "A reset subclass holder's sibling is the free subclass pick"); assertEquals(new BigDecimal("200.00"), ClassPickService.price(character, magePick)); - character.setMMOClass("warrior"); character.setSubclassPicked(true); assertEquals(new BigDecimal("200.00"), ClassPickService.price(character, berserkerPick)); + character.setMMOClass("spellblade"); character.setSubclassPicked(true); assertEquals(new BigDecimal("200.00"), ClassPickService.price(character, berserkerPick), "A used subclass makes its sibling a class change"); + character.setMMOClass("warrior"); assertEquals(BigDecimal.ZERO, ClassPickService.price(character, berserkerPick), "Back on a base class, its first subclass is free again"); assertEquals(BigDecimal.ZERO, ClassPickService.price(new RPCharacter(player), magePick), "A character without a class picks its first one free"); character.setMMOClass("mage"); ClassPickService.configure(settings(true, true)); assertEquals(BigDecimal.ZERO, ClassPickService.price(character, magePick)); assertEquals("Free", ClassPickService.priceText(BigDecimal.ZERO)); assertEquals("200 denars", ClassPickService.priceText(new BigDecimal("200.00"))); @@ -197,6 +198,26 @@ Settings settings(boolean enabled, boolean infinite) { assertEquals(new BigDecimal("1000.00"), ClassPickService.price(character, ClassPickService.option("warrior")), "Each paid change raises the next price"); } + @Test void aMoveToAnotherClassCostsOneChangeAndItsFirstSubclassIsFree() { + displayed.add("paladin"); var paladin = playerClass("paladin", "Paladin", 5, null); var crusader = playerClass("crusader", "Crusader", 6, null); + when(paladin.getSubclasses()).thenReturn(List.of(new Subclass(crusader, 2))); level.set(2); wallet.set(Account.BANK, 100_000); + assertEquals(Status.CHOSEN, ClassPickService.choose(player, "berserker").status()); assertEquals(100_000, wallet.get(Account.BANK), "The first subclass is free"); + assertEquals(Status.CHOSEN, ClassPickService.choose(player, "paladin").status()); assertEquals(80_000, wallet.get(Account.BANK), "The class move is one paid change"); + assertTrue(ClassPickService.freeSubclassOpen(character)); + var crusaderPick = ClassPickService.choose(player, "crusader"); + assertEquals(Status.CHOSEN, crusaderPick.status()); assertNull(crusaderPick.paidFrom()); assertEquals(80_000, wallet.get(Account.BANK), "The new class's first subclass is free"); + assertEquals("CRUSADER", character.getMMOClass()); assertEquals(1, character.getPaidClassPicks()); assertFalse(ClassPickService.freeSubclassOpen(character)); + assertEquals(new BigDecimal("200.00"), ClassPickService.price(character, ClassPickService.option("berserker")), "Another class's subclass is never free"); + } + + @Test void aCharacterStuckOnABaseClassAfterAnEarlierSubclassGetsItsSubclassFree() { + level.set(2); character.setSubclassPicked(true); wallet.set(Account.BANK, 100_000); + var result = ClassPickService.choose(player, "berserker"); + assertEquals(Status.CHOSEN, result.status()); assertNull(result.paidFrom()); assertEquals(100_000, wallet.get(Account.BANK)); assertEquals(0, character.getPaidClassPicks()); + assertEquals(Status.CHOSEN, ClassPickService.choose(player, "spellblade").status()); assertEquals(80_000, wallet.get(Account.BANK), "A sibling switch is still a paid change"); + character.setMMOClass("missing"); assertTrue(ClassPickService.freeSubclassOpen(character), "An unknown class counts as a base class"); + } + @Test void freeSubclassPickAppliesSavesAndAnnouncesTheChange() { level.set(2); var result = ClassPickService.choose(player, "berserker"); assertEquals(Status.CHOSEN, result.status()); assertNull(result.paidFrom()); assertEquals("BERSERKER", character.getMMOClass()); @@ -258,7 +279,8 @@ Settings settings(boolean enabled, boolean infinite) { @Test void pricingLoreDescribesFreeAndPaidPicks() { assertEquals(List.of("Head", "First subclass: Free", "Other picks: 200 denars"), ClassPickService.pricingLore(character, "Head").stream().map(ChatColor::stripColor).toList()); - character.setSubclassPicked(true); assertEquals(List.of("Head", "Other picks: 200 denars"), ClassPickService.pricingLore(character, "Head").stream().map(ChatColor::stripColor).toList()); + character.setSubclassPicked(true); assertEquals(List.of("Head", "First subclass: Free", "Other picks: 200 denars"), ClassPickService.pricingLore(character, "Head").stream().map(ChatColor::stripColor).toList()); + character.setMMOClass("berserker"); assertEquals(List.of("Head", "Other picks: 200 denars"), ClassPickService.pricingLore(character, "Head").stream().map(ChatColor::stripColor).toList()); ClassPickService.configure(settings(true, true)); assertEquals(List.of("Head", "Class picks: Free"), ClassPickService.pricingLore(character, "Head").stream().map(ChatColor::stripColor).toList()); } diff --git a/src/test/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleterTest.java b/src/test/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleterTest.java index fc0f74bc..99242306 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleterTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleterTest.java @@ -119,8 +119,10 @@ RPCharacter character(String slug, String name) { assertEquals(List.of("Iris"), complete(verb, "IR"), verb); assertEquals(List.of("name", "race"), complete("edit", "")); assertEquals(List.of("injure", "permakill", "strikes", "tutorial", "carryexp", "hide", "prison"), complete("admin", "")); - grant("rpchar.class.reset", true); assertEquals(List.of("injure", "permakill", "strikes", "tutorial", "carryexp", "resetclasses", "hide", "prison"), complete("admin", "")); + grant("rpchar.class.reset", true); assertEquals(List.of("injure", "permakill", "strikes", "tutorial", "carryexp", "resetclasses", "classpicks", "hide", "prison"), complete("admin", "")); assertEquals(List.of("confirm"), complete("admin", "RESETCLASSES", "c")); assertEquals(List.of(), complete("admin", "resetclasses", "confirm", "")); + assertEquals(List.of("Iris"), complete("admin", "classpicks", "I")); assertEquals(List.of("reset", "freesubclass", "paid"), complete("admin", "CLASSPICKS", "Iris", "")); + assertEquals(List.of("0", "1", "2"), complete("admin", "classpicks", "Iris", "PAID", "")); assertEquals(List.of(), complete("admin", "classpicks", "Iris", "reset", "")); assertEquals(List.of("5", "10", "25", "50"), complete("clearclues", "")); assertEquals(List.of(), complete("placeclue", "")); assertEquals(List.of("on", "off"), complete("adminmode", "")); grant(Cache.personaTempaliasPermission, true); assertEquals(List.of("clear"), complete("tempalias", "")); @@ -131,9 +133,9 @@ RPCharacter character(String slug, String name) { @Test void adminSuggestionsDisappearWithoutPermission() { for (String verb : List.of("catalog", "pending", "wipe", "stage", "seteighteen", "resetkit", "reclaimkit", "admin", "clearclues", "placeclue", "adminmode", "discordgate", "setclass", "skipcooldown", "addtrait", "removetrait", "menu", "injure", "permakill")) assertEquals(List.of(), complete(verb, ""), verb); - assertEquals(List.of(), complete("admin", "strikes", "")); assertEquals(List.of(), complete("admin", "resetclasses", "")); assertEquals(List.of(), complete("admin", "prison", "")); assertEquals(List.of(), complete("admin", "hide", "")); - grant("rpchar.class.reset", true); assertEquals(List.of("resetclasses"), complete("admin", "")); - grant(PrisonSwitch.PERMISSION, true); assertEquals(List.of("resetclasses", "prison"), complete("admin", "")); + assertEquals(List.of(), complete("admin", "strikes", "")); assertEquals(List.of(), complete("admin", "resetclasses", "")); assertEquals(List.of(), complete("admin", "classpicks", "")); assertEquals(List.of(), complete("admin", "prison", "")); assertEquals(List.of(), complete("admin", "hide", "")); + grant("rpchar.class.reset", true); assertEquals(List.of("resetclasses", "classpicks"), complete("admin", "")); + grant(PrisonSwitch.PERMISSION, true); assertEquals(List.of("resetclasses", "classpicks", "prison"), complete("admin", "")); } @Test void prisonCompletionOffersPlayersTheirLivingCharactersAndTheSwitch() { From 4dbf266d72d631422817edaa32ba5882f26afd3d Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:46:15 +0100 Subject: [PATCH 2/3] test: cover the classpicks admin dispatch Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/rpcharacters/managers/CommandManagerTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/java/net/tfminecraft/rpcharacters/managers/CommandManagerTest.java b/src/test/java/net/tfminecraft/rpcharacters/managers/CommandManagerTest.java index fd24889e..a7febb9b 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/managers/CommandManagerTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/managers/CommandManagerTest.java @@ -157,6 +157,8 @@ PlayerClass knownClass(ClassManager manager, String id) { String[] tutorial = {"admin", "tutorial", "reset"}; assertFalse(run(tutorial)); tutorials.verify(() -> TutorialCommands.handleAdmin(player, tutorial, 2)); var resets = boundary(ClassPickReset.class); String[] reset = {"admin", "ResetClasses", "confirm"}; assertFalse(run(reset)); resets.verify(() -> ClassPickReset.handle(player, reset, 2)); + var picks = boundary(net.tfminecraft.rpcharacters.classpick.ClassPickAdminCommand.class); String[] pick = {"admin", "ClassPicks", "Iris", "reset"}; assertFalse(run(pick)); + picks.verify(() -> net.tfminecraft.rpcharacters.classpick.ClassPickAdminCommand.handle(player, pick, 2)); var carried = boundary(net.tfminecraft.rpcharacters.mmocore.CarriedExperience.class); String[] carry = {"admin", "CarryExp", "Iris", "40"}; assertFalse(run(carry)); carried.verify(() -> net.tfminecraft.rpcharacters.mmocore.CarriedExperience.handle(player, carry, 2)); var prisons = boundary(PrisonSwitch.class); String[] prison = {"admin", "Prison", "Iris"}; assertFalse(run(prison)); From f61201165bd8ce3f360eb51d74c201e5b48853ed Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:57:23 +0100 Subject: [PATCH 3/3] fix: saturate paid class change counts instead of wrapping A staff-set count at Integer.MAX_VALUE wrapped to 0 on the next paid pick, so the following change cost the first price again. Co-Authored-By: Claude Opus 5.5 --- .../rpcharacters/classpick/ClassPickService.java | 9 +++++++-- .../rpcharacters/classpick/ClassPickTest.java | 3 +++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassPickService.java b/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassPickService.java index 6ff56fd7..9f9a0d41 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassPickService.java +++ b/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassPickService.java @@ -310,14 +310,19 @@ private static boolean hold(UUID owner, RPCharacter character, String targetClas /** Counts a kept paid pick and clears its hold. */ private static void countPaid(RPCharacter character) { character.setPendingClassPick(null); - character.setPaidClassPicks(character.getPaidClassPicks() + 1); + character.setPaidClassPicks(saturatedIncrement(character.getPaidClassPicks())); PaidChangeRule rule = PaidChangeService.ruleFor(classStage()); if (rule != null) { // Shared with paid class-stage edits, so the next change costs the next price. - character.setPaidChangeCount(rule.getStageId(), character.getPaidChangeCount(rule.getStageId()) + 1); + // Saturates, so a staff-set maximum never wraps back to the first price. + character.setPaidChangeCount(rule.getStageId(), saturatedIncrement(character.getPaidChangeCount(rule.getStageId()))); } } + private static int saturatedIncrement(int count) { + return count == Integer.MAX_VALUE ? count : count + 1; + } + /** * Settles a paid pick left held by a crash or a failed refund. A debit that never applied is * dropped; a pick whose class stuck (and was not marked for refund) is kept and counted; diff --git a/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassPickTest.java b/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassPickTest.java index 65951c81..8a7badf4 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassPickTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassPickTest.java @@ -196,6 +196,9 @@ Settings settings(boolean enabled, boolean infinite) { wallet.set(Account.POUCH, 15_000); var paid = ClassPickService.choose(player, "mage"); assertEquals(Status.CHOSEN, paid.status()); assertEquals(new BigDecimal("100.00"), paid.cost()); assertEquals(1, character.getPaidChangeCount("class_selection_stage")); assertEquals(new BigDecimal("1000.00"), ClassPickService.price(character, ClassPickService.option("warrior")), "Each paid change raises the next price"); + character.setPaidChangeCount("class_selection_stage", Integer.MAX_VALUE); character.setPaidClassPicks(Integer.MAX_VALUE); wallet.set(Account.POUCH, 1_000_000); + assertEquals(Status.CHOSEN, ClassPickService.choose(player, "warrior").status()); assertEquals(700_000, wallet.get(Account.POUCH), "A maximum count pays the last price"); + assertEquals(Integer.MAX_VALUE, character.getPaidChangeCount("class_selection_stage"), "The count saturates instead of wrapping to the first price"); assertEquals(Integer.MAX_VALUE, character.getPaidClassPicks()); } @Test void aMoveToAnotherClassCostsOneChangeAndItsFirstSubclassIsFree() {