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. 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..f7c98302 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/mmocore/CarriedExperience.java @@ -0,0 +1,166 @@ +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; +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; + /** 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() {} + + /** + * 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; + } + double before = progress.getCarriedExperience(); + progress.setCarriedExperience(before + amount); + release(player, progress); + 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); + 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..e2c4159e --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/progress/CarriedExperienceTest.java @@ -0,0 +1,143 @@ +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); + assertTrue(CarriedExperience.carry(player, 20.5)); assertEquals(170.5d, a.getProgress().getCarriedExperience(), 1e-9); + 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(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)); + } + + @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", "")); }