Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <injure|permakill|strikes|tutorial|resetclasses> ...");
RPTexts.send(sender, RPTexts.ERROR + "Usage: /rpcharacter admin <injure|permakill|strikes|tutorial|resetclasses|carryexp> ...");
return true;
}
if (args[1].equalsIgnoreCase("injure")) {
Expand All @@ -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 <injure|permakill|strikes|tutorial|resetclasses> ...");
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 <injure|permakill|strikes|tutorial|resetclasses|carryexp> ...");
return true;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<UUID, Double> 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 <player> <amount>}, 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 + " <player> <amount>");
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, ProfessionLevel> professions(ConfigurationSection section) {
Map<String, ProfessionLevel> 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<String, ProfessionLevel> professions) {
decode(section);
YamlConfiguration copy = new YamlConfiguration();
section.getValues(false).forEach(copy::set);
List<Map<String, Object>> entries = new ArrayList<>();
for (Map.Entry<String, ProfessionLevel> profession : new TreeMap<>(professions).entrySet()) {
Map<String, Object> 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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Integer> professionPoints = new TreeMap<>();
private YamlConfiguration mmo;

Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
Expand All @@ -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<Map<String, Object>> professions = new ArrayList<>();
professionPoints.forEach((id, value) -> {
Map<String, Object> entry = new LinkedHashMap<>();
Expand Down Expand Up @@ -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");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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<CharacterProgress> others = account.getCharacters().stream().map(RPCharacter::getProgress)
.filter(Objects::nonNull).toList();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<CharacterProgress> account) {
Map<String, ProfessionLevel> 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<String, ProfessionLevel> 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<String, Integer> 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;
}
}
Loading
Loading