diff --git a/src/main/java/net/tfminecraft/rpcharacters/catalog/CreationCatalogSyncService.java b/src/main/java/net/tfminecraft/rpcharacters/catalog/CreationCatalogSyncService.java index a2e8cd02..d70f73c3 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/catalog/CreationCatalogSyncService.java +++ b/src/main/java/net/tfminecraft/rpcharacters/catalog/CreationCatalogSyncService.java @@ -336,6 +336,9 @@ private static void appendTraits(StringBuilder sb) { } private static boolean shouldIncludeTraitInCatalog(Trait trait, String key) { + if (trait.getTraitData().isHidden()) { + return false; + } if (key == null) { return true; } diff --git a/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassPickService.java b/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassPickService.java index 7c6e7d2e..f52dc318 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassPickService.java +++ b/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassPickService.java @@ -58,6 +58,8 @@ public enum Status { NO_CHARACTER, BUSY, UNKNOWN_CLASS, + /** The character plays a class another plugin assigned (see {@link ClassUnlocks}); it can't leave it. */ + ASSIGNED_CLASS, CURRENT_CLASS, LEVEL_TOO_LOW, UNAVAILABLE, @@ -101,12 +103,7 @@ public static boolean isEnabled() { /** Displayed base classes in display order. */ public static List baseClasses() { List classes = new ArrayList<>(); - Set subclassIds = new HashSet<>(); - for (PlayerClass playerClass : MMOCore.plugin.classManager.getAll()) { - for (Subclass subclass : playerClass.getSubclasses()) { - subclassIds.add(subclass.getProfess().getId().toUpperCase(Locale.ROOT)); - } - } + Set subclassIds = subclassIds(); for (PlayerClass playerClass : MMOCore.plugin.classManager.getAll()) { if (!subclassIds.contains(playerClass.getId().toUpperCase(Locale.ROOT)) && MmoCoreClassGuiHelper.isClassDisplayed(playerClass)) { @@ -117,6 +114,16 @@ public static List baseClasses() { return classes; } + private static Set subclassIds() { + Set subclassIds = new HashSet<>(); + for (PlayerClass playerClass : MMOCore.plugin.classManager.getAll()) { + for (Subclass subclass : playerClass.getSubclasses()) { + subclassIds.add(subclass.getProfess().getId().toUpperCase(Locale.ROOT)); + } + } + return subclassIds; + } + /** The pick for {@code classId}: a displayed base class or one of their subclasses, else null. */ public static Option option(String classId) { if (classId == null) { @@ -207,6 +214,9 @@ public static Status blocker(Player player) { if (pd == null || !pd.hasActiveCharacter()) { return Status.NO_CHARACTER; } + if (ClassUnlocks.isLockedIn(pd.getActiveCharacter())) { + return Status.ASSIGNED_CLASS; + } return CreationManager.activeCreators.containsKey(player) ? Status.BUSY : null; } @@ -376,6 +386,7 @@ public static String message(Result result) { case NO_CHARACTER -> RPTexts.ERROR + "You need an active character to pick a class."; case BUSY -> RPTexts.ERROR + "Finish your character session first."; case UNKNOWN_CLASS -> RPTexts.ERROR + "That class can't be picked."; + case ASSIGNED_CLASS -> RPTexts.ERROR + "Your class is bound to your character and can't be changed."; case CURRENT_CLASS -> RPTexts.WARN + "You are already a " + name + RPTexts.WARN + "."; case LEVEL_TOO_LOW -> RPTexts.ERROR + name + RPTexts.ERROR + " unlocks at level " + requiredLevel(result.target()) + "."; case UNAVAILABLE -> RPTexts.ERROR + "You can't pay for a class change right now. Try again later."; diff --git a/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassSkillPoints.java b/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassSkillPoints.java new file mode 100644 index 00000000..e2425c35 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassSkillPoints.java @@ -0,0 +1,69 @@ +package net.tfminecraft.rpcharacters.classpick; + +import java.util.OptionalInt; +import java.util.logging.Logger; + +import org.bukkit.entity.Player; + +import net.Indyuce.mmocore.MMOCore; +import net.tfminecraft.rpcharacters.RPCharacters; +import net.tfminecraft.rpcharacters.mmocore.ClassService; +import net.tfminecraft.rpcharacters.objects.RPCharacter; + +/** + * Per-character skill-point pools for classes that must not share the character's pool, for other + * plugins (Magic's hidden blood classes earn their points from sacrifices only). While the character + * plays a class with its own pool, its free points are that total minus what the class spent; the + * character's shared points and tomes stay with its other classes. Saved in the character file under + * "class-skill-points". Call from the main thread; changes write the character file at once. + */ +public final class ClassSkillPoints { + + private static final Logger LOG = Logger.getLogger("RPCharacters"); + + private ClassSkillPoints() {} + + /** + * Gives the class its own pool on the player's active living character, with this lifetime total + * (never below 0), and saves. When the character plays the class now, its free points follow at + * once. False without an active character, for an unknown MMOCore class id, or when the save + * failed (nothing changes then). Idempotent. + */ + public static boolean set(Player player, String classId, int total) { + RPCharacter character = ClassUnlocks.activeCharacter(player); + String id = ClassUnlocks.normalise(classId); + if (character == null || id == null || MMOCore.plugin.classManager.get(id) == null) { + return false; + } + int wanted = Math.max(0, total); + OptionalInt before = character.getClassSkillPoints(id); + if (before.isPresent() && before.getAsInt() == wanted) { + return true; + } + character.setClassSkillPoints(id, wanted); + if (!RPCharacters.getPlayerManager().saveCharacterNow(player.getUniqueId(), character)) { + if (before.isEmpty()) { + character.removeClassSkillPoints(id); + } else { + character.setClassSkillPoints(id, before.getAsInt()); + } + // A failed save may already have replaced the file, so write the old state back. + RPCharacters.getPlayerManager().saveCharacterNow(player.getUniqueId(), character); + LOG.warning("[RPCharacters] Could not save the character file, so the " + id + " skill points of " + + character.getName() + " (" + character.getId() + ") stay " + + (before.isEmpty() ? "shared" : String.valueOf(before.getAsInt())) + "."); + return false; + } + if (ClassService.isOnClass(player, id)) { + ClassService.refreshSkillPoints(player); + } + return true; + } + + /** The class's own lifetime total on the active character; -1 when it has none (shared pool). */ + public static int get(Player player, String classId) { + RPCharacter character = ClassUnlocks.activeCharacter(player); + String id = ClassUnlocks.normalise(classId); + return character == null || id == null ? -1 : character.getClassSkillPoints(id).orElse(-1); + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassUnlockCommand.java b/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassUnlockCommand.java new file mode 100644 index 00000000..4df7ccbb --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassUnlockCommand.java @@ -0,0 +1,95 @@ +package net.tfminecraft.rpcharacters.classpick; + +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import net.Indyuce.mmocore.MMOCore; +import net.Indyuce.mmocore.api.player.profess.PlayerClass; +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import net.tfminecraft.rpcharacters.utils.RPTexts; + +/** + * {@code /rpcharacter admin assignclass|releaseclass }: staff put a player's active + * character into a hidden class (bound to it), or release it so the character may change class again, + * through {@link ClassUnlocks}. The older names unlockclass and lockclass still work. The caller checks + * the admin permission. + */ +public final class ClassUnlockCommand { + + public static final String UNLOCK = "assignclass"; + public static final String LOCK = "releaseclass"; + private static final String OLD_UNLOCK = "unlockclass"; + private static final String OLD_LOCK = "lockclass"; + + private ClassUnlockCommand() {} + + public static boolean isSubcommand(String arg) { + return UNLOCK.equalsIgnoreCase(arg) || LOCK.equalsIgnoreCase(arg) + || OLD_UNLOCK.equalsIgnoreCase(arg) || OLD_LOCK.equalsIgnoreCase(arg); + } + + /** {@code args[index - 1]} is the subcommand, then the player and the class id. */ + public static boolean handle(CommandSender sender, String[] args, int index) { + boolean unlock = args[index - 1].equalsIgnoreCase(UNLOCK) || args[index - 1].equalsIgnoreCase(OLD_UNLOCK); + if (args.length != index + 2) { + RPTexts.send(sender, RPTexts.ERROR + "Usage: /rpcharacter admin " + (unlock ? UNLOCK : LOCK) + + " "); + return true; + } + Player player = Bukkit.getPlayerExact(args[index]); + RPCharacter character = ClassUnlocks.activeCharacter(player); + if (character == null) { + RPTexts.send(sender, RPTexts.ERROR + "No online player with an active living character named " + + RPTexts.WARN + args[index] + RPTexts.ERROR + "."); + return true; + } + String id = ClassUnlocks.normalise(args[index + 1]); + PlayerClass playerClass = MMOCore.plugin.classManager.get(id); + if (unlock && playerClass == null) { + RPTexts.send(sender, RPTexts.ERROR + "No class by the id " + RPTexts.WARN + id + RPTexts.ERROR + "."); + return true; + } + String name = playerClass == null ? id : ClassPickService.className(playerClass); + String who = RPTexts.WARN + character.getName() + RPTexts.MUTED + " (" + player.getName() + ")"; + boolean playing = playerClass != null && id.equalsIgnoreCase(character.getMMOClass()); + if (unlock ? ClassUnlocks.isUnlocked(player, id) && playing : !ClassUnlocks.isUnlocked(player, id)) { + RPTexts.send(sender, RPTexts.WARN + name + RPTexts.MUTED + " is already " + (unlock ? "assigned to" : "released for") + + " " + who + RPTexts.MUTED + "."); + return true; + } + if (!(unlock ? ClassUnlocks.assign(player, id) : ClassUnlocks.lock(player, id))) { + RPTexts.send(sender, RPTexts.ERROR + "Could not " + (unlock ? "assign " + name + RPTexts.ERROR + " to " : "save ") + + RPTexts.WARN + character.getName() + RPTexts.ERROR + ". See the console."); + return true; + } + RPTexts.send(sender, unlock + ? RPTexts.SUCCESS + "Assigned " + name + RPTexts.SUCCESS + " to " + who + RPTexts.SUCCESS + + ". They are bound to it until released." + : RPTexts.SUCCESS + "Released " + name + RPTexts.SUCCESS + " for " + who + RPTexts.SUCCESS + + ". They keep it for now, but can change class in " + RPTexts.COMMAND + "/class" + + RPTexts.SUCCESS + " and can't pick it back."); + return true; + } + + /** Candidates for {@code admin assignclass|releaseclass }, before prefix filtering. */ + public static List tabComplete(String[] args) { + List completions = new ArrayList<>(); + if (args.length == 3) { + Bukkit.getOnlinePlayers().forEach(online -> completions.add(online.getName())); + } else if (args.length == 4 && (args[1].equalsIgnoreCase(LOCK) || args[1].equalsIgnoreCase(OLD_LOCK))) { + completions.addAll(ClassUnlocks.unlocked(Bukkit.getPlayerExact(args[2]))); + } else if (args.length == 4 && MMOCore.plugin != null && MMOCore.plugin.classManager != null) { + Player target = Bukkit.getPlayerExact(args[2]); + for (PlayerClass playerClass : MMOCore.plugin.classManager.getAll()) { + if (!ClassUnlocks.isUnlocked(target, playerClass.getId())) { + completions.add(playerClass.getId()); + } + } + } + return completions; + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassUnlocks.java b/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassUnlocks.java new file mode 100644 index 00000000..9bf39455 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassUnlocks.java @@ -0,0 +1,158 @@ +package net.tfminecraft.rpcharacters.classpick; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.bukkit.entity.Player; + +import net.Indyuce.mmocore.MMOCore; +import net.tfminecraft.rpcharacters.RPCharacters; +import net.tfminecraft.rpcharacters.enums.Status; +import net.tfminecraft.rpcharacters.lifecycle.CharacterLifecycle; +import net.tfminecraft.rpcharacters.managers.PlayerManager; +import net.tfminecraft.rpcharacters.mmocore.ClassService; +import net.tfminecraft.rpcharacters.objects.PlayerData; +import net.tfminecraft.rpcharacters.objects.RPCharacter; + +/** + * Hidden MMOCore classes ({@code options.display: false}) that other plugins assign to one character, + * such as Magic's wild magic classes. Players never pick them: the class picker doesn't list them, and + * {@link #assign} puts the character into the class for free. While a character plays an assigned + * class it is bound to it, so the class picker refuses any change until staff release it + * ({@link #lock}). Assigned classes are saved in the character file under "unlocked-classes". Class + * ids are matched like MMOCore ids: upper case, with dashes and spaces as underscores. Call from the + * main thread; changes write the character file at once. + */ +public final class ClassUnlocks { + + private static final Logger LOG = Logger.getLogger("RPCharacters"); + + private ClassUnlocks() {} + + /** + * Records the class as assigned to the player's active living character and saves, without + * switching to it ({@link #assign} does both). False without an active character, for an unknown + * MMOCore class id, or when the save failed (nothing changes then). Idempotent. + */ + public static boolean unlock(Player player, String classId) { + RPCharacter character = activeCharacter(player); + String id = normalise(classId); + if (character == null || id == null || MMOCore.plugin.classManager.get(id) == null) { + return false; + } + return character.getUnlockedClasses().contains(id) || change(player, character, id, true); + } + + /** + * Records the class for the active living character and switches the character into it, for free + * and without the picker. False without an active character, for an unknown class id, when the + * save failed or when MMOCore would not apply the class (the character keeps its class then; the + * record stays, so staff can retry). Idempotent. + */ + public static boolean assign(Player player, String classId) { + if (!unlock(player, classId)) { + return false; + } + RPCharacter character = activeCharacter(player); + String id = normalise(classId); + String oldClassId = character.getMMOClass(); + if (id.equalsIgnoreCase(oldClassId) && ClassService.isOnClass(player, id)) { + return true; + } + character.setMMOClass(id); + boolean applied; + try { + applied = ClassService.applyClass(player, id); + } catch (RuntimeException e) { + LOG.log(Level.WARNING, "[RPCharacters] Class " + id + " could not be applied for " + player.getName(), e); + applied = false; + } + if (!applied) { + character.setMMOClass(oldClassId); + LOG.warning("[RPCharacters] Could not switch " + character.getName() + " (" + character.getId() + ") of " + + player.getName() + " into " + id + "; assign it again or set it by hand."); + return false; + } + PlayerData pd = PlayerManager.get(player); + CharacterLifecycle.notifyClassChange(player, pd.getUniqueId(), character, oldClassId, character.getMMOClass()); + RPCharacters.getPlayerManager().savePlayer(player); + LOG.info("[RPCharacters] Assigned class " + id + " to " + character.getName() + " (" + character.getId() + + ") of " + player.getName() + "."); + return true; + } + + /** True when the character plays a class assigned to it, so it can't change class itself. */ + public static boolean isLockedIn(RPCharacter character) { + return character != null && character.hasMMOClass() + && character.getUnlockedClasses().contains(normalise(character.getMMOClass())); + } + + /** + * Releases the class and saves: the character keeps playing it, but may change class in the picker + * again (and can't pick this one back). False without an active character or when the save failed + * (it stays assigned then); true if it was not assigned. + */ + public static boolean lock(Player player, String classId) { + RPCharacter character = activeCharacter(player); + String id = normalise(classId); + if (character == null || id == null) { + return false; + } + return !character.getUnlockedClasses().contains(id) || change(player, character, id, false); + } + + /** Unlocked class ids (upper case) of the active character; empty set without one. */ + public static Set unlocked(Player player) { + RPCharacter character = activeCharacter(player); + return character == null ? Set.of() + : Collections.unmodifiableSet(new LinkedHashSet<>(character.getUnlockedClasses())); + } + + public static boolean isUnlocked(Player player, String classId) { + String id = normalise(classId); + return id != null && unlocked(player).contains(id); + } + + /** The id as MMOCore stores it, or null for a blank one. */ + static String normalise(String classId) { + if (classId == null || classId.isBlank()) { + return null; + } + return classId.trim().toUpperCase(Locale.ROOT).replace('-', '_').replace(' ', '_'); + } + + static RPCharacter activeCharacter(Player player) { + PlayerData pd = player == null ? null : PlayerManager.get(player); + RPCharacter character = pd == null ? null : pd.getActiveCharacter(); + return character != null && character.getStatus() == Status.ALIVE ? character : null; + } + + /** Applies the change and writes the character file; a failed write puts the old state back. */ + private static boolean change(Player player, RPCharacter character, String id, boolean unlock) { + if (unlock) { + character.addUnlockedClass(id); + } else { + character.removeUnlockedClass(id); + } + String what = "class " + id + " for " + character.getName() + " (" + character.getId() + ") of " + + player.getName(); + if (RPCharacters.getPlayerManager().saveCharacterNow(player.getUniqueId(), character)) { + LOG.info("[RPCharacters] " + (unlock ? "Unlocked " : "Locked ") + what + "."); + return true; + } + if (unlock) { + character.removeUnlockedClass(id); + } else { + character.addUnlockedClass(id); + } + // A failed save may already have replaced the file, so write the old state back. + RPCharacters.getPlayerManager().saveCharacterNow(player.getUniqueId(), character); + LOG.warning("[RPCharacters] Could not save the character file, so " + what + " stays " + + (unlock ? "locked." : "unlocked.")); + return false; + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/database/CharacterClassPickFields.java b/src/main/java/net/tfminecraft/rpcharacters/database/CharacterClassPickFields.java index 4088f038..bb9d56d3 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/database/CharacterClassPickFields.java +++ b/src/main/java/net/tfminecraft/rpcharacters/database/CharacterClassPickFields.java @@ -1,8 +1,10 @@ package net.tfminecraft.rpcharacters.database; +import java.util.List; import java.util.Map; import java.util.logging.Logger; +import org.json.simple.JSONArray; import org.json.simple.JSONObject; import net.tfminecraft.rpcharacters.classpick.PendingClassPick; @@ -12,14 +14,34 @@ /** * Class picker state in the character file, under "class-picks": whether the free subclass pick * is used ("subclass-picked"), how many picks were paid for ("paid") and a paid pick saved before - * its debit ("pending"). + * its debit ("pending"). Hidden classes the character has unlocked are listed under + * "unlocked-classes", and classes with their own skill-point pool under "class-skill-points". */ public final class CharacterClassPickFields { + static final String UNLOCKED_CLASSES = "unlocked-classes"; + static final String CLASS_SKILL_POINTS = "class-skill-points"; + private CharacterClassPickFields() {} public static void load(RPCharacter character, JSONObject characterJson) { - if (character == null || characterJson == null - || !(characterJson.get("class-picks") instanceof Map picks)) { + if (character == null || characterJson == null) { + return; + } + if (characterJson.get(UNLOCKED_CLASSES) instanceof List unlocked) { + for (Object classId : unlocked) { + if (classId instanceof String id && !id.isBlank()) { + character.addUnlockedClass(id.trim()); + } + } + } + if (characterJson.get(CLASS_SKILL_POINTS) instanceof Map pools) { + for (Map.Entry pool : pools.entrySet()) { + if (pool.getKey() instanceof String id && !id.isBlank() && pool.getValue() instanceof Number total) { + character.setClassSkillPoints(id.trim(), total.intValue()); + } + } + } + if (!(characterJson.get("class-picks") instanceof Map picks)) { return; } character.setSubclassPicked(Boolean.TRUE.equals(picks.get("subclass-picked"))); @@ -40,6 +62,16 @@ public static void load(RPCharacter character, JSONObject characterJson) { @SuppressWarnings("unchecked") public static void save(Map defaults, RPCharacter character) { + if (!character.getUnlockedClasses().isEmpty()) { + JSONArray unlocked = new JSONArray(); + unlocked.addAll(character.getUnlockedClasses()); + defaults.put(UNLOCKED_CLASSES, unlocked); + } + if (!character.getClassSkillPoints().isEmpty()) { + JSONObject pools = new JSONObject(); + pools.putAll(character.getClassSkillPoints()); + defaults.put(CLASS_SKILL_POINTS, pools); + } PendingClassPick pending = character.getPendingClassPick(); if (!character.hasPickedSubclass() && character.getPaidClassPicks() == 0 && pending == null) { return; diff --git a/src/main/java/net/tfminecraft/rpcharacters/ingest/RosterSyncService.java b/src/main/java/net/tfminecraft/rpcharacters/ingest/RosterSyncService.java index 76d7a0ff..7e96df07 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/ingest/RosterSyncService.java +++ b/src/main/java/net/tfminecraft/rpcharacters/ingest/RosterSyncService.java @@ -380,7 +380,8 @@ private static JSONArray traitRows(RPCharacter character) { } java.util.List editable = Cache.editableTraits; for (Trait trait : character.getTraits()) { - if (trait == null || trait.getId() == null || trait.getTraitData() == null) { + if (trait == null || trait.getId() == null || trait.getTraitData() == null + || trait.getTraitData().isHidden()) { continue; } String key = trait.getTraitData().getKey(); diff --git a/src/main/java/net/tfminecraft/rpcharacters/managers/CommandManager.java b/src/main/java/net/tfminecraft/rpcharacters/managers/CommandManager.java index f705015b..a8006821 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/managers/CommandManager.java +++ b/src/main/java/net/tfminecraft/rpcharacters/managers/CommandManager.java @@ -17,6 +17,7 @@ import net.tfminecraft.rpcharacters.Cache; import net.tfminecraft.rpcharacters.classpick.ClassPickGui; import net.tfminecraft.rpcharacters.classpick.ClassPickReset; +import net.tfminecraft.rpcharacters.classpick.ClassUnlockCommand; import net.tfminecraft.rpcharacters.loaders.ChatLoader; import net.tfminecraft.rpcharacters.loaders.TraitLoader; import net.tfminecraft.rpcharacters.objects.PlayerData; @@ -729,7 +730,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")) { @@ -748,7 +749,10 @@ 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 ..."); + if (ClassUnlockCommand.isSubcommand(args[1])) { + return ClassUnlockCommand.handle(sender, args, 2); + } + RPTexts.send(sender, RPTexts.ERROR + "Usage: /rpcharacter admin ..."); return true; } diff --git a/src/main/java/net/tfminecraft/rpcharacters/managers/CreationManager.java b/src/main/java/net/tfminecraft/rpcharacters/managers/CreationManager.java index a25b72a1..fac451a6 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/managers/CreationManager.java +++ b/src/main/java/net/tfminecraft/rpcharacters/managers/CreationManager.java @@ -547,6 +547,11 @@ private void handleSummaryClick(Player p, InventoryClickEvent e) { } String stageId = action.substring("edit:".length()); Stage stage = StageLoader.getById(stageId); + if (cc.isEditing() && isClassStage(stage) + && net.tfminecraft.rpcharacters.classpick.ClassUnlocks.isLockedIn(cc.getCharacter())) { + RPTexts.send(p, RPTexts.ERROR + "Your class is bound to your character and can't be changed."); + return; + } if (cc.isEditing() && isClassStage(stage) && ClassPickService.isEnabled()) { // Class picks are priced by the class picker, not the stage's lock and paid changes. cc.closeEditSession(); diff --git a/src/main/java/net/tfminecraft/rpcharacters/managers/InventoryManager.java b/src/main/java/net/tfminecraft/rpcharacters/managers/InventoryManager.java index 0c433ccb..45c20175 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/managers/InventoryManager.java +++ b/src/main/java/net/tfminecraft/rpcharacters/managers/InventoryManager.java @@ -80,7 +80,7 @@ public void characterView(Player p, RPCharacter c) { Inventory i = RPCharacters.plugin.getServer().createInventory(new RPCHolder(c.getOwner()), 27, t(RPTexts.MUTED + "Character Info")); i.setItem(10, getCharacterItem(c, false)); i.setItem(12, getDescriptionItem(c)); - i.setItem(14, getTraitsItem(c)); + i.setItem(14, getTraitsItem(c, p)); if (c.getStatus().equals(Status.ALIVE) && c.getOwner().equals(p)) { i.setItem(16, getCluesPreviewItem(c)); } @@ -546,7 +546,7 @@ public ItemStack getCluesPreviewItem(RPCharacter c) { public void traitsView(Player p, RPCharacter c) { int visibleTraitCount = 0; for(Trait t : c.getTraits()) { - if(Cache.backgroundTraitTypes.contains(t.getTraitData().getKey())) continue; + if(Cache.backgroundTraitTypes.contains(t.getTraitData().getKey()) || !shownTo(p, c, t)) continue; visibleTraitCount++; } int rows = Math.max(3, Math.min(6, ((visibleTraitCount + 1) + 8) / 9)); @@ -555,7 +555,7 @@ public void traitsView(Player p, RPCharacter c) { int slot = 0; for(Trait t : c.getTraits()) { if(slot >= i.getSize() - 1) break; - if(Cache.backgroundTraitTypes.contains(t.getTraitData().getKey())) continue; + if(Cache.backgroundTraitTypes.contains(t.getTraitData().getKey()) || !shownTo(p, c, t)) continue; i.setItem(slot, getTraitInfoItem(t, c)); slot++; } @@ -1024,12 +1024,25 @@ public ItemStack getDescriptionItem(RPCharacter c) { i.setItemMeta(meta); return i; } - /** Shown traits as legacy text: name, time left and type, without background traits. */ - @SuppressWarnings("deprecation") + /** + * Whether the viewer may see this trait: a hidden one (TraitData.isHidden, such as another plugin's + * secret mark) only shows to the character's own player. + */ + static boolean shownTo(Player viewer, RPCharacter c, Trait trait) { + return !trait.getTraitData().isHidden() || viewer != null && viewer.equals(c.getOwner()); + } + + /** The character's own player's view of {@link #traitLines(RPCharacter, Player)}. */ public static List traitLines(RPCharacter c) { + return traitLines(c, c.getOwner()); + } + + /** Shown traits as legacy text: name, time left and type, without background traits or hidden ones the viewer may not see. */ + @SuppressWarnings("deprecation") + public static List traitLines(RPCharacter c, Player viewer) { List lines = new ArrayList<>(); for(Trait trait : c.getTraits()) { - if(Cache.backgroundTraitTypes.contains(trait.getTraitData().getKey())) continue; + if(Cache.backgroundTraitTypes.contains(trait.getTraitData().getKey()) || !shownTo(viewer, c, trait)) continue; String key = trait.getTraitData().getKey(); boolean injuryOrProsthetic = key != null && (key.equalsIgnoreCase("injury") || key.equalsIgnoreCase("prosthetic")); @@ -1049,8 +1062,13 @@ public static List traitLines(RPCharacter c) { return lines; } - @SuppressWarnings("deprecation") + /** The character's own player's view of {@link #getTraitsItem(RPCharacter, Player)}. */ public ItemStack getTraitsItem(RPCharacter c) { + return getTraitsItem(c, c.getOwner()); + } + + @SuppressWarnings("deprecation") + public ItemStack getTraitsItem(RPCharacter c, Player viewer) { ItemStack i = new ItemStack(Material.GOLDEN_APPLE, 1); ItemMeta meta = i.getItemMeta(); meta.setDisplayName(t(RPTexts.MUTED + "Character Traits")); @@ -1058,7 +1076,7 @@ public ItemStack getTraitsItem(RPCharacter c) { lore.add(t(RPTexts.MUTED + "------------------------")); lore.add(t(RPTexts.WARN + "Traits:")); lore.add(RPTexts.spacer()); - lore.addAll(traitLines(c)); + lore.addAll(traitLines(c, viewer)); if (c.getStatus().equals(Status.ALIVE)) { lore.addAll(PermadeathService.computeRisk(c).toLoreLines()); } diff --git a/src/main/java/net/tfminecraft/rpcharacters/mmocore/ClassService.java b/src/main/java/net/tfminecraft/rpcharacters/mmocore/ClassService.java index 9dc1bdbc..aad3bcbe 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/mmocore/ClassService.java +++ b/src/main/java/net/tfminecraft/rpcharacters/mmocore/ClassService.java @@ -240,11 +240,39 @@ public static void applyFreeSkillPoints(Player player) { return; } PlayerData mmoPd = PlayerData.get(player); - int total = pools.getSkillPointsTotal(); + OwnPool own = ownPool(player, mmoPd); + int total = own != null ? own.total() : pools.getSkillPointsTotal(); int spent = mmoPd.countSkillPointsSpent(); mmoPd.setSkillPoints(Math.max(0, total - spent)); } + /** Clamps and re-applies the free skill points after a class's own pool changed. */ + public static void refreshSkillPoints(Player player) { + if (player == null) { + return; + } + clampExcessSkillPool(player); + applyFreeSkillPoints(player); + } + + /** + * A class with its own skill-point pool (see {@link net.tfminecraft.rpcharacters.classpick.ClassSkillPoints}): + * the live character, the class id and its total. + */ + private record OwnPool(net.tfminecraft.rpcharacters.objects.RPCharacter character, String classId, int total) {} + + /** The current class's own pool on the active character, or null when it shares the character's pool. */ + private static OwnPool ownPool(Player player, PlayerData mmoPd) { + net.tfminecraft.rpcharacters.objects.PlayerData account = PlayerManager.get(player); + net.tfminecraft.rpcharacters.objects.RPCharacter character = account == null ? null : account.getActiveCharacter(); + PlayerClass profess = mmoPd.getProfess(); + if (character == null || profess == null) { + return null; + } + java.util.OptionalInt total = character.getClassSkillPoints(profess.getId()); + return total.isEmpty() ? null : new OwnPool(character, profess.getId(), total.getAsInt()); + } + public static void syncSkillPoints(Player player) { syncSkillPoints(player, null); } @@ -261,19 +289,27 @@ public static void syncSkillPoints(Player player, CommandSender sender) { List stripped = sanitizeForeignSkillLevels(mmoPd); int spent = mmoPd.countSkillPointsSpent(); int mmoPool = (int) Math.min(Integer.MAX_VALUE, (long) mmoPd.getSkillPoints() + spent); - int beforeTotal = pools.getSkillPointsTotal(); + // Staff skill-point commands on a class with its own pool change that pool, not the shared one. + OwnPool own = ownPool(player, mmoPd); + int beforeTotal = own != null ? own.total() : pools.getSkillPointsTotal(); if (mmoPool != beforeTotal) { - pools.setSkillPointsTotal(mmoPool); - RPCharacters.getPlayerManager().savePlayer(player); - CharacterProgressService.checkpoint(player); + if (own != null) { + own.character().setClassSkillPoints(own.classId(), mmoPool); + RPCharacters.getPlayerManager().saveCharacterNow(player.getUniqueId(), own.character()); + } else { + pools.setSkillPointsTotal(mmoPool); + RPCharacters.getPlayerManager().savePlayer(player); + CharacterProgressService.checkpoint(player); + } } applyFreeSkillPoints(player); if (sender != null && Cache.skillPointsAdminDebugMessages) { - int afterTotal = pools.getSkillPointsTotal(); + OwnPool after = ownPool(player, mmoPd); + int afterTotal = after != null ? after.total() : pools.getSkillPointsTotal(); int unspent = mmoPd.getSkillPoints(); spent = mmoPd.countSkillPointsSpent(); RPTexts.sendPrefixed(sender, RPTexts.WARN + player.getName() - + " skill points: " + pools.holder() + " total " + RPTexts.SUCCESS + beforeTotal + " → " + afterTotal + + " skill points: " + (after != null ? "class" : pools.holder()) + " total " + RPTexts.SUCCESS + beforeTotal + " → " + afterTotal + RPTexts.WARN + ", unspent now " + RPTexts.SUCCESS + unspent + RPTexts.WARN + " (spent on class: " + RPTexts.SUCCESS + spent + RPTexts.WARN + ", mmo pool: " + RPTexts.SUCCESS + mmoPool + RPTexts.WARN + ")"); @@ -531,7 +567,8 @@ private static void clampExcessSkillPool(Player player) { return; } PlayerData mmoPd = PlayerData.get(player); - int lifetime = pools.getSkillPointsTotal(); + OwnPool own = ownPool(player, mmoPd); + int lifetime = own != null ? own.total() : pools.getSkillPointsTotal(); while ((long) mmoPd.getSkillPoints() + mmoPd.countSkillPointsSpent() > lifetime) { int unspent = mmoPd.getSkillPoints(); if (unspent > 0) { diff --git a/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java b/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java index 3bffa060..4777d245 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java +++ b/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java @@ -130,6 +130,10 @@ public class RPCharacter { private boolean subclassPicked; /** Class picks this character has paid for. */ private int paidClassPicks; + /** Upper-case ids of hidden classes this character may pick; see ClassUnlocks. */ + private final Set unlockedClasses = new LinkedHashSet<>(); + /** Skill-point totals of classes that keep their own pool, keyed by upper-case class id; see ClassSkillPoints. */ + private final Map classSkillPoints = new LinkedHashMap<>(); /** Stage revision each stage's lock window was last opened for, keyed by stage id. */ private final Map stageRevisions = new HashMap<>(); /** Epoch seconds a revision reset opened each stage's window; 0 counts from creation. */ @@ -275,6 +279,42 @@ public void setPaidClassPicks(int paidClassPicks) { this.paidClassPicks = Math.max(0, paidClassPicks); } + /** Upper-case class ids, in unlock order. */ + public Set getUnlockedClasses() { + return Collections.unmodifiableSet(unlockedClasses); + } + + /** True when the id was not unlocked before. */ + public boolean addUnlockedClass(String classId) { + return unlockedClasses.add(classId.toUpperCase(Locale.ROOT)); + } + + /** True when the id was unlocked. */ + public boolean removeUnlockedClass(String classId) { + return unlockedClasses.remove(classId.toUpperCase(Locale.ROOT)); + } + + /** Own skill-point pools by upper-case class id, read only. */ + public Map getClassSkillPoints() { + return Collections.unmodifiableMap(classSkillPoints); + } + + /** The class's own skill-point total; empty when it shares the character's pool. */ + public java.util.OptionalInt getClassSkillPoints(String classId) { + Integer total = classId == null ? null : classSkillPoints.get(classId.toUpperCase(Locale.ROOT)); + return total == null ? java.util.OptionalInt.empty() : java.util.OptionalInt.of(total); + } + + /** Gives the class its own skill-point pool with this total (never below 0). */ + public void setClassSkillPoints(String classId, int total) { + classSkillPoints.put(classId.toUpperCase(Locale.ROOT), Math.max(0, total)); + } + + /** Puts the class back on the character's shared pool. */ + public void removeClassSkillPoints(String classId) { + classSkillPoints.remove(classId.toUpperCase(Locale.ROOT)); + } + public net.tfminecraft.rpcharacters.classpick.PendingClassPick getPendingClassPick() { return pendingClassPick; } diff --git a/src/main/java/net/tfminecraft/rpcharacters/objects/trait/TraitData.java b/src/main/java/net/tfminecraft/rpcharacters/objects/trait/TraitData.java index 815fffd9..20dec969 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/objects/trait/TraitData.java +++ b/src/main/java/net/tfminecraft/rpcharacters/objects/trait/TraitData.java @@ -27,6 +27,7 @@ public class TraitData { private Material icon; private boolean blockOffhand; private boolean canLootGraves; + private boolean hidden; public TraitData(ConfigurationSection config) { key = config.getString("key"); @@ -76,6 +77,7 @@ public TraitData(ConfigurationSection config) { } blockOffhand = config.getBoolean("block-offhand", false); canLootGraves = config.getBoolean("can-loot-graves", false); + hidden = config.getBoolean("hidden", false); if (config.contains("icon")) { String raw = config.getString("icon"); if (raw != null && !raw.isBlank()) { @@ -202,4 +204,12 @@ public boolean blocksOffhand() { public boolean canLootGraves() { return canLootGraves; } + + /** + * A trait kept off the website: neither the creation catalog nor a character's roster entry lists it + * (hidden content, such as the mark another plugin's secret path leaves). The game still shows it. + */ + public boolean isHidden() { + return hidden; + } } diff --git a/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManager.java b/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManager.java index 638de121..41e372e0 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManager.java +++ b/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManager.java @@ -197,6 +197,25 @@ public boolean isDown(Player player) { return isKnockedOut(player); } + /** + * Moves a knocked-out player and keeps them down at the new spot, for plugins that drag or + * carry the body. Returns false when the player is not knocked out. + */ + public boolean moveDowned(Player player, Location to) { + if (to == null || to.getWorld() == null || !isKnockedOut(player)) { + return false; + } + Location dest = to.clone(); + dest.setYaw(player.getLocation().getYaw()); + dest.setPitch(player.getLocation().getPitch()); + // Only a move that really happened becomes the new freeze spot; a refused teleport keeps the old one. + if (!player.teleport(dest)) { + return false; + } + knockouts.get(player.getUniqueId()).location = to.clone(); + return true; + } + private boolean isKnockedOut(Player player) { Knockout knockout = knockouts.get(player.getUniqueId()); return knockout != null && !knockout.isOver(player.getUniqueId(), System.currentTimeMillis()) @@ -289,7 +308,8 @@ private boolean shouldSkipTick(Player player) { private void enforceFreeze(Player player, Location freezeAt) { Location here = player.getLocation(); - if (here.getX() == freezeAt.getX() + if (here.getWorld() == freezeAt.getWorld() + && here.getX() == freezeAt.getX() && here.getY() == freezeAt.getY() && here.getZ() == freezeAt.getZ()) { return; @@ -301,7 +321,7 @@ private void enforceFreeze(Player player, Location freezeAt) { } private static final class Knockout { - private final Location location; + private Location location; private final long untilMs; /** Held down by a winner's decision rather than for a fixed time. */ private final boolean deciding; diff --git a/src/main/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleter.java b/src/main/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleter.java index 67e916e8..729437f1 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleter.java +++ b/src/main/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleter.java @@ -17,6 +17,7 @@ import net.tfminecraft.rpcharacters.Cache; import net.tfminecraft.rpcharacters.classpick.ClassPickReset; +import net.tfminecraft.rpcharacters.classpick.ClassUnlockCommand; import net.tfminecraft.rpcharacters.enums.Status; import net.tfminecraft.rpcharacters.loaders.TraitLoader; import net.tfminecraft.rpcharacters.managers.PlayerManager; @@ -112,6 +113,10 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe && PrisonSwitch.canUse(sender)) { return completePrison(args); } + if (sub.equals("admin") && args.length >= 3 && Permissions.isAdmin(sender) + && ClassUnlockCommand.isSubcommand(args[1])) { + return filter(ClassUnlockCommand.tabComplete(args), args[args.length - 1]); + } if (sub.equals("admin") && args.length >= 3 && Permissions.isAdmin(sender) && (args[1].equalsIgnoreCase("strikes") || args[1].equalsIgnoreCase("tutorial"))) { return completeAdminEvilRp(args); @@ -174,6 +179,8 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe completions.add("strikes"); completions.add("tutorial"); completions.add(net.tfminecraft.rpcharacters.mmocore.CarriedExperience.SUBCOMMAND); + completions.add(ClassUnlockCommand.UNLOCK); + completions.add(ClassUnlockCommand.LOCK); } if (ClassPickReset.canUse(sender)) { completions.add(ClassPickReset.SUBCOMMAND); diff --git a/src/main/resources/traits/injury-traits.yml b/src/main/resources/traits/injury-traits.yml index 8b2b63a1..7c14220f 100644 --- a/src/main/resources/traits/injury-traits.yml +++ b/src/main/resources/traits/injury-traits.yml @@ -81,3 +81,20 @@ blind: description: - "§7This character is blind." - "§7There is no prosthetic for blindness." + +manaless: + name: "#8a0303Manaless" + key: injury + # Only given by Magic's hidden blood path (the devotee's mark), and kept off the website. + hidden: true + gained-message: "#8a0303Something of you stays on the stone. The mana in you drains away, and it will not come back" + attribute-modifiers: + - strength.-2 + - dexterity.-2 + - constitution.-2 + - intelligence.-2 + - wisdom.-2 + - charisma.-2 + description: + - "§7Something was taken from this character that will not grow back." + - "§7It can hold no mana, and it is weaker in every way." diff --git a/src/test/java/net/tfminecraft/rpcharacters/catalog/CreationCatalogSyncServiceTest.java b/src/test/java/net/tfminecraft/rpcharacters/catalog/CreationCatalogSyncServiceTest.java index df1f6521..fdeec5b8 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/catalog/CreationCatalogSyncServiceTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/catalog/CreationCatalogSyncServiceTest.java @@ -175,7 +175,7 @@ static YamlConfiguration config(String yaml) throws Exception { arm.getTraitData().getExclusive().addAll(Arrays.asList(null, "one", "two")); prosthetics.when(() -> ProstheticLoader.getReplacementForProsthetic("arm")).thenReturn(new ProstheticReplacement("lost_arm", Map.of("arm", "v.IRON_INGOT"))); trait("leg", "prosthetic", "required-account-playtime: 1.5\n"); - trait("permanent", "injury", ""); trait("temporary", "injury", "duration: 1h\n"); + trait("permanent", "injury", ""); trait("temporary", "injury", "duration: 1h\n"); trait("secret", "injury", "hidden: true\n"); Trait nullKey = mock(Trait.class); TraitData data = mock(TraitData.class); when(nullKey.getId()).thenReturn("untyped"); when(nullKey.getTraitData()).thenReturn(data); when(nullKey.getDesc()).thenReturn(List.of()); TraitLoader.oList.add(nullKey); JSONObject root = payload(); var races = rows(root, "races"); assertEquals(2, races.size()); diff --git a/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassPickTest.java b/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassPickTest.java index 01cae1bf..2041ad57 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassPickTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassPickTest.java @@ -330,6 +330,30 @@ Inventory openSubclasses() { character.setMMOClass("missing"); assertTrue(ClassPickService.subclassOptions(character).isEmpty()); } + @Test void assignedHiddenClassesAreNeverOfferedAndBindTheCharacterWhilePlayed() { + assertEquals(Status.UNKNOWN_CLASS, ClassPickService.choose(player, "test").status(), "Players never pick a hidden class"); + playerClass("blood", "Blood", -5, null); + for (String id : List.of("test", "blood")) character.addUnlockedClass(id); + assertEquals(List.of(warrior, mage), ClassPickService.baseClasses()); + assertNull(ClassPickService.option("test")); + assertEquals(Status.UNKNOWN_CLASS, ClassPickService.choose(player, "test").status(), "Assigned or not, the picker can't choose it"); + var top = openPicker(); + for (int slot = 0; slot < top.getSize(); slot++) { + var item = top.getItem(slot); + assertFalse(item != null && item.hasItemMeta() && "Test".equals(ChatColor.stripColor(item.getItemMeta().getDisplayName()))); + } + character.setMMOClass("TEST"); + assertEquals(Status.ASSIGNED_CLASS, ClassPickService.blocker(player)); + assertEquals(Status.ASSIGNED_CLASS, ClassPickService.choose(player, "mage").status()); + assertEquals("Your class is bound to your character and can't be changed.", + ChatColor.stripColor(ClassPickService.message(new Result(Status.ASSIGNED_CLASS, null, BigDecimal.ZERO, null)))); + player.closeInventory(); messages(); ClassPickGui.open(player); + assertTrue(messages().contains("can't be changed")); + character.removeUnlockedClass("test"); + assertNull(ClassPickService.blocker(player), "A released class no longer binds"); + assertEquals("TEST", character.getMMOClass()); + } + @Test void pickerUsesAdditionalRowsForMoreThanSevenClassesAndSubclasses() { for (int i = 0; i < 9; i++) { playerClass("base" + i, "Base " + i, 10 + i, null); displayed.add("base" + i); } var top = openPicker(); assertEquals(Material.PAPER, top.getItem(19).getType()); diff --git a/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassSkillPointsTest.java b/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassSkillPointsTest.java new file mode 100644 index 00000000..d4186cf8 --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassSkillPointsTest.java @@ -0,0 +1,96 @@ +package net.tfminecraft.rpcharacters.classpick; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.util.*; +import java.util.logging.Handler; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockito.MockedStatic; + +import net.Indyuce.mmocore.MMOCore; +import net.Indyuce.mmocore.api.player.profess.PlayerClass; +import net.Indyuce.mmocore.manager.ClassManager; +import net.tfminecraft.rpcharacters.RPCharacters; +import net.tfminecraft.rpcharacters.enums.Status; +import net.tfminecraft.rpcharacters.managers.PlayerManager; +import net.tfminecraft.rpcharacters.mmocore.ClassService; +import net.tfminecraft.rpcharacters.objects.PlayerData; +import net.tfminecraft.rpcharacters.objects.RPCharacter; + +class ClassSkillPointsTest { + ServerMock server; PlayerMock iris; MMOCore previousMmo; RPCharacter character; PlayerManager saves; PlayerData account; + MockedStatic players; MockedStatic classes; final List> mocks = new ArrayList<>(); + final List logs = new ArrayList<>(); + final Handler handler = new Handler() { + @Override public void publish(LogRecord record) { logs.add(record); } + @Override public void flush() {} + @Override public void close() {} + }; + + @BeforeEach void setup() throws Exception { + server = MockBukkit.mock(); iris = server.addPlayer("Iris"); + previousMmo = MMOCore.plugin; MMOCore.plugin = mock(MMOCore.class); var manager = mock(ClassManager.class); + Field field = MMOCore.class.getField("classManager"); field.setAccessible(true); field.set(MMOCore.plugin, manager); + when(manager.get("BLOODSWORN")).thenReturn(mock(PlayerClass.class)); + character = new RPCharacter(iris); character.setName("Hero"); + account = mock(PlayerData.class); when(account.getActiveCharacter()).thenReturn(character); + players = mockStatic(PlayerManager.class); mocks.add(players); players.when(() -> PlayerManager.get(iris)).thenReturn(account); + saves = mock(PlayerManager.class); when(saves.saveCharacterNow(any(), any())).thenReturn(true); + var root = mockStatic(RPCharacters.class); mocks.add(root); root.when(RPCharacters::getPlayerManager).thenReturn(saves); + classes = mockStatic(ClassService.class); mocks.add(classes); + Logger.getLogger("RPCharacters").addHandler(handler); + } + + @AfterEach void cleanup() { + Logger.getLogger("RPCharacters").removeHandler(handler); + for (int i = mocks.size() - 1; i >= 0; i--) mocks.get(i).close(); + MMOCore.plugin = previousMmo; MockBukkit.unmock(); + } + + boolean logged(String text) { return logs.stream().anyMatch(record -> record.getMessage().contains(text)); } + + @Test void poolsAreSetPerCharacterSavedAndRefreshOnlyWhilePlayed() { + assertEquals(-1, ClassSkillPoints.get(iris, "bloodsworn")); + assertTrue(ClassSkillPoints.set(iris, "bloodsworn", 0)); + assertEquals(0, ClassSkillPoints.get(iris, "Bloodsworn")); + verify(saves).saveCharacterNow(iris.getUniqueId(), character); + classes.verify(() -> ClassService.refreshSkillPoints(any()), never()); + assertTrue(ClassSkillPoints.set(iris, "BLOODSWORN", 0), "The same total again is a success"); + verify(saves, times(1)).saveCharacterNow(any(), any()); + classes.when(() -> ClassService.isOnClass(iris, "BLOODSWORN")).thenReturn(true); + assertTrue(ClassSkillPoints.set(iris, "bloodsworn", 8)); + assertEquals(8, character.getClassSkillPoints("BLOODSWORN").getAsInt()); + classes.verify(() -> ClassService.refreshSkillPoints(iris)); + assertTrue(ClassSkillPoints.set(iris, "bloodsworn", -2)); + assertEquals(0, ClassSkillPoints.get(iris, "bloodsworn")); + assertFalse(ClassSkillPoints.set(iris, "missing", 1)); + assertFalse(ClassSkillPoints.set(iris, " ", 1)); + assertFalse(ClassSkillPoints.set(null, "bloodsworn", 1)); + assertEquals(-1, ClassSkillPoints.get(iris, null)); + character.setStatus(Status.DEAD); + assertFalse(ClassSkillPoints.set(iris, "bloodsworn", 4)); + assertEquals(-1, ClassSkillPoints.get(iris, "bloodsworn")); + } + + @Test void aFailedSaveWritesTheOldPoolBack() { + when(saves.saveCharacterNow(iris.getUniqueId(), character)).thenReturn(false); + assertFalse(ClassSkillPoints.set(iris, "bloodsworn", 4)); + assertTrue(character.getClassSkillPoints("BLOODSWORN").isEmpty()); + assertTrue(logged("BLOODSWORN skill points of Hero (" + character.getId() + ") stay shared.")); + character.setClassSkillPoints("BLOODSWORN", 2); + assertFalse(ClassSkillPoints.set(iris, "bloodsworn", 6)); + assertEquals(2, character.getClassSkillPoints("BLOODSWORN").getAsInt()); + assertTrue(logged("stay 2.")); + verify(saves, times(4)).saveCharacterNow(iris.getUniqueId(), character); + classes.verify(() -> ClassService.refreshSkillPoints(any()), never()); + } +} diff --git a/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassUnlocksTest.java b/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassUnlocksTest.java new file mode 100644 index 00000000..cdc17a15 --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassUnlocksTest.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.lang.reflect.Field; +import java.util.*; +import java.util.logging.Handler; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +import org.bukkit.ChatColor; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockito.MockedStatic; + +import net.Indyuce.mmocore.MMOCore; +import net.Indyuce.mmocore.api.player.profess.PlayerClass; +import net.Indyuce.mmocore.manager.ClassManager; +import net.tfminecraft.rpcharacters.RPCharacters; +import net.tfminecraft.rpcharacters.enums.Status; +import net.tfminecraft.rpcharacters.lifecycle.CharacterLifecycle; +import net.tfminecraft.rpcharacters.managers.PlayerManager; +import net.tfminecraft.rpcharacters.mmocore.ClassService; +import net.tfminecraft.rpcharacters.objects.PlayerData; +import net.tfminecraft.rpcharacters.objects.RPCharacter; + +class ClassUnlocksTest { + ServerMock server; PlayerMock iris, staff; MMOCore previousMmo; PlayerData account; RPCharacter character; PlayerManager saves; + MockedStatic players; MockedStatic service; final List> mocks = new ArrayList<>(); + final Map classMap = new HashMap<>(); + final List logs = new ArrayList<>(); + final Handler handler = new Handler() { + @Override public void publish(LogRecord record) { logs.add(record); } + @Override public void flush() {} + @Override public void close() {} + }; + + @BeforeEach void setup() throws Exception { + server = MockBukkit.mock(); iris = server.addPlayer("Iris"); staff = server.addPlayer("Staff"); + previousMmo = MMOCore.plugin; MMOCore.plugin = mock(MMOCore.class); var classes = mock(ClassManager.class); + Field field = MMOCore.class.getField("classManager"); field.setAccessible(true); field.set(MMOCore.plugin, classes); + when(classes.get(anyString())).thenAnswer(call -> classMap.get(call.getArgument(0))); + var blood = mock(PlayerClass.class); when(blood.getId()).thenReturn("BLOOD_MAGE"); when(blood.getName()).thenReturn("&4Blood Mage"); + classMap.put("BLOOD_MAGE", blood); + character = new RPCharacter(iris); character.setName("Hero"); + account = mock(PlayerData.class); when(account.getActiveCharacter()).thenReturn(character); + players = mockStatic(PlayerManager.class); mocks.add(players); players.when(() -> PlayerManager.get(iris)).thenReturn(account); + saves = mock(PlayerManager.class); when(saves.saveCharacterNow(any(), any())).thenReturn(true); + var root = mockStatic(RPCharacters.class); mocks.add(root); root.when(RPCharacters::getPlayerManager).thenReturn(saves); + service = mockStatic(ClassService.class); mocks.add(service); service.when(() -> ClassService.applyClass(any(), anyString())).thenReturn(true); + mocks.add(mockStatic(CharacterLifecycle.class)); + Logger.getLogger("RPCharacters").addHandler(handler); + } + + @AfterEach void cleanup() { + Logger.getLogger("RPCharacters").removeHandler(handler); + for (int i = mocks.size() - 1; i >= 0; i--) mocks.get(i).close(); + MMOCore.plugin = previousMmo; MockBukkit.unmock(); + } + + boolean run(String... args) { return ClassUnlockCommand.handle(staff, args, 2); } + + String messages() { + List out = new ArrayList<>(); String message; + while ((message = staff.nextMessage()) != null) out.add(ChatColor.stripColor(message)); + return String.join("\n", out); + } + + boolean logged(String text) { return logs.stream().anyMatch(record -> record.getMessage().contains(text)); } + + @Test void unlocksBelongToTheActiveCharacterAreSavedAndLockingKeepsTheCurrentClass() { + assertTrue(ClassUnlocks.unlocked(iris).isEmpty()); + assertTrue(ClassUnlocks.unlock(iris, " blood-mage ")); + assertEquals(Set.of("BLOOD_MAGE"), ClassUnlocks.unlocked(iris)); assertEquals(Set.of("BLOOD_MAGE"), character.getUnlockedClasses()); + assertTrue(ClassUnlocks.isUnlocked(iris, "Blood Mage")); assertFalse(ClassUnlocks.isUnlocked(iris, null)); + verify(saves).saveCharacterNow(iris.getUniqueId(), character); + assertTrue(logged("Unlocked class BLOOD_MAGE for Hero (" + character.getId() + ") of Iris.")); + assertTrue(ClassUnlocks.unlock(iris, "BLOOD_MAGE"), "Unlocking again is a success"); verify(saves, times(1)).saveCharacterNow(any(), any()); + assertThrows(UnsupportedOperationException.class, () -> ClassUnlocks.unlocked(iris).add("OTHER")); + assertFalse(ClassUnlocks.unlock(iris, "missing")); assertFalse(ClassUnlocks.unlock(iris, " ")); assertFalse(ClassUnlocks.unlock(iris, null)); + character.setMMOClass("blood_mage"); + assertTrue(ClassUnlocks.lock(iris, "blood_mage")); assertTrue(ClassUnlocks.unlocked(iris).isEmpty()); + assertEquals("BLOOD_MAGE", character.getMMOClass()); assertTrue(logged("Locked class BLOOD_MAGE")); + assertTrue(ClassUnlocks.lock(iris, "never-unlocked")); assertFalse(ClassUnlocks.lock(iris, null)); + verify(saves, times(2)).saveCharacterNow(any(), any()); + } + + @Test void withoutAnActiveLivingCharacterNothingIsUnlockedOrSaved() { + assertFalse(ClassUnlocks.unlock(null, "BLOOD_MAGE")); + character.addUnlockedClass("BLOOD_MAGE"); character.setStatus(Status.DEAD); + assertFalse(ClassUnlocks.unlock(iris, "BLOOD_MAGE")); assertFalse(ClassUnlocks.lock(iris, "BLOOD_MAGE")); + assertTrue(ClassUnlocks.unlocked(iris).isEmpty()); assertFalse(ClassUnlocks.isUnlocked(iris, "BLOOD_MAGE")); + when(account.getActiveCharacter()).thenReturn(null); assertFalse(ClassUnlocks.unlock(iris, "BLOOD_MAGE")); + players.when(() -> PlayerManager.get(iris)).thenReturn(null); assertFalse(ClassUnlocks.lock(iris, "BLOOD_MAGE")); + verify(saves, never()).saveCharacterNow(any(), any()); + } + + @Test void aFailedSaveWritesTheOldStateBackAndReportsFailure() { + List> written = new ArrayList<>(); + when(saves.saveCharacterNow(iris.getUniqueId(), character)).thenAnswer(call -> { written.add(Set.copyOf(character.getUnlockedClasses())); return false; }); + assertFalse(ClassUnlocks.unlock(iris, "blood_mage")); assertTrue(character.getUnlockedClasses().isEmpty()); + assertEquals(List.of(Set.of("BLOOD_MAGE"), Set.of()), written); assertTrue(logged("BLOOD_MAGE for Hero (" + character.getId() + ") of Iris stays locked.")); + character.addUnlockedClass("BLOOD_MAGE"); written.clear(); + assertFalse(ClassUnlocks.lock(iris, "blood_mage")); assertEquals(Set.of("BLOOD_MAGE"), character.getUnlockedClasses()); + assertEquals(List.of(Set.of(), Set.of("BLOOD_MAGE")), written); assertTrue(logged("stays unlocked.")); + } + + @Test void assignSwitchesTheCharacterIntoTheClassForFreeAndBindsIt() { + character.setMMOClass("MAGE"); + assertFalse(ClassUnlocks.isLockedIn(character)); assertFalse(ClassUnlocks.isLockedIn(null)); + assertTrue(ClassUnlocks.assign(iris, "blood-mage")); + assertEquals("BLOOD_MAGE", character.getMMOClass()); assertTrue(ClassUnlocks.isLockedIn(character)); + service.verify(() -> ClassService.applyClass(iris, "BLOOD_MAGE")); + verify(saves).savePlayer(iris); assertTrue(logged("Assigned class BLOOD_MAGE to Hero")); + service.when(() -> ClassService.isOnClass(iris, "BLOOD_MAGE")).thenReturn(true); + assertTrue(ClassUnlocks.assign(iris, "BLOOD_MAGE"), "Already in it: nothing to do"); + service.verify(() -> ClassService.applyClass(any(), anyString()), times(1)); + assertTrue(ClassUnlocks.lock(iris, "blood_mage")); assertFalse(ClassUnlocks.isLockedIn(character), "Released: it may change class again"); + assertFalse(ClassUnlocks.assign(iris, "missing")); assertFalse(ClassUnlocks.assign(null, "BLOOD_MAGE")); + } + + @Test void aClassMmoCoreWontApplyLeavesTheOldClassButKeepsTheRecordForARetry() { + character.setMMOClass("MAGE"); + service.when(() -> ClassService.applyClass(any(), anyString())).thenReturn(false); + assertFalse(ClassUnlocks.assign(iris, "blood_mage")); + assertEquals("MAGE", character.getMMOClass()); assertEquals(Set.of("BLOOD_MAGE"), character.getUnlockedClasses()); + assertTrue(logged("Could not switch Hero")); + service.when(() -> ClassService.applyClass(any(), anyString())).thenThrow(new IllegalStateException("boom")); + assertFalse(ClassUnlocks.assign(iris, "blood_mage")); assertEquals("MAGE", character.getMMOClass()); + verify(saves, never()).savePlayer(any()); + } + + @Test void staffCommandsAssignAndReleaseWithClearReplies() { + assertTrue(ClassUnlockCommand.isSubcommand("AssignClass")); assertTrue(ClassUnlockCommand.isSubcommand("releaseclass")); + assertTrue(ClassUnlockCommand.isSubcommand("unlockclass")); assertTrue(ClassUnlockCommand.isSubcommand("lockclass")); assertFalse(ClassUnlockCommand.isSubcommand("prison")); + assertTrue(run("admin", "assignclass", "Iris")); assertEquals("Usage: /rpcharacter admin assignclass ", messages()); + assertTrue(run("admin", "LOCKCLASS", "Iris", "a", "b")); assertEquals("Usage: /rpcharacter admin releaseclass ", messages()); + assertTrue(run("admin", "assignclass", "Nobody", "blood_mage")); assertEquals("No online player with an active living character named Nobody.", messages()); + assertTrue(run("admin", "assignclass", "Iris", "nothing")); assertEquals("No class by the id NOTHING.", messages()); + assertTrue(run("admin", "unlockclass", "Iris", "blood-mage")); assertEquals("Assigned Blood Mage to Hero (Iris). They are bound to it until released.", messages()); + assertEquals(Set.of("BLOOD_MAGE"), character.getUnlockedClasses()); assertEquals("BLOOD_MAGE", character.getMMOClass()); + assertTrue(run("admin", "assignclass", "Iris", "BLOOD_MAGE")); assertEquals("Blood Mage is already assigned to Hero (Iris).", messages()); + assertTrue(run("admin", "releaseclass", "Iris", "blood_mage")); + assertEquals("Released Blood Mage for Hero (Iris). They keep it for now, but can change class in /class and can't pick it back.", messages()); + assertTrue(run("admin", "releaseclass", "Iris", "gone")); assertEquals("GONE is already released for Hero (Iris).", messages()); + when(saves.saveCharacterNow(any(), any())).thenReturn(false); + assertTrue(run("admin", "assignclass", "Iris", "blood_mage")); assertEquals("Could not assign Blood Mage to Hero. See the console.", messages()); + assertTrue(character.getUnlockedClasses().isEmpty()); + character.addUnlockedClass("BLOOD_MAGE"); + assertTrue(run("admin", "releaseclass", "Iris", "blood_mage")); assertEquals("Could not save Hero. See the console.", messages()); + assertNull(iris.nextMessage(), "Assignments are not announced to the player by RPCharacters"); + } +} diff --git a/src/test/java/net/tfminecraft/rpcharacters/database/CharacterClassPickFieldsTest.java b/src/test/java/net/tfminecraft/rpcharacters/database/CharacterClassPickFieldsTest.java new file mode 100644 index 00000000..f5415904 --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/database/CharacterClassPickFieldsTest.java @@ -0,0 +1,89 @@ +package net.tfminecraft.rpcharacters.database; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Set; + +import org.bukkit.entity.Player; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.rpcharacters.objects.RPCharacter; + +class CharacterClassPickFieldsTest { + @SuppressWarnings("unchecked") + @Test + void unlockedClassesRoundTripAndFilesWithoutThemLoadWithNone() throws Exception { + RPCharacter character = new RPCharacter(mock(Player.class)); + HashMap none = new HashMap<>(); + CharacterClassPickFields.save(none, character); + assertTrue(none.isEmpty()); + CharacterClassPickFields.load(character, new JSONObject()); + CharacterClassPickFields.load(character, null); + CharacterClassPickFields.load(null, new JSONObject()); + assertTrue(character.getUnlockedClasses().isEmpty()); + character.addUnlockedClass("blood_mage"); + character.addUnlockedClass("Shade"); + assertFalse(character.addUnlockedClass("SHADE")); + assertThrows(UnsupportedOperationException.class, () -> character.getUnlockedClasses().add("X")); + HashMap saved = new HashMap<>(); + CharacterClassPickFields.save(saved, character); + assertEquals(Set.of("unlocked-classes"), saved.keySet()); + JSONObject json = (JSONObject) new JSONParser().parse(new JSONObject(saved).toJSONString()); + RPCharacter loaded = new RPCharacter(mock(Player.class)); + CharacterClassPickFields.load(loaded, json); + assertEquals(List.of("BLOOD_MAGE", "SHADE"), List.copyOf(loaded.getUnlockedClasses())); + assertTrue(loaded.removeUnlockedClass("shade")); + assertFalse(loaded.removeUnlockedClass("shade")); + RPCharacter odd = new RPCharacter(mock(Player.class)); + JSONObject oddJson = new JSONObject(); + oddJson.put("unlocked-classes", Arrays.asList(" ", 5, null, " x ")); + CharacterClassPickFields.load(odd, oddJson); + assertEquals(Set.of("X"), odd.getUnlockedClasses()); + oddJson.put("unlocked-classes", "BLOOD_MAGE"); + RPCharacter scalar = new RPCharacter(mock(Player.class)); + CharacterClassPickFields.load(scalar, oddJson); + assertTrue(scalar.getUnlockedClasses().isEmpty()); + } + + @SuppressWarnings("unchecked") + @Test + void classSkillPointPoolsRoundTripAndSkipOddEntries() throws Exception { + RPCharacter character = new RPCharacter(mock(Player.class)); + assertTrue(character.getClassSkillPoints("bloodsworn").isEmpty()); + assertTrue(character.getClassSkillPoints(null).isEmpty()); + character.setClassSkillPoints("bloodsworn", 8); + character.setClassSkillPoints("Sanguinar", -3); + assertEquals(8, character.getClassSkillPoints("BLOODSWORN").getAsInt()); + assertEquals(0, character.getClassSkillPoints("sanguinar").getAsInt()); + assertThrows(UnsupportedOperationException.class, () -> character.getClassSkillPoints().put("X", 1)); + HashMap saved = new HashMap<>(); + CharacterClassPickFields.save(saved, character); + assertEquals(Set.of("class-skill-points"), saved.keySet()); + JSONObject json = (JSONObject) new JSONParser().parse(new JSONObject(saved).toJSONString()); + RPCharacter loaded = new RPCharacter(mock(Player.class)); + CharacterClassPickFields.load(loaded, json); + assertEquals(java.util.Map.of("BLOODSWORN", 8, "SANGUINAR", 0), loaded.getClassSkillPoints()); + loaded.removeClassSkillPoints("bloodsworn"); + assertTrue(loaded.getClassSkillPoints("BLOODSWORN").isEmpty()); + JSONObject odd = new JSONObject(); + HashMap pools = new HashMap<>(); + pools.put(" ", 1); + pools.put("shade", "x"); + pools.put(" Seer ", 2L); + pools.put(5, 5); + odd.put("class-skill-points", pools); + RPCharacter oddCharacter = new RPCharacter(mock(Player.class)); + CharacterClassPickFields.load(oddCharacter, odd); + assertEquals(java.util.Map.of("SEER", 2), oddCharacter.getClassSkillPoints()); + odd.put("class-skill-points", "BLOODSWORN"); + RPCharacter scalar = new RPCharacter(mock(Player.class)); + CharacterClassPickFields.load(scalar, odd); + assertTrue(scalar.getClassSkillPoints().isEmpty()); + } +} diff --git a/src/test/java/net/tfminecraft/rpcharacters/ingest/RosterSyncServiceTest.java b/src/test/java/net/tfminecraft/rpcharacters/ingest/RosterSyncServiceTest.java index c27377b0..8472d01c 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/ingest/RosterSyncServiceTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/ingest/RosterSyncServiceTest.java @@ -75,8 +75,8 @@ class RosterSyncServiceTest extends IngestFixture { doReturn(Arrays.asList(null,xp(null,null,1),xp(" ","",1),xp(" MINING ","§a Miner ",12),xp("Fishing",null,-2),xp("Foraging","§a ",4))).when(data).getExperienceModifiers(); Cache.attributes=new ArrayList<>(Arrays.asList(null," ","strength"));Cache.editableTraits=new ArrayList<>(Arrays.asList(null," PHYSICAL ")); Trait visible=trait("strong"," PHYSICAL ");when(visible.getName()).thenReturn("§bStrong");Trait injury=trait("wound","injury");when(injury.hasDuration()).thenReturn(true);when(c.getDurationRemainingMs("wound")).thenReturn(2500L);Trait arm=trait("arm","prosthetic");when(arm.hasFuelTemplate()).thenReturn(true);when(arm.getFuelCapacity()).thenReturn(20D);when(c.getFuel("arm")).thenReturn(25D);when(arm.getName()).thenReturn(null); - Trait noData=mock(Trait.class);when(noData.getId()).thenReturn("no-data");Trait noId=mock(Trait.class);Trait blankKey=trait("blank-key"," ");Trait nullKey=trait("null-key",null);Trait hidden=trait("secret","secret");Trait rank=trait("str2","physical"); - when(c.getTraits()).thenReturn(Arrays.asList(null,noId,noData,blankKey,nullKey,hidden,rank,visible,injury,arm));when(c.getPlayerClues()).thenReturn(Arrays.asList(null," ","§a"," §aBlue coat ")); + Trait noData=mock(Trait.class);when(noData.getId()).thenReturn("no-data");Trait noId=mock(Trait.class);Trait blankKey=trait("blank-key"," ");Trait nullKey=trait("null-key",null);Trait hidden=trait("secret","secret");Trait rank=trait("str2","physical");Trait marked=trait("manaless","injury");when(marked.getTraitData().isHidden()).thenReturn(true); + when(c.getTraits()).thenReturn(Arrays.asList(null,noId,noData,blankKey,nullKey,hidden,marked,rank,visible,injury,arm));when(c.getPlayerClues()).thenReturn(Arrays.asList(null," ","§a"," §aBlue coat ")); var kit=mock(KitDefinition.class);when(kit.getId()).thenReturn("starter");when(kit.getCooldownHours()).thenReturn(8);Map configured=new LinkedHashMap<>();configured.put("starter",kit);configured.put("null",null);kits.when(KitLoader::getKits).thenReturn(configured);kits.when(KitLoader::getCooldownHours).thenReturn(8);kitService.when(() -> KitService.cooldownRemainingMs(pd,"starter")).thenReturn(3500L); slots.when(() -> CharacterSlotService.getMaxAliveCharacters(player)).thenReturn(4);permissions.when(() -> PermissionGroupService.getNameColourStops(player)).thenReturn(2);permissions.when(() -> PermissionGroupService.getWardrobeSkinSlots(player)).thenReturn(7); when(pd.getCompletedStages()).thenReturn(List.of("creation_age_set_stage"));when(pd.isEighteen()).thenReturn(true);when(pd.getCreatedAtEpochSeconds()).thenReturn(1700000001); diff --git a/src/test/java/net/tfminecraft/rpcharacters/managers/CommandManagerTest.java b/src/test/java/net/tfminecraft/rpcharacters/managers/CommandManagerTest.java index 1e55d76d..678144af 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/managers/CommandManagerTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/managers/CommandManagerTest.java @@ -161,6 +161,12 @@ PlayerClass knownClass(ClassManager manager, String id) { assertFalse(run(carry)); carried.verify(() -> net.tfminecraft.rpcharacters.mmocore.CarriedExperience.handle(player, carry, 2)); var prisons = boundary(PrisonSwitch.class); String[] prison = {"admin", "Prison", "Iris"}; assertFalse(run(prison)); prisons.verify(() -> PrisonSwitch.handle(player, prison, 2)); + var unlocks = boundary(net.tfminecraft.rpcharacters.classpick.ClassUnlockCommand.class); + unlocks.when(() -> net.tfminecraft.rpcharacters.classpick.ClassUnlockCommand.isSubcommand(anyString())).thenCallRealMethod(); + for (String verb : List.of("UnlockClass", "lockclass")) { + String[] unlock = {"admin", verb, "Iris", "blood_mage"}; assertFalse(run(unlock)); + unlocks.verify(() -> net.tfminecraft.rpcharacters.classpick.ClassUnlockCommand.handle(player, unlock, 2)); + } String[] directKill = {"permakill", "Iris"}; assertFalse(run(directKill)); death.verify(() -> PermadeathAdminCommands.handlePermakill(player, directKill, 1, "permakill")); String[] directInjure = {"injure", "Iris", "permanent"}; assertFalse(run(directInjure)); death.verify(() -> PermadeathAdminCommands.handleInjure(eq(player), same(directInjure), eq(1), anyString())); run("reload"); verify(plugin).reloadConfigs(player); diff --git a/src/test/java/net/tfminecraft/rpcharacters/managers/InventoryManagerItemsTest.java b/src/test/java/net/tfminecraft/rpcharacters/managers/InventoryManagerItemsTest.java index efa25b53..fecb8c0e 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/managers/InventoryManagerItemsTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/managers/InventoryManagerItemsTest.java @@ -84,6 +84,12 @@ class InventoryManagerItemsTest extends InventoryFixture { assertEquals("char-1", tag(result, "character_id")); when(character.getStatus()).thenReturn(Status.DEAD); assertFalse(lore(manager.getTraitsItem(character)).contains("Risk boundary")); + var secret = trait("manaless", "injury"); when(secret.getTraitData().isHidden()).thenReturn(true); + when(character.getTraits()).thenReturn(List.of(secret, ordinary)); + var owner = org.mockito.Mockito.mock(org.bukkit.entity.Player.class); when(character.getOwner()).thenReturn(owner); + assertTrue(lore(manager.getTraitsItem(character)).contains("manaless"), "Its own player sees it"); + assertFalse(lore(manager.getTraitsItem(character, null)).contains("manaless"), "Nobody else does"); + assertFalse(lore(manager.getTraitsItem(character, org.mockito.Mockito.mock(org.bukkit.entity.Player.class))).contains("manaless")); } @Test void traitDetailsShowResolvedEffectsFuelDurationAndNoEffectFallback() { diff --git a/src/test/java/net/tfminecraft/rpcharacters/managers/InventoryManagerViewsTest.java b/src/test/java/net/tfminecraft/rpcharacters/managers/InventoryManagerViewsTest.java index 87f39304..d468b708 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/managers/InventoryManagerViewsTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/managers/InventoryManagerViewsTest.java @@ -75,6 +75,11 @@ class InventoryManagerViewsTest extends InventoryFixture { assertEquals("visible",name(top().getItem(0))); assertEquals("char-1",tag(top().getItem(26),"character_id")); var many=new ArrayList(); many.add(hidden); for(int i=0;i<60;i++) many.add(trait("trait"+i,"physical")); when(character.getTraits()).thenReturn(many); manager.traitsView(player,character); assertEquals(54,top().getSize()); assertEquals("trait52",name(top().getItem(52))); assertEquals("Back",name(top().getItem(53))); + var secret=trait("manaless","injury"); when(secret.getTraitData().isHidden()).thenReturn(true); + when(character.getTraits()).thenReturn(List.of(secret,visible)); when(character.getOwner()).thenReturn(player); + manager.traitsView(player,character); assertEquals("manaless",name(top().getItem(0)),"Its own player sees it"); + manager.traitsView(other,character); + assertEquals("visible",name(other.getOpenInventory().getTopInventory().getItem(0)),"Nobody else does"); when(data.getCharacters(Status.DEAD)).thenReturn(List.of(character)); manager.deadView(player,player); assertFilled(top()); assertEquals("char-1",tag(top().getItem(0),"character_id")); assertEquals("Back",name(top().getItem(26))); when(data.getCharacters(Status.DEAD)).thenReturn(List.of()); manager.deadView(player,player); assertEquals(Material.GRAY_STAINED_GLASS_PANE,top().getItem(0).getType()); } diff --git a/src/test/java/net/tfminecraft/rpcharacters/mmocore/ClassServiceTest.java b/src/test/java/net/tfminecraft/rpcharacters/mmocore/ClassServiceTest.java index 3bb32e19..7d7d624b 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/mmocore/ClassServiceTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/mmocore/ClassServiceTest.java @@ -44,6 +44,18 @@ class ClassServiceTest extends MmoServiceFixture { skillLevels.put("known",3);skillPoints.set(4);ClassService.migrateSkillPointsIfNeeded(player);verify(manager,never()).savePlayer(any());when(account.needsSkillPointsMigration()).thenReturn(true);ClassService.migrateSkillPointsIfNeeded(player);assertEquals(6,accountSkills.get());verify(manager).savePlayer(player);ClassService.grantSkillPoints(player,3);assertEquals(9,accountSkills.get());assertEquals(7,skillPoints.get());accountSkills.set(1);ClassService.applyFreeSkillPoints(player);assertEquals(0,skillPoints.get()); } + @Test void classesWithTheirOwnPoolIgnoreTheSharedPoolAndKeepStaffChangesInIt(){ + Map own=new HashMap<>();when(character.getClassSkillPoints(anyString())).thenAnswer(c->own.containsKey(c.getArgument(0))?OptionalInt.of(own.get(c.getArgument(0))):OptionalInt.empty());doAnswer(c->{own.put(c.getArgument(0),c.getArgument(1));return null;}).when(character).setClassSkillPoints(anyString(),anyInt()); + skillLevels.put("known",2);accountSkills.set(10);ClassService.applyFreeSkillPoints(player);assertEquals(9,skillPoints.get(),"Without its own pool the class shares the character's"); + own.put("warrior",3);ClassService.applyFreeSkillPoints(player);assertEquals(2,skillPoints.get(),"Its own total minus what it spent; the shared 10 stay out"); + skillPoints.set(6);Cache.skillPointsAdminDebugMessages=true;ClassService.syncSkillPoints(player,player);Cache.skillPointsAdminDebugMessages=false; + assertEquals(7,own.get("warrior"));assertEquals(10,accountSkills.get());verify(manager).saveCharacterNow(owner,character);verify(manager,never()).savePlayer(player);assertEquals(6,skillPoints.get()); + texts.verify(() -> RPTexts.sendPrefixed(eq(player),argThat(message->org.bukkit.ChatColor.stripColor(message).contains("skill points: class total 3 → 7")))); + own.put("warrior",1);skillLevels.put("known",3);ClassService.refreshSkillPoints(player);assertEquals(2,skillLevels.get("known"));assertEquals(0,skillPoints.get());ClassService.refreshSkillPoints(null); + when(account.getActiveCharacter()).thenReturn(null);ClassService.applyFreeSkillPoints(player);assertEquals(9,skillPoints.get()); + when(account.getActiveCharacter()).thenReturn(character);current.set(null);ClassService.applyFreeSkillPoints(player);assertEquals(9,skillPoints.get()); + } + @Test void migrationCannotWrapLargeSkillPoolsIntoNegativeLifetimePoints(){ skillPoints.set(Integer.MAX_VALUE);skillLevels.put("known",2);when(account.needsSkillPointsMigration()).thenReturn(true);ClassService.migrateSkillPointsIfNeeded(player);assertEquals(Integer.MAX_VALUE,accountSkills.get(),"Representable lifetime points must saturate rather than wrap negative and erase the account pool"); } diff --git a/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutRuntimeTest.java b/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutRuntimeTest.java index f444f345..e9c483d9 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutRuntimeTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutRuntimeTest.java @@ -79,6 +79,30 @@ EntityDamageByEntityEvent hit(Entity target, double damage) { victim.disconnect(); server.getScheduler().performTicks(1); manager.shutdown(); } + @Test void movedDownedPlayersAreHeldAtTheirNewSpot() { + manager.start(); Location original = victim.getLocation(); + assertFalse(manager.moveDowned(victim, original.clone().add(3, 0, 0))); + hit(victim, 30); + assertFalse(manager.moveDowned(victim, null)); + Location altar = original.clone().add(4, 1, 0); altar.setYaw(0); + assertTrue(manager.moveDowned(victim, altar)); + assertEquals(altar.getX(), victim.getLocation().getX()); assertEquals(altar.getY(), victim.getLocation().getY()); + victim.teleport(original); server.getScheduler().performTicks(1); + assertEquals(altar.getX(), victim.getLocation().getX()); assertEquals(altar.getZ(), victim.getLocation().getZ()); + assertTrue(manager.moveDowned(victim, altar)); + assertFalse(manager.moveDowned(victim, new Location(null, 0, 0, 0))); + World other = server.addSimpleWorld("other"); + Location elsewhere = new Location(other, altar.getX(), altar.getY(), altar.getZ()); + assertTrue(manager.moveDowned(victim, elsewhere)); + assertSame(other, victim.getLocation().getWorld()); + PlayerMock refusing = spy(victim); + doReturn(false).when(refusing).teleport(any(Location.class)); + assertFalse(manager.moveDowned(refusing, altar)); + server.getScheduler().performTicks(1); + assertSame(other, victim.getLocation().getWorld()); + manager.shutdown(); + } + @Test void ordinaryDamageMobsCreationAndPendingDeathNeverTriggerKnockout() { verify(hit(mock(Zombie.class), 30), never()).setCancelled(true); verify(hit(victim, 1), never()).setCancelled(true); diff --git a/src/test/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleterTest.java b/src/test/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleterTest.java index 7d30cf22..e0477aeb 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleterTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleterTest.java @@ -115,8 +115,8 @@ RPCharacter character(String slug, String name) { for (String verb : List.of("menu", "clues", "setclass", "seteighteen", "resetkit", "reclaimkit", "skipcooldown", "addtrait", "removetrait", "injure", "discordgate")) assertEquals(List.of("Iris"), complete(verb, "IR"), verb); assertEquals(List.of("name", "race"), complete("edit", "")); - assertEquals(List.of("injure", "permakill", "strikes", "tutorial", "carryexp", "prison"), complete("admin", "")); - grant("rpchar.class.reset", true); assertEquals(List.of("injure", "permakill", "strikes", "tutorial", "carryexp", "resetclasses", "prison"), complete("admin", "")); + assertEquals(List.of("injure", "permakill", "strikes", "tutorial", "carryexp", "assignclass", "releaseclass", "prison"), complete("admin", "")); + grant("rpchar.class.reset", true); assertEquals(List.of("injure", "permakill", "strikes", "tutorial", "carryexp", "assignclass", "releaseclass", "resetclasses", "prison"), complete("admin", "")); assertEquals(List.of("confirm"), complete("admin", "RESETCLASSES", "c")); assertEquals(List.of(), complete("admin", "resetclasses", "confirm", "")); assertEquals(List.of("5", "10", "25", "50"), complete("clearclues", "")); assertEquals(List.of(), complete("placeclue", "")); assertEquals(List.of("on", "off"), complete("adminmode", "")); @@ -129,6 +129,7 @@ RPCharacter character(String slug, String name) { 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", "unlockclass", "")); assertEquals(List.of(), complete("admin", "lockclass", "Iris", "")); grant("rpchar.class.reset", true); assertEquals(List.of("resetclasses"), complete("admin", "")); grant(PrisonSwitch.PERMISSION, true); assertEquals(List.of("resetclasses", "prison"), complete("admin", "")); } @@ -141,6 +142,24 @@ RPCharacter character(String slug, String name) { assertEquals(List.of(), complete("admin", "prison", "Iris", "hero", "on", "")); } + @Test void classUnlockCompletionOffersPlayersThenLockedOrUnlockedClasses() throws Exception { + admin(); var core = mock(net.Indyuce.mmocore.MMOCore.class); var classes = mock(net.Indyuce.mmocore.manager.ClassManager.class); + var field = net.Indyuce.mmocore.MMOCore.class.getField("classManager"); field.setAccessible(true); field.set(core, classes); + var warrior = mock(net.Indyuce.mmocore.api.player.profess.PlayerClass.class); when(warrior.getId()).thenReturn("WARRIOR"); + var blood = mock(net.Indyuce.mmocore.api.player.profess.PlayerClass.class); when(blood.getId()).thenReturn("BLOOD_MAGE"); + when(classes.getAll()).thenReturn(List.of(warrior, blood)); + RPCharacter active = new RPCharacter(iris); active.setStatus(Status.ALIVE); active.addUnlockedClass("blood_mage"); + when(data.getActiveCharacter()).thenReturn(active); + assertEquals(List.of("Iris"), complete("admin", "UnlockClass", "I")); + net.Indyuce.mmocore.MMOCore.plugin = null; assertEquals(List.of(), complete("admin", "unlockclass", "Iris", "")); + net.Indyuce.mmocore.MMOCore.plugin = core; assertEquals(List.of("WARRIOR"), complete("admin", "unlockclass", "Iris", "")); + assertEquals(List.of("WARRIOR", "BLOOD_MAGE"), complete("admin", "unlockclass", "offline", "")); + assertEquals(List.of("BLOOD_MAGE"), complete("admin", "lockclass", "Iris", "b")); + assertEquals(List.of(), complete("admin", "lockclass", "offline", "")); + assertEquals(List.of(), complete("admin", "lockclass", "Iris", "BLOOD_MAGE", "")); + field.set(core, null); assertEquals(List.of(), complete("admin", "unlockclass", "Iris", "")); + } + @Test void hiddenCharacterSuggestionsWaitForPlayerDataToLoad() { grant(Cache.personaCharacterHiddenPermission, true); players.when(() -> PlayerManager.get(player)).thenReturn(null);