From 371b2d855006a5a5c8f29e302442496404f0ed46 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:31:02 +0200 Subject: [PATCH 1/3] feat: share profession levels and carry transferred class EXP across characters Professions belong to the account: when a character is activated, each MMOCore profession is raised to the best level any of the account's characters (alive or dead) has reached, and its lifetime profession points to the most any of them holds. Upgrades stay with each character, so the points arrive unspent. Taking the best also settles accounts whose characters levelled apart before this. Class EXP that Codex and ConditionalEvents carry over from other characters goes through /rpcharacter admin carryexp . The class takes what it can hold; EXP past its cap waits in the character's progress (carried-experience) and is added level by level after a class change or at the next activation, so each level-up still claims its exp-table rewards (skill points, skill slots). Co-Authored-By: Claude Opus 5.5 --- .../rpcharacters/body/CharacterBodies.java | 2 + .../rpcharacters/managers/CommandManager.java | 7 +- .../mmocore/CarriedExperience.java | 146 ++++++++++++++++++ .../rpcharacters/mmocore/ClassService.java | 2 + .../mmocore/MmoProgressCodec.java | 33 ++++ .../progress/CharacterProgress.java | 19 +++ .../progress/CharacterProgressService.java | 16 ++ .../progress/SharedProfessions.java | 50 ++++++ .../utils/CommandTabCompleter.java | 4 +- .../managers/CommandManagerTest.java | 2 + .../progress/CarriedExperienceTest.java | 139 +++++++++++++++++ .../progress/SharedProfessionsTest.java | 66 ++++++++ .../utils/CommandTabCompleterTest.java | 6 +- 13 files changed, 486 insertions(+), 6 deletions(-) create mode 100644 src/main/java/net/tfminecraft/rpcharacters/mmocore/CarriedExperience.java create mode 100644 src/main/java/net/tfminecraft/rpcharacters/progress/SharedProfessions.java create mode 100644 src/test/java/net/tfminecraft/rpcharacters/progress/CarriedExperienceTest.java create mode 100644 src/test/java/net/tfminecraft/rpcharacters/progress/SharedProfessionsTest.java diff --git a/src/main/java/net/tfminecraft/rpcharacters/body/CharacterBodies.java b/src/main/java/net/tfminecraft/rpcharacters/body/CharacterBodies.java index 0c31c5c2..7939b38f 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/body/CharacterBodies.java +++ b/src/main/java/net/tfminecraft/rpcharacters/body/CharacterBodies.java @@ -572,6 +572,8 @@ private void activate(PlayerData account, RPCharacter character, BodyRecord body try { progress.restoreResources(account, character, resources); body.body().applyHealth(player); + // The body is on, so level-up rewards land in its inventory. + net.tfminecraft.rpcharacters.mmocore.CarriedExperience.release(player); progress.save(account, character); } catch (IOException | RuntimeException error) { fail(player, "restoring character resources", error); diff --git a/src/main/java/net/tfminecraft/rpcharacters/managers/CommandManager.java b/src/main/java/net/tfminecraft/rpcharacters/managers/CommandManager.java index 3969150a..9d5f7166 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/managers/CommandManager.java +++ b/src/main/java/net/tfminecraft/rpcharacters/managers/CommandManager.java @@ -724,7 +724,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")) { @@ -740,7 +740,10 @@ private static boolean handleAdmin(CommandSender sender, String[] args) { if (args[1].equalsIgnoreCase("tutorial")) { return TutorialCommands.handleAdmin(sender, args, 2); } - RPTexts.send(sender, RPTexts.ERROR + "Usage: /rpcharacter admin ..."); + 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 ..."); return true; } diff --git a/src/main/java/net/tfminecraft/rpcharacters/mmocore/CarriedExperience.java b/src/main/java/net/tfminecraft/rpcharacters/mmocore/CarriedExperience.java new file mode 100644 index 00000000..579b541b --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/mmocore/CarriedExperience.java @@ -0,0 +1,146 @@ +package net.tfminecraft.rpcharacters.mmocore; + +import java.util.Locale; +import java.util.function.BooleanSupplier; + +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.PlayerData; +import net.Indyuce.mmocore.experience.EXPSource; +import net.tfminecraft.rpcharacters.RPCharacters; +import net.tfminecraft.rpcharacters.progress.CharacterProgress; +import net.tfminecraft.rpcharacters.progress.CharacterProgressService; +import net.tfminecraft.rpcharacters.progress.ProgressRouting; +import net.tfminecraft.rpcharacters.utils.RPTexts; + +/** + * Class EXP a character receives from its account's other characters, such as the EXP of shared Codex + * discoveries. MMOCore throws away EXP given at a class's level cap, so the class takes only what it can + * hold; the rest waits on the character and is added once it can level further, after a subclass pick + * or a raised cap. Amounts are raw, before MMOCore's EXP stat and boosters, like a discovery's own reward. + */ +public final class CarriedExperience { + public static final String SUBCOMMAND = "carryexp"; + private static final double EPSILON = 1e-6; + private static final int MAX_STEPS = 1000; + + private CarriedExperience() {} + + /** + * Gives EXP to the player's live character, carrying what its class cannot hold yet. While progress + * is shared by the account, it is plain MMOCore EXP. @return false when there is no character to give it to + */ + public static boolean carry(Player player, double amount) { + if (player == null || !(amount > 0) || !Double.isFinite(amount)) { + return false; + } + if (!ProgressRouting.enabled()) { + PlayerData.get(player).giveExperience(amount, EXPSource.COMMAND); + return true; + } + CharacterProgress progress = ProgressRouting.activeProgress(player); + if (progress == null || ClassService.isApplying(player.getUniqueId())) { + return false; + } + boolean waiting = progress.getCarriedExperience() > EPSILON; + progress.setCarriedExperience(progress.getCarriedExperience() + amount); + release(player, progress); + // Said once when EXP starts waiting, not for every reward of a batch. + if (!waiting && progress.getCarriedExperience() > EPSILON) { + RPTexts.send(player, RPTexts.MUTED + "Your class can't level further yet, so " + RPTexts.WARN + + format(progress.getCarriedExperience()) + " EXP" + RPTexts.MUTED + + " from your other characters is waiting. It is added once your class can level, such as after you pick a subclass with " + + RPTexts.COMMAND + "/class" + RPTexts.MUTED + "."); + } + return true; + } + + /** Adds the live character's carried EXP that its class can hold now. */ + public static void release(Player player) { + CharacterProgress progress = player == null ? null : ProgressRouting.activeProgress(player); + if (progress == null || progress.getCarriedExperience() <= EPSILON || ClassService.isApplying(player.getUniqueId())) { + return; + } + double before = progress.getCarriedExperience(); + release(player, progress); + double added = before - progress.getCarriedExperience(); + if (added > EPSILON) { + RPTexts.send(player, RPTexts.SUCCESS + format(added) + " carried EXP" + RPTexts.MUTED + + " from your other characters was added to your class."); + } + } + + /** Releases carried EXP next tick, only while the character that changed class is still live. */ + public static void scheduleRelease(Player player) { + if (player == null || RPCharacters.plugin == null || ProgressRouting.activeProgress(player) == null) { + return; + } + BooleanSupplier current = ProgressRouting.sessionGuard(player); + Bukkit.getScheduler().runTask(RPCharacters.plugin, () -> { + if (current.getAsBoolean()) { + release(player); + } + }); + } + + /** + * Gives the carried EXP level by level until it runs out or the class reaches its cap, so MMOCore + * never drops any of it and each level-up claims its rewards. The character is saved by a coalesced + * checkpoint, since a batch of shared rewards carries EXP many times in one tick. + */ + private static void release(Player player, CharacterProgress progress) { + PlayerData mmo = PlayerData.get(player); + double multiplier = (1 + mmo.getStats().getStat("ADDITIONAL_EXPERIENCE") / 100) + * MMOCore.plugin.boosterManager.getMultiplier(null); + double left = progress.getCarriedExperience(); + for (int step = 0; step < MAX_STEPS && left > EPSILON && multiplier > 0 && !mmo.hasReachedMaxLevel(); step++) { + double needed = Math.max(0, mmo.getLevelUpExperience() - mmo.getExperience()) / multiplier; + double given = Math.min(left, needed); + if (given <= 0) { + break; + } + mmo.giveExperience(given, EXPSource.COMMAND); + left -= given; + } + if (left != progress.getCarriedExperience()) { + progress.setCarriedExperience(left <= EPSILON ? 0 : left); + } + CharacterProgressService.scheduleCheckpoint(player); + } + + /** {@code /rpcharacter admin carryexp }, for Codex and ConditionalEvents shared rewards. */ + public static boolean handle(CommandSender sender, String[] args, int index) { + if (args.length != index + 2) { + RPTexts.send(sender, RPTexts.ERROR + "Usage: /rpcharacter admin " + SUBCOMMAND + " "); + return true; + } + Player player = Bukkit.getPlayerExact(args[index]); + if (player == null) { + RPTexts.send(sender, RPTexts.ERROR + args[index] + " is not online."); + return true; + } + double amount; + try { + amount = Double.parseDouble(args[index + 1]); + } catch (NumberFormatException invalid) { + amount = Double.NaN; + } + if (!(amount > 0) || !Double.isFinite(amount)) { + RPTexts.send(sender, RPTexts.ERROR + "The amount must be a positive number."); + return true; + } + if (!carry(player, amount)) { + RPTexts.send(sender, RPTexts.ERROR + player.getName() + " has no character in play, or its class is being applied; nothing was carried."); + return true; + } + RPTexts.send(sender, RPTexts.MUTED + "Carried " + format(amount) + " EXP to " + player.getName() + "."); + return true; + } + + private static String format(double amount) { + return amount == Math.rint(amount) ? String.valueOf((long) amount) : String.format(Locale.ROOT, "%.1f", amount); + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/mmocore/ClassService.java b/src/main/java/net/tfminecraft/rpcharacters/mmocore/ClassService.java index 8bcbb221..bc149fd8 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/mmocore/ClassService.java +++ b/src/main/java/net/tfminecraft/rpcharacters/mmocore/ClassService.java @@ -367,6 +367,8 @@ private static void applyClassNow(Player player, String classId) { APPLYING.remove(uuid); } CharacterProgressService.checkpoint(player); + // A class with a higher cap can take EXP carried from the account's other characters. + CarriedExperience.scheduleRelease(player); } /** diff --git a/src/main/java/net/tfminecraft/rpcharacters/mmocore/MmoProgressCodec.java b/src/main/java/net/tfminecraft/rpcharacters/mmocore/MmoProgressCodec.java index 88d0f5fa..5df129f2 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/mmocore/MmoProgressCodec.java +++ b/src/main/java/net/tfminecraft/rpcharacters/mmocore/MmoProgressCodec.java @@ -83,6 +83,39 @@ public static void validate(ConfigurationSection section) { decode(section); } + /** A stored profession's level and the experience gained towards the next one. */ + public record ProfessionLevel(int level, double experience) { + /** A higher level, or the same level with more experience; anything is above a missing profession. */ + public boolean isAbove(ProfessionLevel other) { + return other == null || level > other.level || level == other.level && experience > other.experience; + } + } + + /** The stored profession levels by MMOCore profession id. Professions never saved are absent. */ + public static Map professions(ConfigurationSection section) { + Map out = new LinkedHashMap<>(); + decode(section).professions.forEach((id, stored) -> out.put(id, new ProfessionLevel((int) stored[0], stored[1]))); + return out; + } + + /** A copy of the stored progress with these professions instead of its own; the section itself is left as it was. */ + public static YamlConfiguration withProfessions(ConfigurationSection section, Map professions) { + decode(section); + YamlConfiguration copy = new YamlConfiguration(); + section.getValues(false).forEach(copy::set); + List> entries = new ArrayList<>(); + for (Map.Entry profession : new TreeMap<>(professions).entrySet()) { + Map entry = new LinkedHashMap<>(); + entry.put("id", profession.getKey()); + entry.put("level", profession.getValue().level()); + entry.put("experience", profession.getValue().experience()); + entries.add(entry); + } + copy.set("professions", entries); + decode(copy); + return copy; + } + /** Check stored class definitions without applying progress or claiming rewards. */ public static void validateClasses(ConfigurationSection section) { validateClasses(decode(section)); diff --git a/src/main/java/net/tfminecraft/rpcharacters/progress/CharacterProgress.java b/src/main/java/net/tfminecraft/rpcharacters/progress/CharacterProgress.java index 8badc70d..cfc03859 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/progress/CharacterProgress.java +++ b/src/main/java/net/tfminecraft/rpcharacters/progress/CharacterProgress.java @@ -32,6 +32,7 @@ public final class CharacterProgress implements ProgressPools { private int attributePointsTotal; private int classLevel = 1; private double classExperience; + private double carriedExperience; private final Map professionPoints = new TreeMap<>(); private YamlConfiguration mmo; @@ -165,6 +166,18 @@ public void setClassProgress(int level, double experience) { classExperience = Math.max(0.0, experience); } + /** Class EXP carried over from the account's other characters that the class could not hold yet. */ + public double getCarriedExperience() { + return carriedExperience; + } + + public void setCarriedExperience(double experience) { + if (!Double.isFinite(experience)) { + throw new IllegalArgumentException("Carried experience must be finite"); + } + carriedExperience = Math.max(0.0, experience); + } + /** An unsaved copy of these pools and MMOCore data for another character. */ CharacterProgress copyFor(String characterId, String source) { CharacterProgress copy = new CharacterProgress(ownerId, characterId, source, mmo); @@ -173,6 +186,7 @@ CharacterProgress copyFor(String characterId, String source) { copy.attributePointsTotal = attributePointsTotal; copy.classLevel = classLevel; copy.classExperience = classExperience; + copy.carriedExperience = carriedExperience; copy.professionPoints.putAll(professionPoints); return copy; } @@ -184,6 +198,7 @@ void writeTo(ConfigurationSection out) { pools.set("attribute-points", attributePointsTotal); pools.set("class-level", classLevel); pools.set("class-experience", classExperience); + pools.set("carried-experience", carriedExperience); List> professions = new ArrayList<>(); professionPoints.forEach((id, value) -> { Map entry = new LinkedHashMap<>(); @@ -215,6 +230,10 @@ static CharacterProgress readFrom(ConfigurationSection in, UUID ownerId, String progress.setRewardSkillPoints(integer(pools, "reward-skill-points", 0)); progress.setAttributePointsTotal(integer(pools, "attribute-points", 0)); progress.setClassProgress(integer(pools, "class-level", 1), number(pools, "class-experience").doubleValue()); + // Files written before carried EXP existed have none. + if (pools.contains("carried-experience")) { + progress.setCarriedExperience(number(pools, "carried-experience").doubleValue()); + } if (!(pools.get("profession-points") instanceof List professions)) { throw new IOException("Progress is missing profession-points"); } diff --git a/src/main/java/net/tfminecraft/rpcharacters/progress/CharacterProgressService.java b/src/main/java/net/tfminecraft/rpcharacters/progress/CharacterProgressService.java index 2e2110b2..18825376 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/progress/CharacterProgressService.java +++ b/src/main/java/net/tfminecraft/rpcharacters/progress/CharacterProgressService.java @@ -6,6 +6,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.UUID; import java.util.WeakHashMap; @@ -179,6 +180,7 @@ public void activate(PlayerData account, RPCharacter character) throws IOExcepti CharacterProgress progress = requireProgress(account, character); applied.remove(account); try { + shareProfessions(account, progress); ClassService.runApplying(player, () -> MmoProgressCodec.apply(net.Indyuce.mmocore.api.player.PlayerData.get(player), progress.mmo())); } catch (RuntimeException failure) { @@ -187,6 +189,20 @@ public void activate(PlayerData account, RPCharacter character) throws IOExcepti applied.put(account, new Applied(character)); } + /** + * Brings the character's professions up to its account's best before MMOCore loads them. Every + * character on the roster counts, dead ones too, so a death or a switch never loses profession levels. + * The raise is saved with the character's next checkpoint, which follows activation. + */ + private static void shareProfessions(PlayerData account, CharacterProgress progress) { + List others = account.getCharacters().stream().map(RPCharacter::getProgress) + .filter(Objects::nonNull).toList(); + if (SharedProfessions.raise(progress, others)) { + RPCharacters.plugin.getLogger().info("Raised the professions of character " + progress.getCharacterId() + + " of " + account.getUniqueId() + " to its account's best"); + } + } + /** Read before activation: class application may checkpoint temporarily clamped resources. */ public MmoProgressCodec.Resources resources(PlayerData account, RPCharacter character) throws IOException { requireMainThread(); diff --git a/src/main/java/net/tfminecraft/rpcharacters/progress/SharedProfessions.java b/src/main/java/net/tfminecraft/rpcharacters/progress/SharedProfessions.java new file mode 100644 index 00000000..e632dd3c --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/progress/SharedProfessions.java @@ -0,0 +1,50 @@ +package net.tfminecraft.rpcharacters.progress; + +import java.util.Collection; +import java.util.Map; +import java.util.TreeMap; + +import net.tfminecraft.rpcharacters.mmocore.MmoProgressCodec; +import net.tfminecraft.rpcharacters.mmocore.MmoProgressCodec.ProfessionLevel; + +/** + * Professions belong to the account. A character plays each profession at the best level any of its + * account's characters, alive or dead, has reached, and holds at least as many lifetime points for it as + * any of them. Upgrades stay with the character that bought them, so the points arrive unspent. Levels + * never go down, so taking the best also settles accounts whose characters levelled apart before this. + */ +final class SharedProfessions { + private SharedProfessions() {} + + /** + * Raises the target's professions and profession points to the best of the account's characters. + * The target's MMOCore copy is replaced, never edited, since other copies may share it. + * @return whether anything was raised + */ + static boolean raise(CharacterProgress target, Collection account) { + Map levels = new TreeMap<>(MmoProgressCodec.professions(target.mmo())); + boolean levelled = false; + boolean pointed = false; + for (CharacterProgress other : account) { + if (other == null || other == target) { + continue; + } + for (Map.Entry profession : MmoProgressCodec.professions(other.mmo()).entrySet()) { + if (profession.getValue().isAbove(levels.get(profession.getKey()))) { + levels.put(profession.getKey(), profession.getValue()); + levelled = true; + } + } + for (Map.Entry points : other.getProfessionPointsMap().entrySet()) { + if (points.getValue() > target.getProfessionPoints(points.getKey())) { + target.setProfessionPoints(points.getKey(), points.getValue()); + pointed = true; + } + } + } + if (levelled) { + target.setMmo(MmoProgressCodec.withProfessions(target.mmo(), levels)); + } + return levelled || pointed; + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleter.java b/src/main/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleter.java index 5fc1ad20..4b3f5223 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleter.java +++ b/src/main/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleter.java @@ -168,6 +168,7 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe completions.add("permakill"); completions.add("strikes"); completions.add("tutorial"); + completions.add(net.tfminecraft.rpcharacters.mmocore.CarriedExperience.SUBCOMMAND); } if (ClassPickReset.canUse(sender)) { completions.add(ClassPickReset.SUBCOMMAND); @@ -262,7 +263,8 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe } } } else if (args[0].equalsIgnoreCase("admin") && Permissions.isAdmin(sender) - && (args[1].equalsIgnoreCase("injure") || args[1].equalsIgnoreCase("permakill"))) { + && (args[1].equalsIgnoreCase("injure") || args[1].equalsIgnoreCase("permakill") + || args[1].equalsIgnoreCase(net.tfminecraft.rpcharacters.mmocore.CarriedExperience.SUBCOMMAND))) { for (Player online : Bukkit.getOnlinePlayers()) { completions.add(online.getName()); } diff --git a/src/test/java/net/tfminecraft/rpcharacters/managers/CommandManagerTest.java b/src/test/java/net/tfminecraft/rpcharacters/managers/CommandManagerTest.java index 66f0d9aa..b3f152a7 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/managers/CommandManagerTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/managers/CommandManagerTest.java @@ -156,6 +156,8 @@ PlayerClass knownClass(ClassManager manager, String id) { String[] tutorial = {"admin", "tutorial", "reset"}; assertFalse(run(tutorial)); tutorials.verify(() -> TutorialCommands.handleAdmin(player, tutorial, 2)); var resets = boundary(ClassPickReset.class); String[] reset = {"admin", "ResetClasses", "confirm"}; assertFalse(run(reset)); resets.verify(() -> ClassPickReset.handle(player, reset, 2)); + var carried = boundary(net.tfminecraft.rpcharacters.mmocore.CarriedExperience.class); String[] carry = {"admin", "CarryExp", "Iris", "40"}; + assertFalse(run(carry)); carried.verify(() -> net.tfminecraft.rpcharacters.mmocore.CarriedExperience.handle(player, carry, 2)); 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/progress/CarriedExperienceTest.java b/src/test/java/net/tfminecraft/rpcharacters/progress/CarriedExperienceTest.java new file mode 100644 index 00000000..b3ac7c0a --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/progress/CarriedExperienceTest.java @@ -0,0 +1,139 @@ +package net.tfminecraft.rpcharacters.progress; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import java.util.*; +import java.util.concurrent.atomic.*; +import net.Indyuce.mmocore.experience.EXPSource; +import net.Indyuce.mmocore.manager.social.BoosterManager; +import net.tfminecraft.rpcharacters.mmocore.CarriedExperience; +import net.tfminecraft.rpcharacters.mmocore.ClassService; +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class CarriedExperienceTest extends ProgressFixture { + final AtomicInteger level = new AtomicInteger(4), cap = new AtomicInteger(6); + final AtomicReference experience = new AtomicReference<>(0d); + BoosterManager boosters; + + @BeforeEach void levels() throws Exception { + boosters = mock(BoosterManager.class); when(boosters.getMultiplier(null)).thenReturn(1d); field("boosterManager", boosters); + } + + /** A class needing 100 EXP a level; MMOCore stops levelling at the cap. Call after activation. */ + void curve() { + when(mmo.getLevel()).thenAnswer(c -> level.get()); when(mmo.getExperience()).thenAnswer(c -> experience.get()); + when(mmo.getLevelUpExperience()).thenReturn(100L); when(mmo.hasReachedMaxLevel()).thenAnswer(c -> level.get() >= cap.get()); + doAnswer(c -> { + double total = experience.get() + (double) c.getArgument(0) * multiplier(); + while (total >= 100 && level.get() < cap.get()) { total -= 100; level.incrementAndGet(); } + experience.set(total); return null; + }).when(mmo).giveExperience(anyDouble(), any(EXPSource.class)); + } + + double multiplier() { return (1 + mmo.getStats().getStat("ADDITIONAL_EXPERIENCE") / 100) * boosters.getMultiplier(null); } + + RPCharacter live() throws Exception { + var a = character(); enable(); service.prepare(account); play(a); curve(); clearInvocations(player); return a; + } + + void runLater() { var due = new ArrayList<>(later); later.clear(); due.forEach(Runnable::run); } + + List said() { + var lines = ArgumentCaptor.forClass(String.class); verify(player, atLeast(0)).sendMessage(lines.capture()); return lines.getAllValues(); + } + + @Test void theClassTakesWhatItCanHoldAndTheRestWaitsForAHigherCap() throws Exception { + var a = live(); + assertTrue(CarriedExperience.carry(player, 350)); + assertEquals(6, level.get()); assertEquals(150d, a.getProgress().getCarriedExperience(), 1e-9); + assertEquals(1, said().size()); assertTrue(said().getFirst().contains("150 EXP")); + assertTrue(CarriedExperience.carry(player, 20.5)); assertEquals(170.5d, a.getProgress().getCarriedExperience(), 1e-9); + assertEquals(1, said().size(), "Waiting is said once, not for every reward of a batch"); + assertEquals(1, later.size(), "One coalesced checkpoint for the whole batch"); runLater(); + assertEquals(170.5d, store.read(account.getUniqueId(), a.getId()).orElseThrow().getCarriedExperience(), 1e-9, + "Carried EXP is saved with the character"); + + clearInvocations(player); CarriedExperience.release(player); assertTrue(said().isEmpty(), "Nothing is added at the cap"); + cap.set(11); CarriedExperience.release(player); + assertEquals(7, level.get()); assertEquals(70.5d, experience.get(), 1e-9); assertEquals(0d, a.getProgress().getCarriedExperience()); + assertTrue(said().getFirst().contains("170.5 carried EXP")); + clearInvocations(player, mmo); CarriedExperience.release(player); verify(mmo, never()).giveExperience(anyDouble(), any(EXPSource.class)); + } + + @Test void boostedEXPIsGivenRawSoEachStepEndsOnALevel() throws Exception { + var a = live(); when(mmo.getStats().getStat("ADDITIONAL_EXPERIENCE")).thenReturn(100d); experience.set(50d); + assertTrue(CarriedExperience.carry(player, 100)); + assertEquals(6, level.get()); assertEquals(25d, a.getProgress().getCarriedExperience(), 1e-9); + verify(mmo).giveExperience(25d, EXPSource.COMMAND); verify(mmo).giveExperience(50d, EXPSource.COMMAND); + when(boosters.getMultiplier(null)).thenReturn(0d); cap.set(11); CarriedExperience.release(player); + assertEquals(25d, a.getProgress().getCarriedExperience(), 1e-9, "No EXP can be gained, so it keeps waiting"); + } + + @Test void aStuckLevelKeepsTheEXPWaitingInsteadOfLooping() throws Exception { + var a = live(); experience.set(120d); + assertTrue(CarriedExperience.carry(player, 30)); + verify(mmo, never()).giveExperience(anyDouble(), any(EXPSource.class)); assertEquals(30d, a.getProgress().getCarriedExperience()); + } + + @Test void nothingIsCarriedWithoutALiveCharacterOrWhileItsClassIsApplied() throws Exception { + assertFalse(CarriedExperience.carry(null, 5)); assertFalse(CarriedExperience.carry(player, 0)); + assertFalse(CarriedExperience.carry(player, Double.NaN)); assertFalse(CarriedExperience.carry(player, Double.POSITIVE_INFINITY)); + assertTrue(CarriedExperience.carry(player, 40), "Shared progress takes plain MMOCore EXP"); + verify(mmo).giveExperience(40d, EXPSource.COMMAND); + var a = character(); enable(); service.prepare(account); + assertFalse(CarriedExperience.carry(player, 40), "The character room has nobody to carry to"); + CarriedExperience.release(null); CarriedExperience.release(player); CarriedExperience.scheduleRelease(null); CarriedExperience.scheduleRelease(player); + assertTrue(later.isEmpty()); + play(a); curve(); cap.set(4); + ClassService.runApplying(player, () -> assertFalse(CarriedExperience.carry(player, 40))); + assertTrue(CarriedExperience.carry(player, 40)); cap.set(11); + ClassService.runApplying(player, () -> CarriedExperience.release(player)); + assertEquals(40d, a.getProgress().getCarriedExperience()); + } + + @Test void aClassChangeReleasesNextTickOnlyForTheSameActivation() throws Exception { + var a = live(); cap.set(4); CarriedExperience.carry(player, 60); cap.set(11); runLater(); + var plugin = net.tfminecraft.rpcharacters.RPCharacters.plugin; net.tfminecraft.rpcharacters.RPCharacters.plugin = null; + CarriedExperience.scheduleRelease(player); assertTrue(later.isEmpty()); net.tfminecraft.rpcharacters.RPCharacters.plugin = plugin; + CarriedExperience.scheduleRelease(player); play(a); curve(); later.removeFirst().run(); + assertEquals(60d, a.getProgress().getCarriedExperience(), "A stale release never reaches the next activation"); + CarriedExperience.scheduleRelease(player); later.removeFirst().run(); + assertEquals(0d, a.getProgress().getCarriedExperience()); assertEquals(60d, experience.get(), 1e-9); + } + + @Test void adminCommandValidatesItsArguments() throws Exception { + var sender = mock(CommandSender.class); var lines = ArgumentCaptor.forClass(String.class); + bukkit.when(() -> Bukkit.getPlayerExact("Owner")).thenReturn(player); + assertTrue(CarriedExperience.handle(sender, new String[] {"admin", "carryexp", "Owner"}, 2)); + assertTrue(CarriedExperience.handle(sender, new String[] {"admin", "carryexp", "Nobody", "5"}, 2)); + assertTrue(CarriedExperience.handle(sender, new String[] {"admin", "carryexp", "Owner", "lots"}, 2)); + assertTrue(CarriedExperience.handle(sender, new String[] {"admin", "carryexp", "Owner", "-5"}, 2)); + var a = character(); enable(); service.prepare(account); + assertTrue(CarriedExperience.handle(sender, new String[] {"admin", "carryexp", "Owner", "5"}, 2)); + play(a); curve(); + assertTrue(CarriedExperience.handle(sender, new String[] {"admin", "carryexp", "Owner", "12.5"}, 2)); + assertEquals(12.5d, experience.get(), 1e-9); + verify(sender, times(6)).sendMessage(lines.capture()); var said = lines.getAllValues(); + assertTrue(said.get(0).contains("Usage")); assertTrue(said.get(1).contains("Nobody is not online")); + assertTrue(said.get(2).contains("positive")); assertTrue(said.get(3).contains("positive")); + assertTrue(said.get(4).contains("no character in play, or its class is being applied")); assertTrue(said.get(5).contains("Carried 12.5 EXP to ProgressOwner")); + } + + @Test void carriedEXPSurvivesTheFileAndOlderFilesHaveNone() throws Exception { + var progress = progress(UUID.randomUUID().toString()); progress.setCarriedExperience(-3); assertEquals(0d, progress.getCarriedExperience()); + assertThrows(IllegalArgumentException.class, () -> progress.setCarriedExperience(Double.NaN)); + progress.setCarriedExperience(42.25); store.create(progress); + assertEquals(42.25d, store.read(account.getUniqueId(), progress.getCharacterId()).orElseThrow().getCarriedExperience()); + var yaml = new org.bukkit.configuration.file.YamlConfiguration(); progress.writeTo(yaml); yaml.set("pools.carried-experience", null); + assertEquals(0d, CharacterProgress.readFrom(yaml, account.getUniqueId(), progress.getCharacterId(), CharacterProgress.FRESH).getCarriedExperience()); + yaml.set("pools.carried-experience", -1); + assertThrows(java.io.IOException.class, () -> CharacterProgress.readFrom(yaml, account.getUniqueId(), progress.getCharacterId(), CharacterProgress.FRESH)); + assertEquals(42.25d, progress.copyFor(UUID.randomUUID().toString(), CharacterProgress.BASELINE).getCarriedExperience()); + } +} diff --git a/src/test/java/net/tfminecraft/rpcharacters/progress/SharedProfessionsTest.java b/src/test/java/net/tfminecraft/rpcharacters/progress/SharedProfessionsTest.java new file mode 100644 index 00000000..3f7984e1 --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/progress/SharedProfessionsTest.java @@ -0,0 +1,66 @@ +package net.tfminecraft.rpcharacters.progress; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.*; +import net.Indyuce.mmocore.api.event.PlayerLevelChangeEvent; +import net.tfminecraft.rpcharacters.mmocore.MmoProgressCodec; +import net.tfminecraft.rpcharacters.mmocore.MmoProgressCodec.ProfessionLevel; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; + +class SharedProfessionsTest extends ProgressFixture { + @Test void everyProfessionTakesTheAccountsBestLevelAndPointsWithoutEditingSharedCopies() { + var target = with(progress(UUID.randomUUID().toString()), Map.of("fishing", new ProfessionLevel(3, 9.0))); + target.setProfessionPoints("fishing", 7); + var miner = with(progress(UUID.randomUUID().toString()), + Map.of("mining", new ProfessionLevel(10, 4.5), "fishing", new ProfessionLevel(2, 0.0))); + miner.setProfessionPoints("mining", 9); + var fisher = with(progress(UUID.randomUUID().toString()), + Map.of("mining", new ProfessionLevel(10, 3.0), "fishing", new ProfessionLevel(6, 1.5))); + fisher.setProfessionPoints("mining", 2); fisher.setProfessionPoints("fishing", 5); + var smith = with(progress(UUID.randomUUID().toString()), Map.of("mining", new ProfessionLevel(10, 6.25))); + YamlConfiguration own = target.mmo(); String before = own.saveToString(); String minerBefore = miner.mmo().saveToString(); + + assertTrue(SharedProfessions.raise(target, Arrays.asList(target, null, miner, fisher, smith))); + + var levels = MmoProgressCodec.professions(target.mmo()); + assertEquals(new ProfessionLevel(10, 6.25), levels.get("mining"), "Same level, more experience wins"); + assertEquals(new ProfessionLevel(6, 1.5), levels.get("fishing"), "A higher level wins over more experience"); + assertEquals(9, target.getProfessionPoints("mining")); assertEquals(7, target.getProfessionPoints("fishing"), + "Points never go down"); + assertNotSame(own, target.mmo()); assertEquals(before, own.saveToString()); assertEquals(minerBefore, miner.mmo().saveToString()); + assertFalse(SharedProfessions.raise(target, List.of(miner, fisher, smith)), "Nothing is left to raise"); + } + + @Test void pointsAloneRaiseWithoutReplacingTheMmoCopy() { + var target = progress(UUID.randomUUID().toString()); var own = target.mmo(); + var other = progress(UUID.randomUUID().toString()); other.setProfessionPoints("mining", 3); + assertTrue(SharedProfessions.raise(target, List.of(other))); + assertSame(own, target.mmo()); assertEquals(3, target.getProfessionPoints("mining")); + } + + @Test void activationGivesEachCharacterTheAccountsProfessionsOnSwitchDeathAndCreation() throws Exception { + var a = character(); var b = character(); enable(); service.prepare(account); + play(a); when(skills.getLevel(profession)).thenReturn(9); when(skills.getExperience(profession)).thenReturn(3d); + a.getProgress().addProfessionPoints("mining", 4); service.save(account, a); + clearInvocations(skills); play(b); + verify(skills).setLevel(profession, 9, PlayerLevelChangeEvent.Reason.CHOOSE_PROFILE); + verify(skills).setExperience(profession, 3d); + assertEquals(12, b.getProgress().getProfessionPoints("mining"), "The points arrive unspent on the next character"); + assertEquals(8, account.getAccountProfessionPoints("mining"), "Legacy account pools are never written"); + service.save(account, b); + assertEquals(new ProfessionLevel(9, 3d), MmoProgressCodec.professions(store.read(account.getUniqueId(), b.getId()) + .orElseThrow().mmo()).get("mining"), "The raise is saved with the next checkpoint"); + + when(a.getStatus()).thenReturn(net.tfminecraft.rpcharacters.enums.Status.DEAD); + var c = character(); service.ensureFresh(account, c); clearInvocations(skills); play(c); + verify(skills).setLevel(profession, 9, PlayerLevelChangeEvent.Reason.CHOOSE_PROFILE); + assertEquals(12, c.getProgress().getProfessionPoints("mining")); + } + + private static CharacterProgress with(CharacterProgress progress, Map professions) { + progress.setMmo(MmoProgressCodec.withProfessions(progress.mmo(), professions)); + return progress; + } +} diff --git a/src/test/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleterTest.java b/src/test/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleterTest.java index c51dc093..5684cbd3 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleterTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/utils/CommandTabCompleterTest.java @@ -114,8 +114,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"), complete("admin", "")); - grant("rpchar.class.reset", true); assertEquals(List.of("injure", "permakill", "strikes", "tutorial", "resetclasses"), complete("admin", "")); + assertEquals(List.of("injure", "permakill", "strikes", "tutorial", "carryexp"), complete("admin", "")); + grant("rpchar.class.reset", true); assertEquals(List.of("injure", "permakill", "strikes", "tutorial", "carryexp", "resetclasses"), 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", "")); @@ -154,7 +154,7 @@ RPCharacter character(String slug, String name) { when(data.hasActiveCharacter()).thenReturn(true); when(data.getActiveCharacter()).thenReturn(active); assertEquals(List.of("brave"), complete("addtrait", "Iris", "")); assertEquals(List.of(), complete("removetrait", "Iris", "")); - for (String verb : List.of("injure", "permakill")) assertEquals(List.of("Iris"), complete("admin", verb, "I")); + for (String verb : List.of("injure", "permakill", "carryexp")) assertEquals(List.of("Iris"), complete("admin", verb, "I")); assertEquals(List.of(), complete("unknown", "x", "")); } From 359439abe41b17375496c91d1731603037ff4fd0 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:45:22 +0200 Subject: [PATCH 2/3] fix: report carried EXP once per batch with its total Shared Codex rewards carry EXP many times in one tick, so the waiting notice reported only the first reward that overflowed the class cap. It is now sent once the batch is done, with the total waiting, and only if the batch added to it. Co-Authored-By: Claude Opus 5.5 --- .../mmocore/CarriedExperience.java | 38 ++++++++++++++----- .../progress/CarriedExperienceTest.java | 14 ++++--- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/src/main/java/net/tfminecraft/rpcharacters/mmocore/CarriedExperience.java b/src/main/java/net/tfminecraft/rpcharacters/mmocore/CarriedExperience.java index 579b541b..f7c98302 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/mmocore/CarriedExperience.java +++ b/src/main/java/net/tfminecraft/rpcharacters/mmocore/CarriedExperience.java @@ -1,6 +1,9 @@ package net.tfminecraft.rpcharacters.mmocore; +import java.util.HashMap; import java.util.Locale; +import java.util.Map; +import java.util.UUID; import java.util.function.BooleanSupplier; import org.bukkit.Bukkit; @@ -26,6 +29,8 @@ public final class CarriedExperience { public static final String SUBCOMMAND = "carryexp"; private static final double EPSILON = 1e-6; private static final int MAX_STEPS = 1000; + /** Carried EXP at the start of each player's batch still to be reported. Main thread only. */ + private static final Map NOTICES = new HashMap<>(); private CarriedExperience() {} @@ -45,19 +50,34 @@ public static boolean carry(Player player, double amount) { if (progress == null || ClassService.isApplying(player.getUniqueId())) { return false; } - boolean waiting = progress.getCarriedExperience() > EPSILON; - progress.setCarriedExperience(progress.getCarriedExperience() + amount); + double before = progress.getCarriedExperience(); + progress.setCarriedExperience(before + amount); release(player, progress); - // Said once when EXP starts waiting, not for every reward of a batch. - if (!waiting && progress.getCarriedExperience() > EPSILON) { - RPTexts.send(player, RPTexts.MUTED + "Your class can't level further yet, so " + RPTexts.WARN - + format(progress.getCarriedExperience()) + " EXP" + RPTexts.MUTED - + " from your other characters is waiting. It is added once your class can level, such as after you pick a subclass with " - + RPTexts.COMMAND + "/class" + RPTexts.MUTED + "."); - } + noticeAfterBatch(player, before); return true; } + /** + * Tells the player once a batch of carried rewards is done (they arrive many in one tick) how much + * EXP is waiting, if the batch added to it. + */ + private static void noticeAfterBatch(Player player, double before) { + if (RPCharacters.plugin == null || NOTICES.putIfAbsent(player.getUniqueId(), before) != null) { + return; + } + BooleanSupplier current = ProgressRouting.sessionGuard(player); + Bukkit.getScheduler().runTask(RPCharacters.plugin, () -> { + double start = NOTICES.remove(player.getUniqueId()); + CharacterProgress progress = ProgressRouting.activeProgress(player); + if (current.getAsBoolean() && progress != null && progress.getCarriedExperience() > start + EPSILON) { + RPTexts.send(player, RPTexts.MUTED + "Your class can't level further yet, so " + RPTexts.WARN + + format(progress.getCarriedExperience()) + " EXP" + RPTexts.MUTED + + " from your other characters is waiting. It is added once your class can level, such as after you pick a subclass with " + + RPTexts.COMMAND + "/class" + RPTexts.MUTED + "."); + } + }); + } + /** Adds the live character's carried EXP that its class can hold now. */ public static void release(Player player) { CharacterProgress progress = player == null ? null : ProgressRouting.activeProgress(player); diff --git a/src/test/java/net/tfminecraft/rpcharacters/progress/CarriedExperienceTest.java b/src/test/java/net/tfminecraft/rpcharacters/progress/CarriedExperienceTest.java index b3ac7c0a..e2c4159e 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/progress/CarriedExperienceTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/progress/CarriedExperienceTest.java @@ -52,17 +52,21 @@ List said() { var a = live(); assertTrue(CarriedExperience.carry(player, 350)); assertEquals(6, level.get()); assertEquals(150d, a.getProgress().getCarriedExperience(), 1e-9); - assertEquals(1, said().size()); assertTrue(said().getFirst().contains("150 EXP")); assertTrue(CarriedExperience.carry(player, 20.5)); assertEquals(170.5d, a.getProgress().getCarriedExperience(), 1e-9); - assertEquals(1, said().size(), "Waiting is said once, not for every reward of a batch"); - assertEquals(1, later.size(), "One coalesced checkpoint for the whole batch"); runLater(); + assertTrue(said().isEmpty(), "The batch is reported when it is done"); + assertEquals(2, later.size(), "One coalesced checkpoint and one notice for the whole batch"); runLater(); + assertEquals(1, said().size()); assertTrue(said().getFirst().contains("170.5 EXP"), said().getFirst()); assertEquals(170.5d, store.read(account.getUniqueId(), a.getId()).orElseThrow().getCarriedExperience(), 1e-9, "Carried EXP is saved with the character"); + clearInvocations(player); level.set(5); CarriedExperience.carry(player, 50); runLater(); + assertTrue(said().isEmpty(), "Nothing new waits when the class takes the whole batch"); + level.set(6); CarriedExperience.carry(player, 5); play(a); curve(); runLater(); + assertTrue(said().isEmpty(), "A batch from an earlier activation is not reported"); clearInvocations(player); CarriedExperience.release(player); assertTrue(said().isEmpty(), "Nothing is added at the cap"); cap.set(11); CarriedExperience.release(player); - assertEquals(7, level.get()); assertEquals(70.5d, experience.get(), 1e-9); assertEquals(0d, a.getProgress().getCarriedExperience()); - assertTrue(said().getFirst().contains("170.5 carried EXP")); + assertEquals(7, level.get()); assertEquals(25.5d, experience.get(), 1e-9); assertEquals(0d, a.getProgress().getCarriedExperience()); + assertTrue(said().getFirst().contains("125.5 carried EXP"), said().getFirst()); clearInvocations(player, mmo); CarriedExperience.release(player); verify(mmo, never()).giveExperience(anyDouble(), any(EXPSource.class)); } From 8e4fbcf083b03bb2b2f50a64c532539342bdd793 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:49:28 +0200 Subject: [PATCH 3/3] docs: describe shared professions in the README Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d7613aa3..5d207a16 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Beyond a name and appearance, characters have traits, professions, injuries, and - **Character profiles** — create and switch between characters, with race, traits, descriptions, and website-connected creation. - **Roleplay conversation** — use local speech, whispers, shouts, actions, and out-of-character channels, with speech bubbles and channel preferences. - **Identity and disguise** — support masks, alternate personas, and control over character visibility in BirdMessenger's recipient list. -- **Classes and progression** — choose classes and subclasses, develop professions and attributes, and use account-wide class levels across characters. +- **Classes and progression** — choose classes and subclasses and develop attributes per character; profession levels and points are shared by all of an account's characters, and class EXP carried over from other characters waits for a class that can level. - **Character focus** — share a regenerating character resource across Research and Magic, with focus-restoring items. - **Injuries and recovery** — manage injuries and prosthetics; healing continues in real time while players are offline. - **Combat and consequences** — support lethal or nonlethal PvP, timed conflicts, character strikes, armour preparation, graves, and investigation clues.