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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -101,12 +103,7 @@ public static boolean isEnabled() {
/** Displayed base classes in display order. */
public static List<PlayerClass> baseClasses() {
List<PlayerClass> classes = new ArrayList<>();
Set<String> subclassIds = new HashSet<>();
for (PlayerClass playerClass : MMOCore.plugin.classManager.getAll()) {
for (Subclass subclass : playerClass.getSubclasses()) {
subclassIds.add(subclass.getProfess().getId().toUpperCase(Locale.ROOT));
}
}
Set<String> subclassIds = subclassIds();
for (PlayerClass playerClass : MMOCore.plugin.classManager.getAll()) {
if (!subclassIds.contains(playerClass.getId().toUpperCase(Locale.ROOT))
&& MmoCoreClassGuiHelper.isClassDisplayed(playerClass)) {
Expand All @@ -117,6 +114,16 @@ public static List<PlayerClass> baseClasses() {
return classes;
}

private static Set<String> subclassIds() {
Set<String> 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) {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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.";
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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 <player> <classId>}: 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)
+ " <player> <classId>");
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 <player> <classId>}, before prefix filtering. */
public static List<String> tabComplete(String[] args) {
List<String> 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;
}
}
158 changes: 158 additions & 0 deletions src/main/java/net/tfminecraft/rpcharacters/classpick/ClassUnlocks.java
Original file line number Diff line number Diff line change
@@ -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<String> 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;
}
}
Loading
Loading