From ba61a23ab475061769f8234319a255b7d6f82b0a Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 28 Sep 2026 07:51:04 +0000 Subject: [PATCH 1/5] feat: let players pay denars to change a locked choice Once a stage's lock-time runs out, a configured paid-change rule lets the player reopen it by paying denars (pouch first, then bank). The payment is held while the stage is open: keeping a change counts it and raises the next price, and backing out refunds it. Class is the first rule: free for 5 days, then 100, 1000, and 3000 denars for every change after that. Co-Authored-By: Claude Opus 5.5 (1M context) --- pom.xml | 13 + .../rpcharacters/RPCharacters.java | 5 + .../creation/CharacterCreation.java | 37 ++- .../database/CharacterPaidChangeFields.java | 34 +++ .../rpcharacters/database/Database.java | 2 + .../rpcharacters/loaders/ConfigLoader.java | 2 + .../managers/CreationManager.java | 11 +- .../managers/InventoryManager.java | 34 ++- .../rpcharacters/managers/PlayerManager.java | 1 + .../rpcharacters/objects/RPCharacter.java | 21 ++ .../paidchange/DenarEconomyWallet.java | 36 +++ .../rpcharacters/paidchange/DenarWallet.java | 24 ++ .../paidchange/PaidChangeConfig.java | 67 +++++ .../paidchange/PaidChangeRule.java | 47 ++++ .../paidchange/PaidChangeService.java | 237 ++++++++++++++++ src/main/resources/config.yml | 12 + src/main/resources/plugin.yml | 2 +- .../paidchange/PaidChangeServiceTest.java | 256 ++++++++++++++++++ 18 files changed, 826 insertions(+), 15 deletions(-) create mode 100644 src/main/java/net/tfminecraft/rpcharacters/database/CharacterPaidChangeFields.java create mode 100644 src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWallet.java create mode 100644 src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarWallet.java create mode 100644 src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeConfig.java create mode 100644 src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeRule.java create mode 100644 src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java create mode 100644 src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeServiceTest.java diff --git a/pom.xml b/pom.xml index 157cbe3..7642a11 100644 --- a/pom.xml +++ b/pom.xml @@ -11,6 +11,7 @@ 21 UTF-8 + 0.2.7 3.0.1 2.0.0 7.0.9 @@ -115,6 +116,18 @@ ${worldguard.version} provided + + net.tfminecraft + denareconomy + ${denareconomy.version} + provided + + + * + * + + + net.tfminecraft simplefactions diff --git a/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java b/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java index c2741d0..ee4ccc8 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java +++ b/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java @@ -44,7 +44,9 @@ import net.tfminecraft.rpcharacters.loaders.PvpLoader; import net.tfminecraft.rpcharacters.loaders.PartyLoader; import net.tfminecraft.rpcharacters.managers.CommandManager; +import net.tfminecraft.rpcharacters.creation.CharacterCreation; import net.tfminecraft.rpcharacters.managers.CreationManager; +import net.tfminecraft.rpcharacters.paidchange.PaidChangeService; import net.tfminecraft.rpcharacters.managers.ClueDisturbanceListener; import net.tfminecraft.rpcharacters.managers.MagnifyingGlassListener; import net.tfminecraft.rpcharacters.managers.PlaceClueManager; @@ -291,6 +293,9 @@ public void onDisable() { PvpStrikeService.shutdown(); LastSolidTracker.get().shutdown(); GraveManager.get().saveAll(); + for (CharacterCreation cc : new java.util.ArrayList<>(CreationManager.activeCreators.values())) { + PaidChangeService.settle(cc); + } save(); } diff --git a/src/main/java/net/tfminecraft/rpcharacters/creation/CharacterCreation.java b/src/main/java/net/tfminecraft/rpcharacters/creation/CharacterCreation.java index 60a185d..8fd49d7 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/creation/CharacterCreation.java +++ b/src/main/java/net/tfminecraft/rpcharacters/creation/CharacterCreation.java @@ -47,6 +47,8 @@ import net.tfminecraft.rpcharacters.objects.RPCharacter; +import net.tfminecraft.rpcharacters.paidchange.PaidChangeService; + import net.tfminecraft.rpcharacters.objects.attributes.AttributeData; import net.tfminecraft.rpcharacters.objects.trait.Trait; import net.tfminecraft.rpcharacters.objects.trait.TraitEffectResolver; @@ -93,6 +95,8 @@ public class CharacterCreation { private boolean editingFromSummary = false; private Stage editStage = null; + /** Denars held while a paid stage is open; settled when the player leaves that stage. */ + private PaidChangeService.Pending pendingPaidChange; private final String summaryStageId = "creation_summary_stage"; @@ -408,6 +412,25 @@ public RPCharacter getCharacter() { } + public Player getPlayer() { + return p; + } + + public PaidChangeService.Pending getPendingPaidChange() { + return pendingPaidChange; + } + + public void setPendingPaidChange(PaidChangeService.Pending pending) { + this.pendingPaidChange = pending; + } + + /** Returns and clears the held payment so it is settled only once. */ + public PaidChangeService.Pending takePendingPaidChange() { + PaidChangeService.Pending pending = pendingPaidChange; + pendingPaidChange = null; + return pending; + } + public void openSummary() { @@ -490,9 +513,9 @@ public void jumpToStageForEdit(String stageId) { } - if (!StageEditLock.canEdit(p, template, character)) { + PaidChangeService.settle(this); - RPTexts.send(p, RPTexts.ERROR + "That choice is locked and can no longer be edited."); + if (!StageEditLock.canEdit(p, template, character) && !PaidChangeService.payToOpen(p, this, template)) { return; @@ -504,6 +527,8 @@ public void jumpToStageForEdit(String stageId) { RPTexts.send(p, RPTexts.ERROR + "Could not open editor for that choice."); + PaidChangeService.settle(this); + return; } @@ -542,6 +567,8 @@ public void jumpToStageForEdit(String stageId) { RPTexts.send(p, RPTexts.ERROR + "That choice cannot be edited from the summary."); + PaidChangeService.settle(this); + } } @@ -562,6 +589,8 @@ public void returnToSummary() { editStage = null; + PaidChangeService.settle(this); + if (isEditing()) { persistEdits(); @@ -857,6 +886,8 @@ public void finish() { public void closeEditSession() { + PaidChangeService.settle(this); + persistEdits(); CreationManager.activeCreators.remove(p); @@ -1063,6 +1094,8 @@ public void cancel() { if (isEditing()) { + PaidChangeService.settle(this); + CreationManager.activeCreators.remove(p); p.closeInventory(); diff --git a/src/main/java/net/tfminecraft/rpcharacters/database/CharacterPaidChangeFields.java b/src/main/java/net/tfminecraft/rpcharacters/database/CharacterPaidChangeFields.java new file mode 100644 index 0000000..3d01744 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/database/CharacterPaidChangeFields.java @@ -0,0 +1,34 @@ +package net.tfminecraft.rpcharacters.database; + +import java.util.Map; + +import org.json.simple.JSONObject; + +import net.tfminecraft.rpcharacters.objects.RPCharacter; + +/** How many paid changes each rule has had, stored under "paid-changes" in the character file. */ +public final class CharacterPaidChangeFields { + private CharacterPaidChangeFields() {} + + public static void load(RPCharacter character, JSONObject characterJson) { + if (character == null || characterJson == null + || !(characterJson.get("paid-changes") instanceof Map counts)) { + return; + } + for (Map.Entry entry : counts.entrySet()) { + if (entry.getKey() instanceof String ruleId && entry.getValue() instanceof Number count) { + character.setPaidChangeCount(ruleId, count.intValue()); + } + } + } + + @SuppressWarnings("unchecked") + public static void save(Map defaults, RPCharacter character) { + if (character.getPaidChangeCounts().isEmpty()) { + return; + } + JSONObject counts = new JSONObject(); + counts.putAll(character.getPaidChangeCounts()); + defaults.put("paid-changes", counts); + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/database/Database.java b/src/main/java/net/tfminecraft/rpcharacters/database/Database.java index dedbea6..0f06695 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/database/Database.java +++ b/src/main/java/net/tfminecraft/rpcharacters/database/Database.java @@ -312,6 +312,7 @@ public void loadCharacters(PlayerData pd) { loadLastLocation(c, json); loadPvpLethal(c, json); CharacterEvilRpFields.load(c, json); + CharacterPaidChangeFields.load(c, json); loadNutritionFields(c, json); c.ensureTraitStateDefaults(); if (!Boolean.TRUE.equals(c.isActive()) && c.removeExpiredDurationTraits(System.currentTimeMillis())) { @@ -461,6 +462,7 @@ public void saveCharacter(PlayerData pd, RPCharacter c) { saveLastLocation(defaults, c); defaults.put("pvp-lethal", String.valueOf(c.isPvpLethal())); CharacterEvilRpFields.save(defaults, c); + CharacterPaidChangeFields.save(defaults, c); saveNutritionFields(defaults, c); if (save(file, defaults)) { net.tfminecraft.rpcharacters.playtime.CharacterPlaytimeDirectory.upsert(pd.getUniqueId(), c); diff --git a/src/main/java/net/tfminecraft/rpcharacters/loaders/ConfigLoader.java b/src/main/java/net/tfminecraft/rpcharacters/loaders/ConfigLoader.java index 9cc540e..4885de6 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/loaders/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/rpcharacters/loaders/ConfigLoader.java @@ -16,6 +16,7 @@ import net.tfminecraft.tlibs.interfaces.LoaderInterface; import net.tfminecraft.rpcharacters.Cache; +import net.tfminecraft.rpcharacters.paidchange.PaidChangeConfig; public class ConfigLoader implements LoaderInterface{ @@ -105,6 +106,7 @@ public void load(File configFile) { Cache.rpInjureRange = 10; Cache.rpInjureTimeoutSeconds = 30; } + PaidChangeConfig.load(config.getConfigurationSection("paid-changes")); validateClueConfig(); } diff --git a/src/main/java/net/tfminecraft/rpcharacters/managers/CreationManager.java b/src/main/java/net/tfminecraft/rpcharacters/managers/CreationManager.java index 05a6e3d..d1fd4d9 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/managers/CreationManager.java +++ b/src/main/java/net/tfminecraft/rpcharacters/managers/CreationManager.java @@ -40,6 +40,7 @@ import net.tfminecraft.rpcharacters.objects.RPCharacter; import net.tfminecraft.rpcharacters.objects.SelectableItem; import net.tfminecraft.rpcharacters.objects.trait.Trait; +import net.tfminecraft.rpcharacters.paidchange.PaidChangeService; import net.tfminecraft.rpcharacters.utils.PlaytimeGate; import net.tfminecraft.rpcharacters.utils.ProstheticTraitRules; import net.tfminecraft.rpcharacters.utils.RPTexts; @@ -141,7 +142,8 @@ public static void initiateEditEntry(Player p, String entryKey) { RPTexts.send(p, RPTexts.ERROR + "You have no active character to edit."); return; } - if (stage != null && !StageEditLock.canEdit(p, stage, pd.getActiveCharacter())) { + if (stage != null && !StageEditLock.canEdit(p, stage, pd.getActiveCharacter()) + && !PaidChangeService.canPayToOpen(stage)) { RPTexts.send(p, RPTexts.ERROR + "That choice is locked and can no longer be edited."); return; } @@ -149,6 +151,10 @@ public static void initiateEditEntry(Player p, String entryKey) { CharacterCreation cc = CharacterCreation.forEdit(p, pd.getActiveCharacter()); activeCreators.put(p, cc); cc.jumpToStageForEdit(stageId); + if (!cc.isEditingFromSummary()) { + // Nothing opened (e.g. the player could not pay), so don't leave a session with no GUI. + activeCreators.remove(p, cc); + } return; } CharacterCreation existing = activeCreators.get(p); @@ -505,7 +511,8 @@ private void handleSummaryClick(Player p, InventoryClickEvent e) { } String stageId = action.substring("edit:".length()); Stage stage = StageLoader.getById(stageId); - if (stage != null && !StageEditLock.canEdit(p, stage, cc.getCharacter())) { + if (stage != null && !StageEditLock.canEdit(p, stage, cc.getCharacter()) + && !PaidChangeService.canPayToOpen(stage)) { RPTexts.send(p, RPTexts.ERROR + "That choice is locked and can no longer be edited."); return; } diff --git a/src/main/java/net/tfminecraft/rpcharacters/managers/InventoryManager.java b/src/main/java/net/tfminecraft/rpcharacters/managers/InventoryManager.java index 2b90976..85f4ea1 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/managers/InventoryManager.java +++ b/src/main/java/net/tfminecraft/rpcharacters/managers/InventoryManager.java @@ -25,6 +25,7 @@ import net.tfminecraft.rpcharacters.creation.Dependency; import net.tfminecraft.rpcharacters.creation.Stage; import net.tfminecraft.rpcharacters.creation.StageEditLock; +import net.tfminecraft.rpcharacters.paidchange.PaidChangeService; import net.tfminecraft.rpcharacters.creation.stages.AttributesStage; import net.tfminecraft.rpcharacters.creation.stages.SelectionStage; import net.tfminecraft.rpcharacters.creation.stages.SummaryStage; @@ -209,8 +210,8 @@ public void creationSummaryView(Player player, CharacterCreation creation, Summa } ItemMeta meta = item.getItemMeta(); if (meta != null) { - boolean locked = isSummaryEntryLocked(player, character, stageId); - if (!locked) { + boolean locked = editing && isSummaryEntryLocked(player, character, stageId); + if (!locked || PaidChangeService.canPayToOpen(StageLoader.getById(stageId))) { String action = "clues".equalsIgnoreCase(stageId) ? "clues" : "edit:" + stageId; meta.getPersistentDataContainer().set(actionKey, PersistentDataType.STRING, action); } @@ -295,14 +296,19 @@ private ItemStack buildSummaryEntryItem(Player player, RPCharacter character, St String label = WordUtils.capitalize(entryKey.replace('_', ' ')); List lore = new ArrayList<>(); boolean locked = editing && isSummaryEntryLocked(player, character, stageId); - if (locked) { - lore.add(summaryValue(RPTexts.ERROR + "Locked")); + Stage editedStage = editing && !"clues".equalsIgnoreCase(stageId) ? StageLoader.getById(stageId) : null; + List paidLore = PaidChangeService.summaryLore(editedStage, character, locked); + if (paidLore != null) { + for (String line : paidLore) { + lore.add(summaryValue(line)); + } } else { - lore.add(summaryValue(RPTexts.MUTED + "Click to change")); - } - if (editing && !"clues".equalsIgnoreCase(stageId)) { - Stage stage = StageLoader.getById(stageId); - String lockLore = StageEditLock.lockLore(stage, character); + if (locked) { + lore.add(summaryValue(RPTexts.ERROR + "Locked")); + } else { + lore.add(summaryValue(RPTexts.MUTED + "Click to change")); + } + String lockLore = StageEditLock.lockLore(editedStage, character); if (lockLore != null && !locked) { lore.add(summaryValue(lockLore)); } @@ -903,7 +909,15 @@ public ItemStack createCancelItem(CharacterCreation cc) { ItemStack i = new ItemStack(Material.BARRIER, 1); ItemMeta meta = i.getItemMeta(); List lore = new ArrayList<>(); - if(cc != null) { + PaidChangeService.Pending pending = cc != null ? cc.getPendingPaidChange() : null; + if (pending != null) { + meta.setDisplayName(t(RPTexts.ERROR + "Cancel")); + lore.add(t(RPTexts.MUTED + "Keep your " + pending.rule().getLabel())); + if (pending.amount() > 0.0) { + lore.add(t(RPTexts.MUTED + "and get " + RPTexts.GUI_WARN + PaidChangeService.formatDenars(pending.amount()) + + RPTexts.MUTED + " back")); + } + } else if(cc != null) { meta.setDisplayName(t(RPTexts.ERROR + "Cancel Creation")); lore.add(t(RPTexts.MUTED + "Cancel the current character creation")); diff --git a/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java b/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java index 091ccec..b63c3ff 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java +++ b/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java @@ -337,6 +337,7 @@ public void onLeave(PlayerQuitEvent e) { net.tfminecraft.rpcharacters.clues.discovery.ClueAdminModeService.clear(p); TempAliasService.clear(p); MmoCorePlayerReady.cancel(p.getUniqueId()); + net.tfminecraft.rpcharacters.paidchange.PaidChangeService.settle(CreationManager.activeCreators.get(p)); PlayerData pd = get(p); if (pd != null && pd.hasActiveCharacter()) { // Prevent MMOCore from persisting stacked attribute bases for the next login. diff --git a/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java b/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java index e9db746..9e6156b 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java +++ b/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java @@ -99,6 +99,8 @@ public class RPCharacter { private long evilRpSessionEndsAtMs; /** When the latest strike landed, so strikes can wear off. 0 when unknown. */ private long lastStrikeAtMs; + /** Paid changes made so far per paid-change rule, keyed by rule id. Sets the next price. */ + private final Map paidChangeCounts = new HashMap<>(); public static final int MAX_FOOD_VALUE = 200; private static final int MAX_DIET_SCORE = 40; @@ -205,6 +207,25 @@ public void setPvpLethal(boolean pvpLethal) { this.pvpLethal = pvpLethal; } + public int getPaidChangeCount(String ruleId) { + return ruleId == null ? 0 : paidChangeCounts.getOrDefault(ruleId, 0); + } + + public void setPaidChangeCount(String ruleId, int count) { + if (ruleId == null) { + return; + } + if (count <= 0) { + paidChangeCounts.remove(ruleId); + } else { + paidChangeCounts.put(ruleId, count); + } + } + + public Map getPaidChangeCounts() { + return Collections.unmodifiableMap(paidChangeCounts); + } + public int getEvilRpStrikes() { return evilRpStrikes; } diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWallet.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWallet.java new file mode 100644 index 0000000..a980a6b --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWallet.java @@ -0,0 +1,36 @@ +package net.tfminecraft.rpcharacters.paidchange; + +import java.util.UUID; + +import org.bukkit.Bukkit; + +import net.tfminecraft.denareconomy.accounts.OfflineModifier; +import net.tfminecraft.denareconomy.enums.Accounts; + +/** DenarEconomy accounts. DenarEconomy is a soft dependency, so check {@link #available()} first. */ +final class DenarEconomyWallet implements DenarWallet { + + @Override + public boolean available() { + return Bukkit.getPluginManager().isPluginEnabled("DenarEconomy"); + } + + @Override + public double balance(UUID playerId, Account account) { + return OfflineModifier.balance(playerId, toDenar(account)); + } + + @Override + public boolean withdraw(UUID playerId, Account account, double amount) { + return amount > 0.0 && OfflineModifier.apply(playerId, toDenar(account), -amount); + } + + @Override + public boolean deposit(UUID playerId, Account account, double amount) { + return amount > 0.0 && OfflineModifier.apply(playerId, toDenar(account), amount); + } + + private static Accounts toDenar(Account account) { + return account == Account.BANK ? Accounts.BANK : Accounts.POUCH; + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarWallet.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarWallet.java new file mode 100644 index 0000000..6008ff8 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarWallet.java @@ -0,0 +1,24 @@ +package net.tfminecraft.rpcharacters.paidchange; + +import java.util.UUID; + +/** The denar accounts a paid change is charged to. */ +public interface DenarWallet { + enum Account { + POUCH, + BANK; + + public String displayName() { + return name().toLowerCase(java.util.Locale.ROOT); + } + } + + boolean available(); + + double balance(UUID playerId, Account account); + + /** Takes the whole amount, or nothing when the account cannot cover it. */ + boolean withdraw(UUID playerId, Account account, double amount); + + boolean deposit(UUID playerId, Account account, double amount); +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeConfig.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeConfig.java new file mode 100644 index 0000000..3aae5bf --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeConfig.java @@ -0,0 +1,67 @@ +package net.tfminecraft.rpcharacters.paidchange; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.logging.Logger; + +import org.bukkit.configuration.ConfigurationSection; + +import net.tfminecraft.rpcharacters.paidchange.DenarWallet.Account; + +/** Reads the {@code paid-changes} section of config.yml into {@link PaidChangeService}. */ +public final class PaidChangeConfig { + private static final Logger LOG = Logger.getLogger("RPCharacters"); + + private PaidChangeConfig() {} + + public static void load(ConfigurationSection section) { + List rules = new ArrayList<>(); + List accounts = new ArrayList<>(); + if (section != null) { + for (String name : section.getStringList("accounts")) { + try { + accounts.add(Account.valueOf(name.trim().toUpperCase(Locale.ROOT))); + } catch (IllegalArgumentException e) { + LOG.warning("[RPCharacters] paid-changes.accounts: unknown account '" + name + + "'. Use pouch or bank."); + } + } + ConfigurationSection ruleSection = section.getConfigurationSection("rules"); + if (ruleSection != null) { + for (String id : ruleSection.getKeys(false)) { + PaidChangeRule rule = readRule(id, ruleSection.getConfigurationSection(id)); + if (rule != null) { + rules.add(rule); + } + } + } + } + PaidChangeService.configure(rules, accounts); + } + + private static PaidChangeRule readRule(String id, ConfigurationSection rule) { + if (rule == null) { + return null; + } + String stageId = rule.getString("stage"); + if (stageId == null || stageId.isBlank()) { + LOG.warning("[RPCharacters] paid-changes.rules." + id + " has no stage; skipped."); + return null; + } + List costs = new ArrayList<>(); + for (Object value : rule.getList("costs", List.of())) { + if (value instanceof Number number && number.doubleValue() >= 0.0) { + costs.add(number.doubleValue()); + } else { + LOG.warning("[RPCharacters] paid-changes.rules." + id + ".costs: ignored '" + value + + "'. Costs must be numbers of 0 or more."); + } + } + if (costs.isEmpty()) { + LOG.warning("[RPCharacters] paid-changes.rules." + id + " has no costs; skipped."); + return null; + } + return new PaidChangeRule(id, stageId.trim(), rule.getString("label", id), costs); + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeRule.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeRule.java new file mode 100644 index 0000000..d0967b3 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeRule.java @@ -0,0 +1,47 @@ +package net.tfminecraft.rpcharacters.paidchange; + +import java.util.List; + +/** + * Lets players pay to reopen a creation stage after its lock-time runs out. Each paid change + * costs the next entry in {@code costs}; the last entry repeats once the list runs out. + */ +public final class PaidChangeRule { + private final String id; + private final String stageId; + private final String label; + private final List costs; + + public PaidChangeRule(String id, String stageId, String label, List costs) { + this.id = id; + this.stageId = stageId; + this.label = label == null || label.isBlank() ? id : label; + this.costs = List.copyOf(costs); + } + + /** Key for the per-character paid change count. */ + public String getId() { + return id; + } + + public String getStageId() { + return stageId; + } + + /** What players change, in lower case, e.g. "class". */ + public String getLabel() { + return label; + } + + public List getCosts() { + return costs; + } + + /** Price of a change after {@code paidSoFar} earlier paid changes. */ + public double costAfter(int paidSoFar) { + if (costs.isEmpty()) { + return 0.0; + } + return costs.get(Math.min(Math.max(0, paidSoFar), costs.size() - 1)); + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java new file mode 100644 index 0000000..bfc7ef2 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java @@ -0,0 +1,237 @@ +package net.tfminecraft.rpcharacters.paidchange; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.bukkit.entity.Player; + +import net.tfminecraft.rpcharacters.RPCharacters; +import net.tfminecraft.rpcharacters.creation.CharacterCreation; +import net.tfminecraft.rpcharacters.creation.Stage; +import net.tfminecraft.rpcharacters.creation.StageEditLock; +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import net.tfminecraft.rpcharacters.objects.trait.Trait; +import net.tfminecraft.rpcharacters.paidchange.DenarWallet.Account; +import net.tfminecraft.rpcharacters.utils.AgeFormatter; +import net.tfminecraft.rpcharacters.utils.RPTexts; + +/** + * Pay to reopen a locked creation stage. The player pays when the stage opens, and the denars + * stay held on the edit session until they leave it. A changed character keeps the payment and + * raises the next price; an unchanged one gets the denars back. + */ +public final class PaidChangeService { + + /** Denars held for an open paid stage. {@code account} is null when the change was free. */ + public record Pending(PaidChangeRule rule, UUID payerId, Account account, double amount, String before) {} + + public enum ChargeStatus { + PAID, + UNAVAILABLE, + INSUFFICIENT_FUNDS + } + + public record ChargeResult(ChargeStatus status, Pending pending, double cost) {} + + private static final Logger LOG = Logger.getLogger("RPCharacters"); + + private static volatile Map rulesByStage = Map.of(); + private static volatile List accountOrder = List.of(Account.POUCH, Account.BANK); + private static DenarWallet wallet = new DenarEconomyWallet(); + + private PaidChangeService() {} + + public static void configure(Collection rules, List accounts) { + Map byStage = new LinkedHashMap<>(); + for (PaidChangeRule rule : rules) { + byStage.put(rule.getStageId().toLowerCase(Locale.ROOT), rule); + } + rulesByStage = Map.copyOf(byStage); + accountOrder = accounts == null || accounts.isEmpty() + ? List.of(Account.POUCH, Account.BANK) + : List.copyOf(accounts); + } + + static void setWallet(DenarWallet replacement) { + wallet = replacement; + } + + public static PaidChangeRule ruleFor(Stage stage) { + if (stage == null || stage.getId() == null) { + return null; + } + return rulesByStage.get(stage.getId().toLowerCase(Locale.ROOT)); + } + + /** True when a locked stage can be reopened by paying. */ + public static boolean canPayToOpen(Stage stage) { + return ruleFor(stage) != null && wallet.available(); + } + + /** Takes the next price from the first account that covers it. */ + public static ChargeResult charge(UUID payerId, PaidChangeRule rule, RPCharacter character) { + double cost = rule.costAfter(character.getPaidChangeCount(rule.getId())); + String before = snapshot(character); + if (cost <= 0.0) { + return new ChargeResult(ChargeStatus.PAID, new Pending(rule, payerId, null, 0.0, before), 0.0); + } + if (!wallet.available()) { + return new ChargeResult(ChargeStatus.UNAVAILABLE, null, cost); + } + for (Account account : accountOrder) { + if (wallet.balance(payerId, account) >= cost && wallet.withdraw(payerId, account, cost)) { + return new ChargeResult(ChargeStatus.PAID, new Pending(rule, payerId, account, cost, before), cost); + } + } + return new ChargeResult(ChargeStatus.INSUFFICIENT_FUNDS, null, cost); + } + + /** + * Keeps the payment and counts the change when the character changed; otherwise refunds it. + * Returns true when the payment was kept. + */ + public static boolean resolve(Pending pending, RPCharacter character) { + if (character != null && !snapshot(character).equals(pending.before())) { + String ruleId = pending.rule().getId(); + character.setPaidChangeCount(ruleId, character.getPaidChangeCount(ruleId) + 1); + return true; + } + if (pending.account() != null && pending.amount() > 0.0 + && !wallet.deposit(pending.payerId(), pending.account(), pending.amount())) { + LOG.log(Level.SEVERE, "Could not refund " + pending.amount() + " denars to " + + pending.payerId() + " (" + pending.account() + ") for a " + pending.rule().getId() + + " change they backed out of. Refund them by hand."); + } + return false; + } + + /** Charges for a locked stage and holds the payment on the session. False leaves the stage shut. */ + public static boolean payToOpen(Player player, CharacterCreation cc, Stage stage) { + settle(cc); + PaidChangeRule rule = ruleFor(stage); + if (rule == null) { + RPTexts.send(player, RPTexts.ERROR + "That choice is locked and can no longer be edited."); + return false; + } + ChargeResult result = charge(player.getUniqueId(), rule, cc.getCharacter()); + switch (result.status()) { + case UNAVAILABLE -> { + RPTexts.send(player, RPTexts.ERROR + "You can't pay to change your " + rule.getLabel() + + " right now. Try again later."); + return false; + } + case INSUFFICIENT_FUNDS -> { + UUID id = player.getUniqueId(); + RPTexts.send(player, RPTexts.ERROR + "Changing your " + rule.getLabel() + " costs " + + formatDenars(result.cost()) + ". You have " + formatDenars(wallet.balance(id, Account.POUCH)) + + " in your pouch and " + formatDenars(wallet.balance(id, Account.BANK)) + " in the bank."); + return false; + } + default -> { + } + } + Pending pending = result.pending(); + cc.setPendingPaidChange(pending); + if (pending.amount() > 0.0) { + RPTexts.send(player, RPTexts.SUCCESS + "Paid " + formatDenars(pending.amount()) + " from your " + + pending.account().displayName() + " to change your " + rule.getLabel() + "."); + RPTexts.send(player, RPTexts.MUTED + "Leave without changing it and you get the denars back."); + } + return true; + } + + /** Keeps or refunds the payment held on this session, if any. Safe to call more than once. */ + public static void settle(CharacterCreation cc) { + if (cc == null) { + return; + } + Pending pending = cc.takePendingPaidChange(); + if (pending == null) { + return; + } + Player player = cc.getPlayer(); + RPCharacter character = cc.getCharacter(); + PaidChangeRule rule = pending.rule(); + if (resolve(pending, character)) { + double next = rule.costAfter(character.getPaidChangeCount(rule.getId())); + tell(player, RPTexts.SUCCESS + "Your " + rule.getLabel() + " change is paid for. The next one costs " + + formatDenars(next) + "."); + if (player != null && RPCharacters.getPlayerManager() != null) { + RPCharacters.getPlayerManager().savePlayer(player); + } + } else if (pending.amount() > 0.0) { + tell(player, RPTexts.SUCCESS + "Your " + rule.getLabel() + " is unchanged, so " + + formatDenars(pending.amount()) + " went back to your " + pending.account().displayName() + "."); + } + } + + private static void tell(Player player, String message) { + if (player != null) { + RPTexts.send(player, message); + } + } + + /** + * Summary lore for a stage with a paid-change rule, or null to keep the default lore. + * Free window: how long it stays free and the next two prices. Locked: the price to change now + * and the one after. + */ + public static List summaryLore(Stage stage, RPCharacter character, boolean locked) { + PaidChangeRule rule = ruleFor(stage); + if (rule == null || character == null) { + return null; + } + int paid = character.getPaidChangeCount(rule.getId()); + String next = formatDenars(rule.costAfter(paid)); + String after = formatDenars(rule.costAfter(paid + 1)); + List lines = new ArrayList<>(); + if (!locked) { + lines.add(RPTexts.MUTED + "Click to change"); + long remaining = StageEditLock.lockRemainingMs(stage, character); + if (remaining > 0L) { + lines.add(RPTexts.MUTED + "Free to change for: " + RPTexts.WARN + AgeFormatter.formatCountdown(remaining)); + lines.add(RPTexts.MUTED + "Then: " + RPTexts.WARN + next + RPTexts.MUTED + ", next: " + RPTexts.WARN + after); + } + return lines; + } + if (!wallet.available()) { + return null; + } + lines.add(RPTexts.WARN + "Click to change for " + next); + lines.add(RPTexts.MUTED + "The change after costs " + RPTexts.WARN + after); + lines.add(RPTexts.MUTED + "Refunded if you keep your " + rule.getLabel()); + return lines; + } + + public static String formatDenars(double amount) { + String number = amount == Math.rint(amount) + ? String.format(Locale.ROOT, "%,d", (long) amount) + : String.format(Locale.ROOT, "%,.2f", amount); + return number + (amount == 1.0 ? " denar" : " denars"); + } + + /** Everything a creation stage can change, so any edit made after paying shows up. */ + static String snapshot(RPCharacter character) { + List traitIds = new ArrayList<>(); + for (Trait trait : character.getTraits()) { + if (trait != null) { + traitIds.add(trait.getId()); + } + } + traitIds.sort(null); + return String.join("\u0000", + String.valueOf(character.getMMOClass()), + character.getRace() == null ? "null" : String.valueOf(character.getRace().getId()), + String.join(",", traitIds), + String.valueOf(character.getName()), + String.valueOf(character.getPersonaDescription()), + String.valueOf(character.getBirthday())); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index cba3f94..238c827 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -79,3 +79,15 @@ conversation: rp-injure: range: 10 timeout-seconds: 30 + +# Pay to change a choice after its stage's lock-time runs out (class is locked after 5d). +# Players pay when they open the choice, and get the denars back if they leave without changing it. +# costs: the first paid change, the second, and so on. The last cost repeats after that. +# accounts: where to take the denars from, tried in order. The whole cost comes from one account. +paid-changes: + accounts: [pouch, bank] + rules: + class: + stage: class_selection_stage + label: class + costs: [100, 1000, 3000] diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 0ca373e..5e5cb4c 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -5,7 +5,7 @@ version: ${project.version} author: Drefvelin depend: [TLibs, MMOCore, TFMCWeb, MMOItems, MythicLib] -softdepend: [ProtocolLib, PlaceholderAPI, LuckPerms, WorldGuard, SimpleFactions, GSit] +softdepend: [ProtocolLib, PlaceholderAPI, LuckPerms, WorldGuard, SimpleFactions, GSit, DenarEconomy] commands: focus: diff --git a/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeServiceTest.java b/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeServiceTest.java new file mode 100644 index 0000000..cca9249 --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeServiceTest.java @@ -0,0 +1,256 @@ +package net.tfminecraft.rpcharacters.paidchange; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.rpcharacters.creation.Stage; +import net.tfminecraft.rpcharacters.database.CharacterPaidChangeFields; +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import net.tfminecraft.rpcharacters.paidchange.DenarWallet.Account; +import net.tfminecraft.rpcharacters.paidchange.PaidChangeService.ChargeResult; +import net.tfminecraft.rpcharacters.paidchange.PaidChangeService.ChargeStatus; + +class PaidChangeServiceTest { + + private static final long DAY_MS = 86_400_000L; + private static final UUID PLAYER = UUID.randomUUID(); + private static final PaidChangeRule CLASS_RULE = + new PaidChangeRule("class", "class_selection_stage", "class", List.of(100.0, 1000.0, 3000.0)); + + private final FakeWallet wallet = new FakeWallet(); + + @BeforeEach + void setUp() { + PaidChangeService.setWallet(wallet); + PaidChangeService.configure(List.of(CLASS_RULE), List.of(Account.POUCH, Account.BANK)); + } + + @AfterEach + void tearDown() { + PaidChangeService.configure(List.of(), List.of()); + } + + @Test + void pricesRiseThenRepeatTheLastCost() { + assertEquals(100.0, CLASS_RULE.costAfter(0)); + assertEquals(1000.0, CLASS_RULE.costAfter(1)); + assertEquals(3000.0, CLASS_RULE.costAfter(2)); + assertEquals(3000.0, CLASS_RULE.costAfter(7)); + } + + @Test + void chargesThePouchFirstThenTheBank() { + wallet.set(Account.POUCH, 150); + wallet.set(Account.BANK, 5000); + RPCharacter character = characterWithClass("WARRIOR"); + + ChargeResult first = PaidChangeService.charge(PLAYER, CLASS_RULE, character); + assertEquals(ChargeStatus.PAID, first.status()); + assertEquals(Account.POUCH, first.pending().account()); + assertEquals(50.0, wallet.get(Account.POUCH)); + + character.setPaidChangeCount("class", 1); + ChargeResult second = PaidChangeService.charge(PLAYER, CLASS_RULE, character); + assertEquals(Account.BANK, second.pending().account()); + assertEquals(1000.0, second.pending().amount()); + assertEquals(4000.0, wallet.get(Account.BANK)); + } + + @Test + void refusesWhenNoAccountCoversTheCost() { + wallet.set(Account.POUCH, 60); + wallet.set(Account.BANK, 60); + + ChargeResult result = PaidChangeService.charge(PLAYER, CLASS_RULE, characterWithClass("WARRIOR")); + assertEquals(ChargeStatus.INSUFFICIENT_FUNDS, result.status()); + assertEquals(100.0, result.cost()); + assertEquals(60.0, wallet.get(Account.POUCH)); + assertEquals(60.0, wallet.get(Account.BANK)); + } + + @Test + void unavailableWithoutDenarEconomy() { + wallet.available = false; + wallet.set(Account.POUCH, 500); + assertFalse(PaidChangeService.canPayToOpen(stage("class_selection_stage", 5 * DAY_MS))); + assertEquals(ChargeStatus.UNAVAILABLE, + PaidChangeService.charge(PLAYER, CLASS_RULE, characterWithClass("WARRIOR")).status()); + } + + @Test + void backingOutWithoutAChangeRefunds() { + wallet.set(Account.POUCH, 100); + RPCharacter character = characterWithClass("WARRIOR"); + ChargeResult result = PaidChangeService.charge(PLAYER, CLASS_RULE, character); + assertEquals(0.0, wallet.get(Account.POUCH)); + + assertFalse(PaidChangeService.resolve(result.pending(), character)); + assertEquals(100.0, wallet.get(Account.POUCH)); + assertEquals(0, character.getPaidChangeCount("class")); + } + + @Test + void keepingTheChangeKeepsThePaymentAndRaisesTheNextPrice() { + wallet.set(Account.BANK, 100); + RPCharacter character = characterWithClass("WARRIOR"); + ChargeResult result = PaidChangeService.charge(PLAYER, CLASS_RULE, character); + + character.setMMOClass("mage"); + assertTrue(PaidChangeService.resolve(result.pending(), character)); + assertEquals(0.0, wallet.get(Account.BANK)); + assertEquals(1, character.getPaidChangeCount("class")); + assertEquals(1000.0, CLASS_RULE.costAfter(character.getPaidChangeCount("class"))); + } + + @Test + void loreShowsTheFreeWindowThenTheNextTwoPrices() { + RPCharacter character = characterWithClass("WARRIOR"); + character.setCreatedAtEpochSeconds((int) Instant.now().getEpochSecond() - 3600); + + List lore = PaidChangeService.summaryLore(stage("class_selection_stage", 5 * DAY_MS), character, false); + String joined = String.join("\n", lore); + assertTrue(joined.contains("Free to change for: "), joined); + assertTrue(joined.contains("4d 23h"), joined); + assertTrue(joined.contains("Then: §e100 denars"), joined); + assertTrue(joined.contains("next: §e1,000 denars"), joined); + } + + @Test + void lockedLoreShowsThePriceNowAndTheOneAfter() { + RPCharacter character = characterWithClass("WARRIOR"); + character.setCreatedAtEpochSeconds((int) (Instant.now().getEpochSecond() - 6 * 86_400L)); + Stage stage = stage("class_selection_stage", 5 * DAY_MS); + + String first = String.join("\n", PaidChangeService.summaryLore(stage, character, true)); + assertTrue(first.contains("Click to change for 100 denars"), first); + assertTrue(first.contains("change after costs §e1,000 denars"), first); + + character.setPaidChangeCount("class", 1); + String second = String.join("\n", PaidChangeService.summaryLore(stage, character, true)); + assertTrue(second.contains("Click to change for 1,000 denars"), second); + assertTrue(second.contains("change after costs §e3,000 denars"), second); + + character.setPaidChangeCount("class", 2); + String third = String.join("\n", PaidChangeService.summaryLore(stage, character, true)); + assertTrue(third.contains("Click to change for 3,000 denars"), third); + assertTrue(third.contains("change after costs §e3,000 denars"), third); + } + + @Test + void stagesWithoutARuleKeepTheirDefaultLore() { + assertNull(PaidChangeService.summaryLore(stage("race_selection_stage", DAY_MS), characterWithClass("WARRIOR"), true)); + assertFalse(PaidChangeService.canPayToOpen(stage("race_selection_stage", DAY_MS))); + assertTrue(PaidChangeService.canPayToOpen(stage("class_selection_stage", 5 * DAY_MS))); + } + + @Test + void configReadsRulesAndAccounts() { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("paid-changes.accounts", List.of("bank")); + yaml.set("paid-changes.rules.race.stage", "race_selection_stage"); + yaml.set("paid-changes.rules.race.costs", List.of(50, 75)); + PaidChangeConfig.load(yaml.getConfigurationSection("paid-changes")); + + assertFalse(PaidChangeService.canPayToOpen(stage("class_selection_stage", 5 * DAY_MS))); + PaidChangeRule race = PaidChangeService.ruleFor(stage("race_selection_stage", DAY_MS)); + assertEquals("race", race.getLabel()); + assertEquals(75.0, race.costAfter(3)); + + wallet.set(Account.POUCH, 1000); + wallet.set(Account.BANK, 50); + ChargeResult result = PaidChangeService.charge(PLAYER, race, characterWithClass("WARRIOR")); + assertEquals(Account.BANK, result.pending().account()); + } + + @Test + void paidChangeCountsSurviveASaveAndLoad() throws Exception { + RPCharacter character = new RPCharacter(null); + character.setPaidChangeCount("class", 2); + HashMap saved = new HashMap<>(); + CharacterPaidChangeFields.save(saved, character); + JSONObject reparsed = (JSONObject) new JSONParser().parse(new JSONObject(saved).toJSONString()); + + RPCharacter loaded = new RPCharacter(null); + CharacterPaidChangeFields.load(loaded, reparsed); + assertEquals(2, loaded.getPaidChangeCount("class")); + + HashMap clean = new HashMap<>(); + CharacterPaidChangeFields.save(clean, new RPCharacter(null)); + assertTrue(clean.isEmpty()); + } + + @Test + void denarsFormatWithSeparators() { + assertEquals("100 denars", PaidChangeService.formatDenars(100)); + assertEquals("3,000 denars", PaidChangeService.formatDenars(3000)); + assertEquals("1 denar", PaidChangeService.formatDenars(1)); + assertEquals("2.50 denars", PaidChangeService.formatDenars(2.5)); + } + + private static RPCharacter characterWithClass(String classId) { + RPCharacter character = new RPCharacter(null); + character.setMMOClass(classId); + return character; + } + + private static Stage stage(String id, long lockMs) { + Stage stage = new Stage(); + stage.setId(id); + stage.setLockTimeMs(lockMs); + return stage; + } + + private static final class FakeWallet implements DenarWallet { + private final Map balances = new EnumMap<>(Account.class); + boolean available = true; + + void set(Account account, double amount) { + balances.put(account, amount); + } + + double get(Account account) { + return balances.getOrDefault(account, 0.0); + } + + @Override + public boolean available() { + return available; + } + + @Override + public double balance(UUID playerId, Account account) { + return get(account); + } + + @Override + public boolean withdraw(UUID playerId, Account account, double amount) { + if (get(account) < amount) { + return false; + } + balances.put(account, get(account) - amount); + return true; + } + + @Override + public boolean deposit(UUID playerId, Account account, double amount) { + balances.put(account, get(account) + amount); + return true; + } + } +} From 75ee9624b7990573a402b44b9a7dd9c0a632fcec Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:12:37 +0000 Subject: [PATCH 2/5] feat: reset a stage's lock window when its revision goes up Stages take a manual `revision` in stages.yml. When staff raise it after a large change, each character gets a fresh lock-time window on that stage from when it next loads (or at once on reload if online), and paid-change prices start over. New characters start on the current revisions. Also: - Reach DenarEconomy by reflection instead of softdepend. The softdepend made a load cycle (DenarEconomy -> TLibs -> ItemsAdder -> BirdMessenger -> RPCharacters) that stopped the server from starting. - Settle held payments when DenarEconomy disables, since it shuts down before RPCharacters. - Drop edit sessions on quit so the next /rpcharacter edit isn't blocked. Co-Authored-By: Claude Opus 5.5 (1M context) --- pom.xml | 13 ---- .../rpcharacters/RPCharacters.java | 29 +++++-- .../creation/CharacterCreation.java | 2 + .../rpcharacters/creation/Stage.java | 13 ++++ .../rpcharacters/creation/StageEditLock.java | 4 +- .../rpcharacters/creation/StageRevisions.java | 72 +++++++++++++++++ .../database/CharacterPaidChangeFields.java | 34 -------- .../database/CharacterStageChangeFields.java | 59 ++++++++++++++ .../rpcharacters/database/Database.java | 7 +- .../ingest/CharacterIngestService.java | 2 + .../rpcharacters/managers/PlayerManager.java | 7 +- .../rpcharacters/objects/RPCharacter.java | 30 +++++++- .../paidchange/DenarEconomyWallet.java | 77 ++++++++++++++++--- .../paidchange/PaidChangeListener.java | 30 ++++++++ .../paidchange/PaidChangeRule.java | 4 +- .../paidchange/PaidChangeService.java | 10 +-- src/main/resources/plugin.yml | 2 +- src/main/resources/stages.yml | 3 + .../paidchange/PaidChangeServiceTest.java | 71 +++++++++++++---- 19 files changed, 379 insertions(+), 90 deletions(-) create mode 100644 src/main/java/net/tfminecraft/rpcharacters/creation/StageRevisions.java delete mode 100644 src/main/java/net/tfminecraft/rpcharacters/database/CharacterPaidChangeFields.java create mode 100644 src/main/java/net/tfminecraft/rpcharacters/database/CharacterStageChangeFields.java create mode 100644 src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeListener.java diff --git a/pom.xml b/pom.xml index 7642a11..157cbe3 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,6 @@ 21 UTF-8 - 0.2.7 3.0.1 2.0.0 7.0.9 @@ -116,18 +115,6 @@ ${worldguard.version} provided - - net.tfminecraft - denareconomy - ${denareconomy.version} - provided - - - * - * - - - net.tfminecraft simplefactions diff --git a/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java b/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java index ee4ccc8..56a7329 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java +++ b/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java @@ -44,9 +44,8 @@ import net.tfminecraft.rpcharacters.loaders.PvpLoader; import net.tfminecraft.rpcharacters.loaders.PartyLoader; import net.tfminecraft.rpcharacters.managers.CommandManager; -import net.tfminecraft.rpcharacters.creation.CharacterCreation; import net.tfminecraft.rpcharacters.managers.CreationManager; -import net.tfminecraft.rpcharacters.paidchange.PaidChangeService; +import net.tfminecraft.rpcharacters.paidchange.PaidChangeListener; import net.tfminecraft.rpcharacters.managers.ClueDisturbanceListener; import net.tfminecraft.rpcharacters.managers.MagnifyingGlassListener; import net.tfminecraft.rpcharacters.managers.PlaceClueManager; @@ -293,9 +292,7 @@ public void onDisable() { PvpStrikeService.shutdown(); LastSolidTracker.get().shutdown(); GraveManager.get().saveAll(); - for (CharacterCreation cc : new java.util.ArrayList<>(CreationManager.activeCreators.values())) { - PaidChangeService.settle(cc); - } + PaidChangeListener.settleAll(); save(); } @@ -315,6 +312,7 @@ public void registerListeners() { getServer().getPluginManager().registerEvents(playerManager, this); getServer().getPluginManager().registerEvents(new net.tfminecraft.rpcharacters.mmocore.MmoCorePlayerReady(), this); getServer().getPluginManager().registerEvents(creationManager, this); + getServer().getPluginManager().registerEvents(new PaidChangeListener(), this); getServer().getPluginManager().registerEvents(clueInputManager, this); getServer().getPluginManager().registerEvents(placeClueManager, this); getServer().getPluginManager().registerEvents(magnifyingGlassListener, this); @@ -537,6 +535,7 @@ public void reload() { private boolean reloadWithFocusStatus() { loadConfigs(); + refreshStageRevisionsOnline(); boolean focusReloaded = reloadFocusConfig(); if (!focusReloaded) { getLogger().warning("Focus did not reload; see the preceding focus error. " @@ -549,7 +548,25 @@ private boolean reloadWithFocusStatus() { return focusReloaded; } - public void reloadConfigs(CommandSender sender) { + /** A raised stage revision opens a fresh lock window for online characters straight away. */ + private void refreshStageRevisionsOnline() { + for (Player p : Bukkit.getOnlinePlayers()) { + net.tfminecraft.rpcharacters.objects.PlayerData pd = PlayerManager.get(p); + if (pd == null) { + continue; + } + boolean changed = false; + for (net.tfminecraft.rpcharacters.objects.RPCharacter c : pd.getCharacters()) { + changed |= net.tfminecraft.rpcharacters.creation.StageRevisions.refresh( + c, net.tfminecraft.rpcharacters.loaders.StageLoader.oList); + } + if (changed) { + playerManager.savePlayer(p); + } + } + } + + public void reloadConfigs(CommandSender sender) { String name = sender != null ? sender.getName() : "unknown"; getLogger().info("Config reload requested by " + name); if (sender != null) { diff --git a/src/main/java/net/tfminecraft/rpcharacters/creation/CharacterCreation.java b/src/main/java/net/tfminecraft/rpcharacters/creation/CharacterCreation.java index 8fd49d7..46d278c 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/creation/CharacterCreation.java +++ b/src/main/java/net/tfminecraft/rpcharacters/creation/CharacterCreation.java @@ -856,6 +856,8 @@ public void finish() { character.update(); + StageRevisions.stampCurrent(character, StageLoader.oList); + pd.addCharacter(character); net.tfminecraft.rpcharacters.lifecycle.CharacterLifecycle.fireCreated(p, pd.getUniqueId(), character); diff --git a/src/main/java/net/tfminecraft/rpcharacters/creation/Stage.java b/src/main/java/net/tfminecraft/rpcharacters/creation/Stage.java index 153b7df..3ad58d1 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/creation/Stage.java +++ b/src/main/java/net/tfminecraft/rpcharacters/creation/Stage.java @@ -27,6 +27,9 @@ public class Stage { private long lockTimeMs = -1L; + /** Raised by hand after a large change; gives every character a fresh lock window. */ + private int revision; + /** Inclusive minimum account-age hours, or null if unset. */ private Integer requireAccountAgeHoursMin; /** Exclusive maximum account-age hours, or null if unset. */ @@ -35,6 +38,14 @@ public class Stage { /** Creation client: both (default), web-only, or game-only. */ private String platform = "both"; + public int getRevision() { + return revision; + } + + public void setRevision(int revision) { + this.revision = Math.max(0, revision); + } + public long getLockTimeMs() { return lockTimeMs; } @@ -114,6 +125,7 @@ protected void copyBaseFields(Stage source) { setDependency(null); } setLockTimeMs(source.getLockTimeMs()); + setRevision(source.getRevision()); setRequireAccountAgeHoursMin(source.getRequireAccountAgeHoursMin()); setRequireAccountAgeHoursMax(source.getRequireAccountAgeHoursMax()); setPlatform(source.getPlatform()); @@ -172,6 +184,7 @@ public static Stage create(String id, ConfigurationSection config) { s.setDependency(new Dependency(config.getConfigurationSection("dependency"))); } s.setLockTimeMs(DurationParser.parseLockTimeMs(config.getString("lock-time", "-1"))); + s.setRevision(config.getInt("revision", 0)); if (config.contains("require-account-age-hours-min")) { s.setRequireAccountAgeHoursMin(config.getInt("require-account-age-hours-min")); } diff --git a/src/main/java/net/tfminecraft/rpcharacters/creation/StageEditLock.java b/src/main/java/net/tfminecraft/rpcharacters/creation/StageEditLock.java index 0e985c9..84e3eb8 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/creation/StageEditLock.java +++ b/src/main/java/net/tfminecraft/rpcharacters/creation/StageEditLock.java @@ -28,7 +28,7 @@ public static boolean canEdit(Stage stage, RPCharacter character) { if (lockMs < 0) { return true; } - return character.getAgeSeconds() * 1000L < lockMs; + return StageRevisions.secondsIntoWindow(stage, character) * 1000L < lockMs; } public static long lockRemainingMs(Stage stage, RPCharacter character) { @@ -39,7 +39,7 @@ public static long lockRemainingMs(Stage stage, RPCharacter character) { if (lockMs < 0) { return 0L; } - long remaining = lockMs - (character.getAgeSeconds() * 1000L); + long remaining = lockMs - (StageRevisions.secondsIntoWindow(stage, character) * 1000L); return Math.max(0L, remaining); } diff --git a/src/main/java/net/tfminecraft/rpcharacters/creation/StageRevisions.java b/src/main/java/net/tfminecraft/rpcharacters/creation/StageRevisions.java new file mode 100644 index 0000000..15a2688 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/creation/StageRevisions.java @@ -0,0 +1,72 @@ +package net.tfminecraft.rpcharacters.creation; + +import java.time.Instant; +import java.util.Collection; +import java.util.Locale; + +import net.tfminecraft.rpcharacters.objects.RPCharacter; + +/** + * Staff raise a stage's {@code revision} in stages.yml after a large change to it. Each character + * then gets a fresh lock window on that stage, starting when it next loads, and its paid-change + * prices start over. + */ +public final class StageRevisions { + + private StageRevisions() {} + + /** Starts a fresh window on every stage whose revision passed the character's. True if any did. */ + public static boolean refresh(RPCharacter character, Collection stages, long nowEpochSeconds) { + if (character == null || stages == null) { + return false; + } + boolean changed = false; + for (Stage stage : stages) { + if (stage == null || stage.getId() == null) { + continue; + } + String id = key(stage); + if (stage.getRevision() > character.getStageRevision(id)) { + character.setStageRevision(id, stage.getRevision(), nowEpochSeconds); + character.setPaidChangeCount(id, 0); + changed = true; + } + } + return changed; + } + + public static boolean refresh(RPCharacter character, Collection stages) { + return refresh(character, stages, Instant.now().getEpochSecond()); + } + + /** New characters start on the current revisions, with the window counted from creation. */ + public static void stampCurrent(RPCharacter character, Collection stages) { + if (character == null || stages == null) { + return; + } + for (Stage stage : stages) { + if (stage != null && stage.getId() != null && stage.getRevision() > 0) { + character.setStageRevision(key(stage), stage.getRevision(), 0L); + } + } + } + + /** Seconds since this stage's lock window opened: creation, or the latest revision reset. */ + public static long secondsIntoWindow(Stage stage, RPCharacter character, long nowEpochSeconds) { + int createdAt = character.getCreatedAtEpochSeconds(); + if (createdAt <= 0) { + return 0L; + } + long start = Math.max(createdAt, character.getStageRevisionSince(key(stage))); + return Math.max(0L, nowEpochSeconds - start); + } + + /** Stage ids are stored lower case, the same key paid-change counts use. */ + public static String key(Stage stage) { + return stage.getId() == null ? null : stage.getId().toLowerCase(Locale.ROOT); + } + + public static long secondsIntoWindow(Stage stage, RPCharacter character) { + return secondsIntoWindow(stage, character, Instant.now().getEpochSecond()); + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/database/CharacterPaidChangeFields.java b/src/main/java/net/tfminecraft/rpcharacters/database/CharacterPaidChangeFields.java deleted file mode 100644 index 3d01744..0000000 --- a/src/main/java/net/tfminecraft/rpcharacters/database/CharacterPaidChangeFields.java +++ /dev/null @@ -1,34 +0,0 @@ -package net.tfminecraft.rpcharacters.database; - -import java.util.Map; - -import org.json.simple.JSONObject; - -import net.tfminecraft.rpcharacters.objects.RPCharacter; - -/** How many paid changes each rule has had, stored under "paid-changes" in the character file. */ -public final class CharacterPaidChangeFields { - private CharacterPaidChangeFields() {} - - public static void load(RPCharacter character, JSONObject characterJson) { - if (character == null || characterJson == null - || !(characterJson.get("paid-changes") instanceof Map counts)) { - return; - } - for (Map.Entry entry : counts.entrySet()) { - if (entry.getKey() instanceof String ruleId && entry.getValue() instanceof Number count) { - character.setPaidChangeCount(ruleId, count.intValue()); - } - } - } - - @SuppressWarnings("unchecked") - public static void save(Map defaults, RPCharacter character) { - if (character.getPaidChangeCounts().isEmpty()) { - return; - } - JSONObject counts = new JSONObject(); - counts.putAll(character.getPaidChangeCounts()); - defaults.put("paid-changes", counts); - } -} diff --git a/src/main/java/net/tfminecraft/rpcharacters/database/CharacterStageChangeFields.java b/src/main/java/net/tfminecraft/rpcharacters/database/CharacterStageChangeFields.java new file mode 100644 index 0000000..9fc03b7 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/database/CharacterStageChangeFields.java @@ -0,0 +1,59 @@ +package net.tfminecraft.rpcharacters.database; + +import java.util.Map; + +import org.json.simple.JSONObject; + +import net.tfminecraft.rpcharacters.objects.RPCharacter; + +/** + * Per-stage change state in the character file: paid change counts under "paid-changes" and the + * stage revision each lock window was opened for under "stage-revisions". Both are keyed by stage id. + */ +public final class CharacterStageChangeFields { + private CharacterStageChangeFields() {} + + public static void load(RPCharacter character, JSONObject characterJson) { + if (character == null || characterJson == null) { + return; + } + if (characterJson.get("paid-changes") instanceof Map counts) { + for (Map.Entry entry : counts.entrySet()) { + if (entry.getKey() instanceof String stageId && entry.getValue() instanceof Number count) { + character.setPaidChangeCount(stageId, count.intValue()); + } + } + } + if (characterJson.get("stage-revisions") instanceof Map revisions) { + for (Map.Entry entry : revisions.entrySet()) { + if (entry.getKey() instanceof String stageId && entry.getValue() instanceof Map mark + && mark.get("revision") instanceof Number revision) { + long since = mark.get("since") instanceof Number n ? n.longValue() : 0L; + character.setStageRevision(stageId, revision.intValue(), since); + } + } + } + } + + @SuppressWarnings("unchecked") + public static void save(Map defaults, RPCharacter character) { + if (!character.getPaidChangeCounts().isEmpty()) { + JSONObject counts = new JSONObject(); + counts.putAll(character.getPaidChangeCounts()); + defaults.put("paid-changes", counts); + } + if (!character.getStageRevisions().isEmpty()) { + JSONObject revisions = new JSONObject(); + for (Map.Entry entry : character.getStageRevisions().entrySet()) { + JSONObject mark = new JSONObject(); + mark.put("revision", entry.getValue()); + long since = character.getStageRevisionSince(entry.getKey()); + if (since > 0L) { + mark.put("since", since); + } + revisions.put(entry.getKey(), mark); + } + defaults.put("stage-revisions", revisions); + } + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/database/Database.java b/src/main/java/net/tfminecraft/rpcharacters/database/Database.java index 0f06695..cf037d0 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/database/Database.java +++ b/src/main/java/net/tfminecraft/rpcharacters/database/Database.java @@ -17,7 +17,9 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import net.tfminecraft.rpcharacters.creation.StageRevisions; import net.tfminecraft.rpcharacters.loaders.RaceLoader; +import net.tfminecraft.rpcharacters.loaders.StageLoader; import net.tfminecraft.rpcharacters.loaders.TraitLoader; import net.tfminecraft.rpcharacters.objects.PlayerData; import net.tfminecraft.rpcharacters.objects.RPCharacter; @@ -312,7 +314,8 @@ public void loadCharacters(PlayerData pd) { loadLastLocation(c, json); loadPvpLethal(c, json); CharacterEvilRpFields.load(c, json); - CharacterPaidChangeFields.load(c, json); + CharacterStageChangeFields.load(c, json); + StageRevisions.refresh(c, StageLoader.oList); loadNutritionFields(c, json); c.ensureTraitStateDefaults(); if (!Boolean.TRUE.equals(c.isActive()) && c.removeExpiredDurationTraits(System.currentTimeMillis())) { @@ -462,7 +465,7 @@ public void saveCharacter(PlayerData pd, RPCharacter c) { saveLastLocation(defaults, c); defaults.put("pvp-lethal", String.valueOf(c.isPvpLethal())); CharacterEvilRpFields.save(defaults, c); - CharacterPaidChangeFields.save(defaults, c); + CharacterStageChangeFields.save(defaults, c); saveNutritionFields(defaults, c); if (save(file, defaults)) { net.tfminecraft.rpcharacters.playtime.CharacterPlaytimeDirectory.upsert(pd.getUniqueId(), c); diff --git a/src/main/java/net/tfminecraft/rpcharacters/ingest/CharacterIngestService.java b/src/main/java/net/tfminecraft/rpcharacters/ingest/CharacterIngestService.java index 1abf096..df77b59 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/ingest/CharacterIngestService.java +++ b/src/main/java/net/tfminecraft/rpcharacters/ingest/CharacterIngestService.java @@ -355,6 +355,8 @@ private static ApplyOutcome applyPayload(UUID playerUuid, String createId, JSONO pd.setEighteen((Boolean) eighteenRaw); } + net.tfminecraft.rpcharacters.creation.StageRevisions.stampCurrent( + character, net.tfminecraft.rpcharacters.loaders.StageLoader.oList); pd.addCharacter(character); net.tfminecraft.rpcharacters.lifecycle.CharacterLifecycle.fireCreated(online, pd.getUniqueId(), character); diff --git a/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java b/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java index b63c3ff..6408cf7 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java +++ b/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java @@ -337,7 +337,12 @@ public void onLeave(PlayerQuitEvent e) { net.tfminecraft.rpcharacters.clues.discovery.ClueAdminModeService.clear(p); TempAliasService.clear(p); MmoCorePlayerReady.cancel(p.getUniqueId()); - net.tfminecraft.rpcharacters.paidchange.PaidChangeService.settle(CreationManager.activeCreators.get(p)); + CharacterCreation session = CreationManager.activeCreators.get(p); + net.tfminecraft.rpcharacters.paidchange.PaidChangeService.settle(session); + if (session != null && session.isEditing()) { + // Edits apply as they are made, so nothing is lost; a kept session would block the next /rpcharacter edit. + CreationManager.activeCreators.remove(p, session); + } PlayerData pd = get(p); if (pd != null && pd.hasActiveCharacter()) { // Prevent MMOCore from persisting stacked attribute bases for the next login. diff --git a/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java b/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java index 9e6156b..a015ea1 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java +++ b/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java @@ -99,8 +99,12 @@ public class RPCharacter { private long evilRpSessionEndsAtMs; /** When the latest strike landed, so strikes can wear off. 0 when unknown. */ private long lastStrikeAtMs; - /** Paid changes made so far per paid-change rule, keyed by rule id. Sets the next price. */ + /** Paid changes made so far, keyed by stage id. Sets the next price. */ private final Map paidChangeCounts = new HashMap<>(); + /** Stage revision each stage's lock window was last opened for, keyed by stage id. */ + private final Map stageRevisions = new HashMap<>(); + /** Epoch seconds a revision reset opened each stage's window; 0 counts from creation. */ + private final Map stageRevisionSince = new HashMap<>(); public static final int MAX_FOOD_VALUE = 200; private static final int MAX_DIET_SCORE = 40; @@ -226,6 +230,30 @@ public Map getPaidChangeCounts() { return Collections.unmodifiableMap(paidChangeCounts); } + public int getStageRevision(String stageId) { + return stageId == null ? 0 : stageRevisions.getOrDefault(stageId, 0); + } + + public long getStageRevisionSince(String stageId) { + return stageId == null ? 0L : stageRevisionSince.getOrDefault(stageId, 0L); + } + + public void setStageRevision(String stageId, int revision, long sinceEpochSeconds) { + if (stageId == null) { + return; + } + stageRevisions.put(stageId, revision); + if (sinceEpochSeconds > 0L) { + stageRevisionSince.put(stageId, sinceEpochSeconds); + } else { + stageRevisionSince.remove(stageId); + } + } + + public Map getStageRevisions() { + return Collections.unmodifiableMap(stageRevisions); + } + public int getEvilRpStrikes() { return evilRpStrikes; } diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWallet.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWallet.java index a980a6b..6024997 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWallet.java +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWallet.java @@ -1,36 +1,93 @@ package net.tfminecraft.rpcharacters.paidchange; +import java.lang.reflect.Method; import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; import org.bukkit.Bukkit; +import org.bukkit.plugin.Plugin; -import net.tfminecraft.denareconomy.accounts.OfflineModifier; -import net.tfminecraft.denareconomy.enums.Accounts; - -/** DenarEconomy accounts. DenarEconomy is a soft dependency, so check {@link #available()} first. */ +/** + * DenarEconomy accounts, reached through DenarEconomy's own class loader. RPCharacters can't + * declare DenarEconomy as a dependency: DenarEconomy already loads after RPCharacters through + * TLibs, ItemsAdder and BirdMessenger, so either depend would make a load cycle. + */ final class DenarEconomyWallet implements DenarWallet { + private static final Logger LOG = Logger.getLogger("RPCharacters"); + + private Plugin boundTo; + private Method balance; + private Method apply; + private Class accounts; @Override public boolean available() { - return Bukkit.getPluginManager().isPluginEnabled("DenarEconomy"); + return bind(); } @Override public double balance(UUID playerId, Account account) { - return OfflineModifier.balance(playerId, toDenar(account)); + if (!bind()) { + return 0.0; + } + try { + return ((Number) balance.invoke(null, playerId, toDenar(account))).doubleValue(); + } catch (ReflectiveOperationException | RuntimeException e) { + LOG.log(Level.WARNING, "[RPCharacters] Could not read a DenarEconomy balance", e); + return 0.0; + } } @Override public boolean withdraw(UUID playerId, Account account, double amount) { - return amount > 0.0 && OfflineModifier.apply(playerId, toDenar(account), -amount); + return amount > 0.0 && apply(playerId, account, -amount); } @Override public boolean deposit(UUID playerId, Account account, double amount) { - return amount > 0.0 && OfflineModifier.apply(playerId, toDenar(account), amount); + return amount > 0.0 && apply(playerId, account, amount); + } + + /** OfflineModifier.apply refuses a withdrawal the account can't cover. */ + private boolean apply(UUID playerId, Account account, double amount) { + if (!bind()) { + return false; + } + try { + return Boolean.TRUE.equals(apply.invoke(null, playerId, toDenar(account), amount)); + } catch (ReflectiveOperationException | RuntimeException e) { + LOG.log(Level.WARNING, "[RPCharacters] Could not change a DenarEconomy balance", e); + return false; + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private Object toDenar(Account account) { + return Enum.valueOf((Class) accounts, account == Account.BANK ? "BANK" : "POUCH"); } - private static Accounts toDenar(Account account) { - return account == Account.BANK ? Accounts.BANK : Accounts.POUCH; + /** Looks the API up again whenever DenarEconomy is (re)loaded. */ + @SuppressWarnings("unchecked") + private synchronized boolean bind() { + Plugin plugin = Bukkit.getPluginManager().getPlugin("DenarEconomy"); + if (plugin == null || !plugin.isEnabled()) { + return false; + } + if (plugin == boundTo) { + return true; + } + try { + ClassLoader loader = plugin.getClass().getClassLoader(); + Class modifier = Class.forName("net.tfminecraft.denareconomy.accounts.OfflineModifier", true, loader); + accounts = (Class) Class.forName("net.tfminecraft.denareconomy.enums.Accounts", true, loader); + balance = modifier.getMethod("balance", UUID.class, accounts); + apply = modifier.getMethod("apply", UUID.class, accounts, double.class); + boundTo = plugin; + return true; + } catch (ReflectiveOperationException | RuntimeException e) { + LOG.log(Level.WARNING, "[RPCharacters] DenarEconomy has no OfflineModifier API; paid changes are off", e); + return false; + } } } diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeListener.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeListener.java new file mode 100644 index 0000000..7535bf2 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeListener.java @@ -0,0 +1,30 @@ +package net.tfminecraft.rpcharacters.paidchange; + +import java.util.ArrayList; + +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.server.PluginDisableEvent; + +import net.tfminecraft.rpcharacters.creation.CharacterCreation; +import net.tfminecraft.rpcharacters.managers.CreationManager; + +/** + * DenarEconomy disables before RPCharacters on shutdown, so held payments are settled here, + * while its accounts can still take a refund. + */ +public final class PaidChangeListener implements Listener { + + @EventHandler + public void onPluginDisable(PluginDisableEvent event) { + if ("DenarEconomy".equals(event.getPlugin().getName())) { + settleAll(); + } + } + + public static void settleAll() { + for (CharacterCreation cc : new ArrayList<>(CreationManager.activeCreators.values())) { + PaidChangeService.settle(cc); + } + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeRule.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeRule.java index d0967b3..84b7beb 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeRule.java +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeRule.java @@ -14,16 +14,16 @@ public final class PaidChangeRule { public PaidChangeRule(String id, String stageId, String label, List costs) { this.id = id; - this.stageId = stageId; + this.stageId = stageId.toLowerCase(java.util.Locale.ROOT); this.label = label == null || label.isBlank() ? id : label; this.costs = List.copyOf(costs); } - /** Key for the per-character paid change count. */ public String getId() { return id; } + /** Also the key for the per-character paid change count. */ public String getStageId() { return stageId; } diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java index bfc7ef2..e828913 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java @@ -77,7 +77,7 @@ public static boolean canPayToOpen(Stage stage) { /** Takes the next price from the first account that covers it. */ public static ChargeResult charge(UUID payerId, PaidChangeRule rule, RPCharacter character) { - double cost = rule.costAfter(character.getPaidChangeCount(rule.getId())); + double cost = rule.costAfter(character.getPaidChangeCount(rule.getStageId())); String before = snapshot(character); if (cost <= 0.0) { return new ChargeResult(ChargeStatus.PAID, new Pending(rule, payerId, null, 0.0, before), 0.0); @@ -99,8 +99,8 @@ public static ChargeResult charge(UUID payerId, PaidChangeRule rule, RPCharacter */ public static boolean resolve(Pending pending, RPCharacter character) { if (character != null && !snapshot(character).equals(pending.before())) { - String ruleId = pending.rule().getId(); - character.setPaidChangeCount(ruleId, character.getPaidChangeCount(ruleId) + 1); + String stageId = pending.rule().getStageId(); + character.setPaidChangeCount(stageId, character.getPaidChangeCount(stageId) + 1); return true; } if (pending.account() != null && pending.amount() > 0.0 @@ -160,7 +160,7 @@ public static void settle(CharacterCreation cc) { RPCharacter character = cc.getCharacter(); PaidChangeRule rule = pending.rule(); if (resolve(pending, character)) { - double next = rule.costAfter(character.getPaidChangeCount(rule.getId())); + double next = rule.costAfter(character.getPaidChangeCount(rule.getStageId())); tell(player, RPTexts.SUCCESS + "Your " + rule.getLabel() + " change is paid for. The next one costs " + formatDenars(next) + "."); if (player != null && RPCharacters.getPlayerManager() != null) { @@ -188,7 +188,7 @@ public static List summaryLore(Stage stage, RPCharacter character, boole if (rule == null || character == null) { return null; } - int paid = character.getPaidChangeCount(rule.getId()); + int paid = character.getPaidChangeCount(rule.getStageId()); String next = formatDenars(rule.costAfter(paid)); String after = formatDenars(rule.costAfter(paid + 1)); List lines = new ArrayList<>(); diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 5e5cb4c..0ca373e 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -5,7 +5,7 @@ version: ${project.version} author: Drefvelin depend: [TLibs, MMOCore, TFMCWeb, MMOItems, MythicLib] -softdepend: [ProtocolLib, PlaceholderAPI, LuckPerms, WorldGuard, SimpleFactions, GSit, DenarEconomy] +softdepend: [ProtocolLib, PlaceholderAPI, LuckPerms, WorldGuard, SimpleFactions, GSit] commands: focus: diff --git a/src/main/resources/stages.yml b/src/main/resources/stages.yml index 68ee3b2..7ff7fb7 100644 --- a/src/main/resources/stages.yml +++ b/src/main/resources/stages.yml @@ -97,6 +97,9 @@ class_info_stage: class_selection_stage: type: selection lock-time: 5d + # Raise by hand after a large change to this stage. Every character then gets a fresh + # lock-time window from when it next loads, and paid-change prices start over. + revision: 0 repeat: true target: class min-select: 1 diff --git a/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeServiceTest.java b/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeServiceTest.java index cca9249..80c098c 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeServiceTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeServiceTest.java @@ -20,7 +20,9 @@ import org.junit.jupiter.api.Test; import net.tfminecraft.rpcharacters.creation.Stage; -import net.tfminecraft.rpcharacters.database.CharacterPaidChangeFields; +import net.tfminecraft.rpcharacters.creation.StageEditLock; +import net.tfminecraft.rpcharacters.creation.StageRevisions; +import net.tfminecraft.rpcharacters.database.CharacterStageChangeFields; import net.tfminecraft.rpcharacters.objects.RPCharacter; import net.tfminecraft.rpcharacters.paidchange.DenarWallet.Account; import net.tfminecraft.rpcharacters.paidchange.PaidChangeService.ChargeResult; @@ -30,6 +32,7 @@ class PaidChangeServiceTest { private static final long DAY_MS = 86_400_000L; private static final UUID PLAYER = UUID.randomUUID(); + private static final String CLASS_STAGE = "class_selection_stage"; private static final PaidChangeRule CLASS_RULE = new PaidChangeRule("class", "class_selection_stage", "class", List.of(100.0, 1000.0, 3000.0)); @@ -65,7 +68,7 @@ void chargesThePouchFirstThenTheBank() { assertEquals(Account.POUCH, first.pending().account()); assertEquals(50.0, wallet.get(Account.POUCH)); - character.setPaidChangeCount("class", 1); + character.setPaidChangeCount(CLASS_STAGE, 1); ChargeResult second = PaidChangeService.charge(PLAYER, CLASS_RULE, character); assertEquals(Account.BANK, second.pending().account()); assertEquals(1000.0, second.pending().amount()); @@ -102,7 +105,7 @@ void backingOutWithoutAChangeRefunds() { assertFalse(PaidChangeService.resolve(result.pending(), character)); assertEquals(100.0, wallet.get(Account.POUCH)); - assertEquals(0, character.getPaidChangeCount("class")); + assertEquals(0, character.getPaidChangeCount(CLASS_STAGE)); } @Test @@ -114,8 +117,8 @@ void keepingTheChangeKeepsThePaymentAndRaisesTheNextPrice() { character.setMMOClass("mage"); assertTrue(PaidChangeService.resolve(result.pending(), character)); assertEquals(0.0, wallet.get(Account.BANK)); - assertEquals(1, character.getPaidChangeCount("class")); - assertEquals(1000.0, CLASS_RULE.costAfter(character.getPaidChangeCount("class"))); + assertEquals(1, character.getPaidChangeCount(CLASS_STAGE)); + assertEquals(1000.0, CLASS_RULE.costAfter(character.getPaidChangeCount(CLASS_STAGE))); } @Test @@ -141,12 +144,12 @@ void lockedLoreShowsThePriceNowAndTheOneAfter() { assertTrue(first.contains("Click to change for 100 denars"), first); assertTrue(first.contains("change after costs §e1,000 denars"), first); - character.setPaidChangeCount("class", 1); + character.setPaidChangeCount(CLASS_STAGE, 1); String second = String.join("\n", PaidChangeService.summaryLore(stage, character, true)); assertTrue(second.contains("Click to change for 1,000 denars"), second); assertTrue(second.contains("change after costs §e3,000 denars"), second); - character.setPaidChangeCount("class", 2); + character.setPaidChangeCount(CLASS_STAGE, 2); String third = String.join("\n", PaidChangeService.summaryLore(stage, character, true)); assertTrue(third.contains("Click to change for 3,000 denars"), third); assertTrue(third.contains("change after costs §e3,000 denars"), third); @@ -179,19 +182,61 @@ void configReadsRulesAndAccounts() { } @Test - void paidChangeCountsSurviveASaveAndLoad() throws Exception { + void raisingTheRevisionReopensTheWindowAndResetsPrices() { + long now = Instant.now().getEpochSecond(); + RPCharacter character = characterWithClass("WARRIOR"); + character.setCreatedAtEpochSeconds((int) (now - 30 * 86_400L)); + character.setPaidChangeCount(CLASS_STAGE, 2); + Stage stage = stage(CLASS_STAGE, 5 * DAY_MS); + + assertFalse(StageRevisions.refresh(character, List.of(stage), now)); + assertFalse(StageEditLock.canEdit(stage, character)); + + stage.setRevision(1); + assertTrue(StageRevisions.refresh(character, List.of(stage), now - 3600)); + assertTrue(StageEditLock.canEdit(stage, character)); + assertEquals(0, character.getPaidChangeCount(CLASS_STAGE)); + String lore = String.join("\n", PaidChangeService.summaryLore(stage, character, false)); + assertTrue(lore.contains("4d 23h") && lore.contains("Then: §e100 denars"), lore); + + // Same revision on the next load keeps the window where it started. + assertFalse(StageRevisions.refresh(character, List.of(stage), now + 10 * 86_400L)); + assertEquals(now - 3600, character.getStageRevisionSince(CLASS_STAGE)); + } + + @Test + void newCharactersStartOnTheCurrentRevision() { + long now = Instant.now().getEpochSecond(); + Stage stage = stage(CLASS_STAGE, 5 * DAY_MS); + stage.setRevision(3); + RPCharacter character = characterWithClass("WARRIOR"); + character.setCreatedAtEpochSeconds((int) (now - 6 * 86_400L)); + + StageRevisions.stampCurrent(character, List.of(stage)); + assertFalse(StageRevisions.refresh(character, List.of(stage), now)); + assertFalse(StageEditLock.canEdit(stage, character)); + } + + @Test + void stageChangeStateSurvivesASaveAndLoad() throws Exception { RPCharacter character = new RPCharacter(null); - character.setPaidChangeCount("class", 2); + character.setPaidChangeCount(CLASS_STAGE, 2); + character.setStageRevision(CLASS_STAGE, 4, 1_790_000_000L); + character.setStageRevision("race_selection_stage", 1, 0L); HashMap saved = new HashMap<>(); - CharacterPaidChangeFields.save(saved, character); + CharacterStageChangeFields.save(saved, character); JSONObject reparsed = (JSONObject) new JSONParser().parse(new JSONObject(saved).toJSONString()); RPCharacter loaded = new RPCharacter(null); - CharacterPaidChangeFields.load(loaded, reparsed); - assertEquals(2, loaded.getPaidChangeCount("class")); + CharacterStageChangeFields.load(loaded, reparsed); + assertEquals(2, loaded.getPaidChangeCount(CLASS_STAGE)); + assertEquals(4, loaded.getStageRevision(CLASS_STAGE)); + assertEquals(1_790_000_000L, loaded.getStageRevisionSince(CLASS_STAGE)); + assertEquals(1, loaded.getStageRevision("race_selection_stage")); + assertEquals(0L, loaded.getStageRevisionSince("race_selection_stage")); HashMap clean = new HashMap<>(); - CharacterPaidChangeFields.save(clean, new RPCharacter(null)); + CharacterStageChangeFields.save(clean, new RPCharacter(null)); assertTrue(clean.isEmpty()); } From 7fda69529c75871105ce9bb18eaed660d5a58c8c Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:22:56 +0000 Subject: [PATCH 3/5] fix: save held paid-change payments and retry failed refunds The payment held while a paid stage is open now lives on the character and is saved as soon as the player pays, under "paid-change-pending". A failed refund stays held instead of being dropped, and the player is told it will be retried. When the owner next joins, any hold left by a crash or a failed refund is settled: kept and counted if the character changed, refunded otherwise. Stage revision refreshes also moved to join, so offline data loads can't start a window early. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../creation/CharacterCreation.java | 19 +-- .../database/CharacterStageChangeFields.java | 38 +++++- .../rpcharacters/database/Database.java | 3 - .../managers/InventoryManager.java | 4 +- .../rpcharacters/managers/PlayerManager.java | 13 +- .../rpcharacters/objects/RPCharacter.java | 10 ++ .../paidchange/PaidChangeService.java | 128 +++++++++++++----- .../paidchange/PendingPaidChange.java | 13 ++ .../paidchange/PaidChangeServiceTest.java | 56 +++++++- 9 files changed, 221 insertions(+), 63 deletions(-) create mode 100644 src/main/java/net/tfminecraft/rpcharacters/paidchange/PendingPaidChange.java diff --git a/src/main/java/net/tfminecraft/rpcharacters/creation/CharacterCreation.java b/src/main/java/net/tfminecraft/rpcharacters/creation/CharacterCreation.java index 46d278c..357a1d7 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/creation/CharacterCreation.java +++ b/src/main/java/net/tfminecraft/rpcharacters/creation/CharacterCreation.java @@ -48,6 +48,7 @@ import net.tfminecraft.rpcharacters.objects.RPCharacter; import net.tfminecraft.rpcharacters.paidchange.PaidChangeService; +import net.tfminecraft.rpcharacters.paidchange.PendingPaidChange; import net.tfminecraft.rpcharacters.objects.attributes.AttributeData; import net.tfminecraft.rpcharacters.objects.trait.Trait; @@ -95,8 +96,6 @@ public class CharacterCreation { private boolean editingFromSummary = false; private Stage editStage = null; - /** Denars held while a paid stage is open; settled when the player leaves that stage. */ - private PaidChangeService.Pending pendingPaidChange; private final String summaryStageId = "creation_summary_stage"; @@ -416,19 +415,9 @@ public Player getPlayer() { return p; } - public PaidChangeService.Pending getPendingPaidChange() { - return pendingPaidChange; - } - - public void setPendingPaidChange(PaidChangeService.Pending pending) { - this.pendingPaidChange = pending; - } - - /** Returns and clears the held payment so it is settled only once. */ - public PaidChangeService.Pending takePendingPaidChange() { - PaidChangeService.Pending pending = pendingPaidChange; - pendingPaidChange = null; - return pending; + /** Denars held while a paid stage is open; kept on the character so they are saved. */ + public PendingPaidChange getPendingPaidChange() { + return character == null ? null : character.getPendingPaidChange(); } diff --git a/src/main/java/net/tfminecraft/rpcharacters/database/CharacterStageChangeFields.java b/src/main/java/net/tfminecraft/rpcharacters/database/CharacterStageChangeFields.java index 9fc03b7..58e70bc 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/database/CharacterStageChangeFields.java +++ b/src/main/java/net/tfminecraft/rpcharacters/database/CharacterStageChangeFields.java @@ -1,14 +1,19 @@ package net.tfminecraft.rpcharacters.database; import java.util.Map; +import java.util.UUID; +import java.util.logging.Logger; import org.json.simple.JSONObject; import net.tfminecraft.rpcharacters.objects.RPCharacter; +import net.tfminecraft.rpcharacters.paidchange.DenarWallet.Account; +import net.tfminecraft.rpcharacters.paidchange.PendingPaidChange; /** - * Per-stage change state in the character file: paid change counts under "paid-changes" and the - * stage revision each lock window was opened for under "stage-revisions". Both are keyed by stage id. + * Per-stage change state in the character file: paid change counts under "paid-changes", the + * stage revision each lock window was opened for under "stage-revisions" (both keyed by stage id), + * and denars held for an open paid stage under "paid-change-pending". */ public final class CharacterStageChangeFields { private CharacterStageChangeFields() {} @@ -24,6 +29,9 @@ public static void load(RPCharacter character, JSONObject characterJson) { } } } + if (characterJson.get("paid-change-pending") instanceof Map pending) { + character.setPendingPaidChange(readPending(pending)); + } if (characterJson.get("stage-revisions") instanceof Map revisions) { for (Map.Entry entry : revisions.entrySet()) { if (entry.getKey() instanceof String stageId && entry.getValue() instanceof Map mark @@ -55,5 +63,31 @@ public static void save(Map defaults, RPCharacter character) { } defaults.put("stage-revisions", revisions); } + PendingPaidChange pending = character.getPendingPaidChange(); + if (pending != null) { + JSONObject held = new JSONObject(); + held.put("stage", pending.stageId()); + held.put("label", pending.label()); + held.put("payer", pending.payerId().toString()); + if (pending.account() != null) { + held.put("account", pending.account().name()); + } + held.put("amount", pending.amount()); + held.put("before", pending.before()); + defaults.put("paid-change-pending", held); + } + } + + private static PendingPaidChange readPending(Map held) { + try { + Account account = held.get("account") instanceof String name ? Account.valueOf(name) : null; + double amount = held.get("amount") instanceof Number n ? n.doubleValue() : 0.0; + return new PendingPaidChange((String) held.get("stage"), (String) held.get("label"), + UUID.fromString((String) held.get("payer")), account, amount, (String) held.get("before")); + } catch (RuntimeException e) { + Logger.getLogger("RPCharacters").warning("[RPCharacters] Unreadable paid-change-pending entry " + held + + "; refund it by hand if denars were taken."); + return null; + } } } diff --git a/src/main/java/net/tfminecraft/rpcharacters/database/Database.java b/src/main/java/net/tfminecraft/rpcharacters/database/Database.java index cf037d0..5357426 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/database/Database.java +++ b/src/main/java/net/tfminecraft/rpcharacters/database/Database.java @@ -17,9 +17,7 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import net.tfminecraft.rpcharacters.creation.StageRevisions; import net.tfminecraft.rpcharacters.loaders.RaceLoader; -import net.tfminecraft.rpcharacters.loaders.StageLoader; import net.tfminecraft.rpcharacters.loaders.TraitLoader; import net.tfminecraft.rpcharacters.objects.PlayerData; import net.tfminecraft.rpcharacters.objects.RPCharacter; @@ -315,7 +313,6 @@ public void loadCharacters(PlayerData pd) { loadPvpLethal(c, json); CharacterEvilRpFields.load(c, json); CharacterStageChangeFields.load(c, json); - StageRevisions.refresh(c, StageLoader.oList); loadNutritionFields(c, json); c.ensureTraitStateDefaults(); if (!Boolean.TRUE.equals(c.isActive()) && c.removeExpiredDurationTraits(System.currentTimeMillis())) { diff --git a/src/main/java/net/tfminecraft/rpcharacters/managers/InventoryManager.java b/src/main/java/net/tfminecraft/rpcharacters/managers/InventoryManager.java index 85f4ea1..fea1319 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/managers/InventoryManager.java +++ b/src/main/java/net/tfminecraft/rpcharacters/managers/InventoryManager.java @@ -909,10 +909,10 @@ public ItemStack createCancelItem(CharacterCreation cc) { ItemStack i = new ItemStack(Material.BARRIER, 1); ItemMeta meta = i.getItemMeta(); List lore = new ArrayList<>(); - PaidChangeService.Pending pending = cc != null ? cc.getPendingPaidChange() : null; + net.tfminecraft.rpcharacters.paidchange.PendingPaidChange pending = cc != null ? cc.getPendingPaidChange() : null; if (pending != null) { meta.setDisplayName(t(RPTexts.ERROR + "Cancel")); - lore.add(t(RPTexts.MUTED + "Keep your " + pending.rule().getLabel())); + lore.add(t(RPTexts.MUTED + "Keep your " + pending.label())); if (pending.amount() > 0.0) { lore.add(t(RPTexts.MUTED + "and get " + RPTexts.GUI_WARN + PaidChangeService.formatDenars(pending.amount()) + RPTexts.MUTED + " back")); diff --git a/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java b/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java index 6408cf7..018bc63 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java +++ b/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java @@ -379,7 +379,18 @@ public void initiatePlayer(Player p) { } final PlayerData loaded = pd; data.add(loaded); - if (ProstheticTraitRules.sanitize(loaded)) { + boolean dirty = ProstheticTraitRules.sanitize(loaded); + // On join only, not on offline loads: a refund or a fresh window here is saved at once. + for (RPCharacter c : loaded.getCharacters()) { + dirty |= net.tfminecraft.rpcharacters.creation.StageRevisions.refresh( + c, net.tfminecraft.rpcharacters.loaders.StageLoader.oList); + String recovered = net.tfminecraft.rpcharacters.paidchange.PaidChangeService.recover(c); + if (recovered != null) { + dirty = true; + RPTexts.send(p, recovered); + } + } + if (dirty) { savePlayer(p); } if(!loaded.hasActiveCharacter() && loaded.getCharacters(Status.ALIVE).size() > 0) { diff --git a/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java b/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java index a015ea1..29f5a7e 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java +++ b/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java @@ -101,6 +101,8 @@ public class RPCharacter { private long lastStrikeAtMs; /** Paid changes made so far, keyed by stage id. Sets the next price. */ private final Map paidChangeCounts = new HashMap<>(); + /** Denars held for an open paid stage, saved so a crash can't lose the refund or the count. */ + private net.tfminecraft.rpcharacters.paidchange.PendingPaidChange pendingPaidChange; /** Stage revision each stage's lock window was last opened for, keyed by stage id. */ private final Map stageRevisions = new HashMap<>(); /** Epoch seconds a revision reset opened each stage's window; 0 counts from creation. */ @@ -230,6 +232,14 @@ public Map getPaidChangeCounts() { return Collections.unmodifiableMap(paidChangeCounts); } + public net.tfminecraft.rpcharacters.paidchange.PendingPaidChange getPendingPaidChange() { + return pendingPaidChange; + } + + public void setPendingPaidChange(net.tfminecraft.rpcharacters.paidchange.PendingPaidChange pending) { + this.pendingPaidChange = pending; + } + public int getStageRevision(String stageId) { return stageId == null ? 0 : stageRevisions.getOrDefault(stageId, 0); } diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java index e828913..58f22e5 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java @@ -29,16 +29,22 @@ */ public final class PaidChangeService { - /** Denars held for an open paid stage. {@code account} is null when the change was free. */ - public record Pending(PaidChangeRule rule, UUID payerId, Account account, double amount, String before) {} - public enum ChargeStatus { PAID, UNAVAILABLE, INSUFFICIENT_FUNDS } - public record ChargeResult(ChargeStatus status, Pending pending, double cost) {} + public record ChargeResult(ChargeStatus status, PendingPaidChange pending, double cost) {} + + public enum Outcome { + /** The character changed: the payment stays and the change is counted. */ + KEPT, + /** Nothing changed and the denars went back. */ + REFUNDED, + /** Nothing changed but the refund failed; the payment stays held for a later retry. */ + REFUND_FAILED + } private static final Logger LOG = Logger.getLogger("RPCharacters"); @@ -80,47 +86,64 @@ public static ChargeResult charge(UUID payerId, PaidChangeRule rule, RPCharacter double cost = rule.costAfter(character.getPaidChangeCount(rule.getStageId())); String before = snapshot(character); if (cost <= 0.0) { - return new ChargeResult(ChargeStatus.PAID, new Pending(rule, payerId, null, 0.0, before), 0.0); + return new ChargeResult(ChargeStatus.PAID, pending(rule, payerId, null, 0.0, before), 0.0); } if (!wallet.available()) { return new ChargeResult(ChargeStatus.UNAVAILABLE, null, cost); } for (Account account : accountOrder) { if (wallet.balance(payerId, account) >= cost && wallet.withdraw(payerId, account, cost)) { - return new ChargeResult(ChargeStatus.PAID, new Pending(rule, payerId, account, cost, before), cost); + return new ChargeResult(ChargeStatus.PAID, pending(rule, payerId, account, cost, before), cost); } } return new ChargeResult(ChargeStatus.INSUFFICIENT_FUNDS, null, cost); } + private static PendingPaidChange pending(PaidChangeRule rule, UUID payerId, Account account, double amount, + String before) { + return new PendingPaidChange(rule.getStageId(), rule.getLabel(), payerId, account, amount, before); + } + /** - * Keeps the payment and counts the change when the character changed; otherwise refunds it. - * Returns true when the payment was kept. + * Settles the payment held on the character. A changed character keeps it and counts the + * change; an unchanged one is refunded. The hold is cleared only once one of those happened, + * so the caller must save the character after any outcome. */ - public static boolean resolve(Pending pending, RPCharacter character) { - if (character != null && !snapshot(character).equals(pending.before())) { - String stageId = pending.rule().getStageId(); - character.setPaidChangeCount(stageId, character.getPaidChangeCount(stageId) + 1); - return true; + public static Outcome resolve(RPCharacter character) { + PendingPaidChange pending = character.getPendingPaidChange(); + if (pending == null) { + return null; + } + if (!snapshot(character).equals(pending.before())) { + character.setPaidChangeCount(pending.stageId(), character.getPaidChangeCount(pending.stageId()) + 1); + character.setPendingPaidChange(null); + return Outcome.KEPT; } if (pending.account() != null && pending.amount() > 0.0 && !wallet.deposit(pending.payerId(), pending.account(), pending.amount())) { - LOG.log(Level.SEVERE, "Could not refund " + pending.amount() + " denars to " - + pending.payerId() + " (" + pending.account() + ") for a " + pending.rule().getId() - + " change they backed out of. Refund them by hand."); + LOG.log(Level.WARNING, "[RPCharacters] Could not refund " + pending.amount() + " denars to " + + pending.payerId() + " (" + pending.account() + ") for a " + pending.label() + + " change; keeping it held to retry."); + return Outcome.REFUND_FAILED; } - return false; + character.setPendingPaidChange(null); + return Outcome.REFUNDED; } - /** Charges for a locked stage and holds the payment on the session. False leaves the stage shut. */ + /** Charges for a locked stage and holds the payment on the character. False leaves the stage shut. */ public static boolean payToOpen(Player player, CharacterCreation cc, Stage stage) { settle(cc); + RPCharacter character = cc.getCharacter(); + if (character.getPendingPaidChange() != null) { + RPTexts.send(player, RPTexts.ERROR + "Your last refund hasn't gone through yet. Try again later."); + return false; + } PaidChangeRule rule = ruleFor(stage); if (rule == null) { RPTexts.send(player, RPTexts.ERROR + "That choice is locked and can no longer be edited."); return false; } - ChargeResult result = charge(player.getUniqueId(), rule, cc.getCharacter()); + ChargeResult result = charge(player.getUniqueId(), rule, character); switch (result.status()) { case UNAVAILABLE -> { RPTexts.send(player, RPTexts.ERROR + "You can't pay to change your " + rule.getLabel() @@ -137,8 +160,10 @@ public static boolean payToOpen(Player player, CharacterCreation cc, Stage stage default -> { } } - Pending pending = result.pending(); - cc.setPendingPaidChange(pending); + PendingPaidChange pending = result.pending(); + character.setPendingPaidChange(pending); + // Saved now, so a crash before the player leaves the stage still refunds or counts it. + save(player); if (pending.amount() > 0.0) { RPTexts.send(player, RPTexts.SUCCESS + "Paid " + formatDenars(pending.amount()) + " from your " + pending.account().displayName() + " to change your " + rule.getLabel() + "."); @@ -147,33 +172,62 @@ public static boolean payToOpen(Player player, CharacterCreation cc, Stage stage return true; } - /** Keeps or refunds the payment held on this session, if any. Safe to call more than once. */ + /** Keeps or refunds the payment held on this session's character, if any. Safe to call more than once. */ public static void settle(CharacterCreation cc) { - if (cc == null) { + if (cc == null || cc.getCharacter() == null) { return; } - Pending pending = cc.takePendingPaidChange(); - if (pending == null) { + RPCharacter character = cc.getCharacter(); + PendingPaidChange pending = character.getPendingPaidChange(); + Outcome outcome = resolve(character); + if (outcome == null) { return; } Player player = cc.getPlayer(); - RPCharacter character = cc.getCharacter(); - PaidChangeRule rule = pending.rule(); - if (resolve(pending, character)) { - double next = rule.costAfter(character.getPaidChangeCount(rule.getStageId())); - tell(player, RPTexts.SUCCESS + "Your " + rule.getLabel() + " change is paid for. The next one costs " - + formatDenars(next) + "."); - if (player != null && RPCharacters.getPlayerManager() != null) { - RPCharacters.getPlayerManager().savePlayer(player); + tell(player, outcomeMessage(pending, outcome, character)); + save(player); + } + + /** + * Settles a payment left held by a crash or a failed refund. Run when the owner joins, before + * any edit session exists. Returns the player message, or null when there was nothing to settle. + */ + public static String recover(RPCharacter character) { + PendingPaidChange pending = character == null ? null : character.getPendingPaidChange(); + Outcome outcome = pending == null ? null : resolve(character); + return outcome == null ? null : outcomeMessage(pending, outcome, character); + } + + private static String outcomeMessage(PendingPaidChange pending, Outcome outcome, RPCharacter character) { + switch (outcome) { + case KEPT -> { + PaidChangeRule rule = rulesByStage.get(pending.stageId()); + String next = rule == null ? "" + : " The next one costs " + formatDenars(rule.costAfter(character.getPaidChangeCount(pending.stageId()))) + "."; + return RPTexts.SUCCESS + "Your " + pending.label() + " change is paid for." + next; } - } else if (pending.amount() > 0.0) { - tell(player, RPTexts.SUCCESS + "Your " + rule.getLabel() + " is unchanged, so " - + formatDenars(pending.amount()) + " went back to your " + pending.account().displayName() + "."); + case REFUNDED -> { + if (pending.amount() <= 0.0) { + return null; + } + return RPTexts.SUCCESS + "Your " + pending.label() + " is unchanged, so " + + formatDenars(pending.amount()) + " went back to your " + pending.account().displayName() + "."; + } + default -> { + return RPTexts.ERROR + "Your " + formatDenars(pending.amount()) + " refund couldn't go through yet. " + + "It will be retried when you next join."; + } + } + } + + private static void save(Player player) { + if (player != null && RPCharacters.getPlayerManager() != null) { + RPCharacters.getPlayerManager().savePlayer(player); } } private static void tell(Player player, String message) { - if (player != null) { + if (player != null && message != null) { RPTexts.send(player, message); } } diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PendingPaidChange.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PendingPaidChange.java new file mode 100644 index 0000000..424bcc9 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PendingPaidChange.java @@ -0,0 +1,13 @@ +package net.tfminecraft.rpcharacters.paidchange; + +import java.util.UUID; + +import net.tfminecraft.rpcharacters.paidchange.DenarWallet.Account; + +/** + * Denars taken to open a paid stage, held on the character (and saved with it) until the change + * is kept or refunded. {@code account} is null when the change was free. {@code before} is the + * character snapshot at payment. + */ +public record PendingPaidChange(String stageId, String label, UUID payerId, Account account, double amount, + String before) {} diff --git a/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeServiceTest.java b/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeServiceTest.java index 80c098c..f42c44a 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeServiceTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeServiceTest.java @@ -102,20 +102,66 @@ void backingOutWithoutAChangeRefunds() { RPCharacter character = characterWithClass("WARRIOR"); ChargeResult result = PaidChangeService.charge(PLAYER, CLASS_RULE, character); assertEquals(0.0, wallet.get(Account.POUCH)); + character.setPendingPaidChange(result.pending()); - assertFalse(PaidChangeService.resolve(result.pending(), character)); + assertEquals(PaidChangeService.Outcome.REFUNDED, PaidChangeService.resolve(character)); assertEquals(100.0, wallet.get(Account.POUCH)); assertEquals(0, character.getPaidChangeCount(CLASS_STAGE)); + assertNull(character.getPendingPaidChange()); + } + + @Test + void aFailedRefundStaysHeldAndIsRetried() { + wallet.set(Account.POUCH, 100); + RPCharacter character = characterWithClass("WARRIOR"); + character.setPendingPaidChange(PaidChangeService.charge(PLAYER, CLASS_RULE, character).pending()); + + wallet.depositsFail = true; + assertEquals(PaidChangeService.Outcome.REFUND_FAILED, PaidChangeService.resolve(character)); + assertEquals(0.0, wallet.get(Account.POUCH)); + assertTrue(character.getPendingPaidChange() != null); + + wallet.depositsFail = false; + String message = PaidChangeService.recover(character); + assertTrue(message.contains("100 denars went back to your pouch"), message); + assertEquals(100.0, wallet.get(Account.POUCH)); + assertNull(character.getPendingPaidChange()); + assertNull(PaidChangeService.recover(character)); + } + + @Test + void aSavedHoldSurvivesACrashAndSettlesOnRecovery() throws Exception { + wallet.set(Account.BANK, 1000); + RPCharacter character = characterWithClass("WARRIOR"); + character.setPaidChangeCount(CLASS_STAGE, 1); + character.setPendingPaidChange(PaidChangeService.charge(PLAYER, CLASS_RULE, character).pending()); + character.setMMOClass("mage"); + + HashMap saved = new HashMap<>(); + CharacterStageChangeFields.save(saved, character); + JSONObject reparsed = (JSONObject) new JSONParser().parse(new JSONObject(saved).toJSONString()); + RPCharacter loaded = characterWithClass("MAGE"); + CharacterStageChangeFields.load(loaded, reparsed); + PendingPaidChange held = loaded.getPendingPaidChange(); + assertEquals(Account.BANK, held.account()); + assertEquals(1000.0, held.amount()); + assertEquals(PLAYER, held.payerId()); + + String message = PaidChangeService.recover(loaded); + assertTrue(message.contains("class change is paid for. The next one costs 3,000 denars"), message); + assertEquals(2, loaded.getPaidChangeCount(CLASS_STAGE)); + assertEquals(0.0, wallet.get(Account.BANK)); + assertNull(loaded.getPendingPaidChange()); } @Test void keepingTheChangeKeepsThePaymentAndRaisesTheNextPrice() { wallet.set(Account.BANK, 100); RPCharacter character = characterWithClass("WARRIOR"); - ChargeResult result = PaidChangeService.charge(PLAYER, CLASS_RULE, character); + character.setPendingPaidChange(PaidChangeService.charge(PLAYER, CLASS_RULE, character).pending()); character.setMMOClass("mage"); - assertTrue(PaidChangeService.resolve(result.pending(), character)); + assertEquals(PaidChangeService.Outcome.KEPT, PaidChangeService.resolve(character)); assertEquals(0.0, wallet.get(Account.BANK)); assertEquals(1, character.getPaidChangeCount(CLASS_STAGE)); assertEquals(1000.0, CLASS_RULE.costAfter(character.getPaidChangeCount(CLASS_STAGE))); @@ -264,6 +310,7 @@ private static Stage stage(String id, long lockMs) { private static final class FakeWallet implements DenarWallet { private final Map balances = new EnumMap<>(Account.class); boolean available = true; + boolean depositsFail; void set(Account account, double amount) { balances.put(account, amount); @@ -294,6 +341,9 @@ public boolean withdraw(UUID playerId, Account account, double amount) { @Override public boolean deposit(UUID playerId, Account account, double amount) { + if (depositsFail) { + return false; + } balances.put(account, get(account) + amount); return true; } From 470a8ba661cb55966d10459e03db108eebeba93b Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:43:51 +0000 Subject: [PATCH 4/5] fix: save paid-change holds on DenarEconomy's schedule DenarEconomy keeps online balances in memory until its own save, so a crash rolls back the withdrawal or refund. Forcing the hold to disk right after paying meant a crash refunded twice: once by DenarEconomy's rollback and again on the next join. The hold now saves with the character on the normal schedule, the same way DenarEconomy saves the balance it changed. Join recovery still retries failed refunds. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../rpcharacters/managers/PlayerManager.java | 4 ++-- .../paidchange/PaidChangeService.java | 19 +++++-------------- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java b/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java index 018bc63..7ab5693 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java +++ b/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java @@ -380,13 +380,13 @@ public void initiatePlayer(Player p) { final PlayerData loaded = pd; data.add(loaded); boolean dirty = ProstheticTraitRules.sanitize(loaded); - // On join only, not on offline loads: a refund or a fresh window here is saved at once. + // On join only, not on offline loads, which may never be saved. A settled hold is saved on + // the normal schedule, the same way DenarEconomy saves the online balance it changed. for (RPCharacter c : loaded.getCharacters()) { dirty |= net.tfminecraft.rpcharacters.creation.StageRevisions.refresh( c, net.tfminecraft.rpcharacters.loaders.StageLoader.oList); String recovered = net.tfminecraft.rpcharacters.paidchange.PaidChangeService.recover(c); if (recovered != null) { - dirty = true; RPTexts.send(p, recovered); } } diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java index 58f22e5..5379bf5 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java @@ -12,7 +12,6 @@ import org.bukkit.entity.Player; -import net.tfminecraft.rpcharacters.RPCharacters; import net.tfminecraft.rpcharacters.creation.CharacterCreation; import net.tfminecraft.rpcharacters.creation.Stage; import net.tfminecraft.rpcharacters.creation.StageEditLock; @@ -106,8 +105,7 @@ private static PendingPaidChange pending(PaidChangeRule rule, UUID payerId, Acco /** * Settles the payment held on the character. A changed character keeps it and counts the - * change; an unchanged one is refunded. The hold is cleared only once one of those happened, - * so the caller must save the character after any outcome. + * change; an unchanged one is refunded. The hold is cleared only once one of those happened. */ public static Outcome resolve(RPCharacter character) { PendingPaidChange pending = character.getPendingPaidChange(); @@ -161,9 +159,10 @@ public static boolean payToOpen(Player player, CharacterCreation cc, Stage stage } } PendingPaidChange pending = result.pending(); + // Saved with the character on the normal schedule, not forced here. DenarEconomy keeps online + // balances in memory until its own save, so after a crash it rolls the withdrawal back; a hold + // forced to disk now would then be refunded a second time on the next join. character.setPendingPaidChange(pending); - // Saved now, so a crash before the player leaves the stage still refunds or counts it. - save(player); if (pending.amount() > 0.0) { RPTexts.send(player, RPTexts.SUCCESS + "Paid " + formatDenars(pending.amount()) + " from your " + pending.account().displayName() + " to change your " + rule.getLabel() + "."); @@ -183,9 +182,7 @@ public static void settle(CharacterCreation cc) { if (outcome == null) { return; } - Player player = cc.getPlayer(); - tell(player, outcomeMessage(pending, outcome, character)); - save(player); + tell(cc.getPlayer(), outcomeMessage(pending, outcome, character)); } /** @@ -220,12 +217,6 @@ private static String outcomeMessage(PendingPaidChange pending, Outcome outcome, } } - private static void save(Player player) { - if (player != null && RPCharacters.getPlayerManager() != null) { - RPCharacters.getPlayerManager().savePlayer(player); - } - } - private static void tell(Player player, String message) { if (player != null && message != null) { RPTexts.send(player, message); From 8bef9455eb8913caf640fa50d9bd30ee834b840d Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:01:03 +0000 Subject: [PATCH 5/5] fix: exact denar amounts, recover before revision resets, block edits on a held refund - Prices, balances and held amounts are BigDecimal at cents, converted to double only at the DenarEconomy call. - On join, settle an old hold before applying a stage revision reset, so it counts toward the old revision and the new window starts at the first price. - While a failed refund is still held, no stage edit can open. An unrelated edit would otherwise look like the paid change was used. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../creation/CharacterCreation.java | 10 ++ .../database/CharacterStageChangeFields.java | 5 +- .../managers/InventoryManager.java | 2 +- .../rpcharacters/managers/PlayerManager.java | 5 +- .../paidchange/DenarEconomyWallet.java | 25 ++-- .../rpcharacters/paidchange/DenarWallet.java | 9 +- .../paidchange/PaidChangeConfig.java | 8 +- .../paidchange/PaidChangeRule.java | 14 ++- .../paidchange/PaidChangeService.java | 28 ++--- .../paidchange/PendingPaidChange.java | 3 +- .../paidchange/PaidChangeServiceTest.java | 108 ++++++++++-------- 11 files changed, 129 insertions(+), 88 deletions(-) diff --git a/src/main/java/net/tfminecraft/rpcharacters/creation/CharacterCreation.java b/src/main/java/net/tfminecraft/rpcharacters/creation/CharacterCreation.java index 357a1d7..cd66355 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/creation/CharacterCreation.java +++ b/src/main/java/net/tfminecraft/rpcharacters/creation/CharacterCreation.java @@ -504,6 +504,16 @@ public void jumpToStageForEdit(String stageId) { PaidChangeService.settle(this); + if (character.getPendingPaidChange() != null) { + + // A refund that failed stays held. Any edit now would look like the paid change was used. + + RPTexts.send(p, RPTexts.ERROR + "Your last refund hasn't gone through yet. Try again after you rejoin."); + + return; + + } + if (!StageEditLock.canEdit(p, template, character) && !PaidChangeService.payToOpen(p, this, template)) { return; diff --git a/src/main/java/net/tfminecraft/rpcharacters/database/CharacterStageChangeFields.java b/src/main/java/net/tfminecraft/rpcharacters/database/CharacterStageChangeFields.java index 58e70bc..ead9256 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/database/CharacterStageChangeFields.java +++ b/src/main/java/net/tfminecraft/rpcharacters/database/CharacterStageChangeFields.java @@ -1,5 +1,6 @@ package net.tfminecraft.rpcharacters.database; +import java.math.BigDecimal; import java.util.Map; import java.util.UUID; import java.util.logging.Logger; @@ -72,7 +73,7 @@ public static void save(Map defaults, RPCharacter character) { if (pending.account() != null) { held.put("account", pending.account().name()); } - held.put("amount", pending.amount()); + held.put("amount", pending.amount().toPlainString()); held.put("before", pending.before()); defaults.put("paid-change-pending", held); } @@ -81,7 +82,7 @@ public static void save(Map defaults, RPCharacter character) { private static PendingPaidChange readPending(Map held) { try { Account account = held.get("account") instanceof String name ? Account.valueOf(name) : null; - double amount = held.get("amount") instanceof Number n ? n.doubleValue() : 0.0; + BigDecimal amount = new BigDecimal(String.valueOf(held.get("amount"))); return new PendingPaidChange((String) held.get("stage"), (String) held.get("label"), UUID.fromString((String) held.get("payer")), account, amount, (String) held.get("before")); } catch (RuntimeException e) { diff --git a/src/main/java/net/tfminecraft/rpcharacters/managers/InventoryManager.java b/src/main/java/net/tfminecraft/rpcharacters/managers/InventoryManager.java index fea1319..9d8c407 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/managers/InventoryManager.java +++ b/src/main/java/net/tfminecraft/rpcharacters/managers/InventoryManager.java @@ -913,7 +913,7 @@ public ItemStack createCancelItem(CharacterCreation cc) { if (pending != null) { meta.setDisplayName(t(RPTexts.ERROR + "Cancel")); lore.add(t(RPTexts.MUTED + "Keep your " + pending.label())); - if (pending.amount() > 0.0) { + if (pending.amount().signum() > 0) { lore.add(t(RPTexts.MUTED + "and get " + RPTexts.GUI_WARN + PaidChangeService.formatDenars(pending.amount()) + RPTexts.MUTED + " back")); } diff --git a/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java b/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java index 7ab5693..737a598 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java +++ b/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java @@ -383,12 +383,13 @@ public void initiatePlayer(Player p) { // On join only, not on offline loads, which may never be saved. A settled hold is saved on // the normal schedule, the same way DenarEconomy saves the online balance it changed. for (RPCharacter c : loaded.getCharacters()) { - dirty |= net.tfminecraft.rpcharacters.creation.StageRevisions.refresh( - c, net.tfminecraft.rpcharacters.loaders.StageLoader.oList); + // Settle an old hold first, so it counts toward the old revision, not the fresh window. String recovered = net.tfminecraft.rpcharacters.paidchange.PaidChangeService.recover(c); if (recovered != null) { RPTexts.send(p, recovered); } + dirty |= net.tfminecraft.rpcharacters.creation.StageRevisions.refresh( + c, net.tfminecraft.rpcharacters.loaders.StageLoader.oList); } if (dirty) { savePlayer(p); diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWallet.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWallet.java index 6024997..6264258 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWallet.java +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWallet.java @@ -1,6 +1,8 @@ package net.tfminecraft.rpcharacters.paidchange; import java.lang.reflect.Method; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.UUID; import java.util.logging.Level; import java.util.logging.Logger; @@ -27,35 +29,38 @@ public boolean available() { } @Override - public double balance(UUID playerId, Account account) { + public BigDecimal balance(UUID playerId, Account account) { if (!bind()) { - return 0.0; + return BigDecimal.ZERO; } try { - return ((Number) balance.invoke(null, playerId, toDenar(account))).doubleValue(); + double value = ((Number) balance.invoke(null, playerId, toDenar(account))).doubleValue(); + // DenarEconomy stores cents in a BigDecimal and hands back its double value. + return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP); } catch (ReflectiveOperationException | RuntimeException e) { LOG.log(Level.WARNING, "[RPCharacters] Could not read a DenarEconomy balance", e); - return 0.0; + return BigDecimal.ZERO; } } @Override - public boolean withdraw(UUID playerId, Account account, double amount) { - return amount > 0.0 && apply(playerId, account, -amount); + public boolean withdraw(UUID playerId, Account account, BigDecimal amount) { + return amount.signum() > 0 && apply(playerId, account, amount.negate()); } @Override - public boolean deposit(UUID playerId, Account account, double amount) { - return amount > 0.0 && apply(playerId, account, amount); + public boolean deposit(UUID playerId, Account account, BigDecimal amount) { + return amount.signum() > 0 && apply(playerId, account, amount); } /** OfflineModifier.apply refuses a withdrawal the account can't cover. */ - private boolean apply(UUID playerId, Account account, double amount) { + private boolean apply(UUID playerId, Account account, BigDecimal amount) { if (!bind()) { return false; } try { - return Boolean.TRUE.equals(apply.invoke(null, playerId, toDenar(account), amount)); + // The API takes a double; DenarEconomy turns it back into cents with BigDecimal.valueOf. + return Boolean.TRUE.equals(apply.invoke(null, playerId, toDenar(account), amount.doubleValue())); } catch (ReflectiveOperationException | RuntimeException e) { LOG.log(Level.WARNING, "[RPCharacters] Could not change a DenarEconomy balance", e); return false; diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarWallet.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarWallet.java index 6008ff8..28a836a 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarWallet.java +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarWallet.java @@ -1,8 +1,9 @@ package net.tfminecraft.rpcharacters.paidchange; +import java.math.BigDecimal; import java.util.UUID; -/** The denar accounts a paid change is charged to. */ +/** The denar accounts a paid change is charged to. Amounts are exact, in cents (scale 2). */ public interface DenarWallet { enum Account { POUCH, @@ -15,10 +16,10 @@ public String displayName() { boolean available(); - double balance(UUID playerId, Account account); + BigDecimal balance(UUID playerId, Account account); /** Takes the whole amount, or nothing when the account cannot cover it. */ - boolean withdraw(UUID playerId, Account account, double amount); + boolean withdraw(UUID playerId, Account account, BigDecimal amount); - boolean deposit(UUID playerId, Account account, double amount); + boolean deposit(UUID playerId, Account account, BigDecimal amount); } diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeConfig.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeConfig.java index 3aae5bf..d836928 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeConfig.java +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeConfig.java @@ -1,5 +1,6 @@ package net.tfminecraft.rpcharacters.paidchange; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -49,10 +50,11 @@ private static PaidChangeRule readRule(String id, ConfigurationSection rule) { LOG.warning("[RPCharacters] paid-changes.rules." + id + " has no stage; skipped."); return null; } - List costs = new ArrayList<>(); + List costs = new ArrayList<>(); for (Object value : rule.getList("costs", List.of())) { - if (value instanceof Number number && number.doubleValue() >= 0.0) { - costs.add(number.doubleValue()); + BigDecimal cost = value instanceof Number number ? new BigDecimal(number.toString()) : null; + if (cost != null && cost.signum() >= 0) { + costs.add(cost); } else { LOG.warning("[RPCharacters] paid-changes.rules." + id + ".costs: ignored '" + value + "'. Costs must be numbers of 0 or more."); diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeRule.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeRule.java index 84b7beb..4dbeea9 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeRule.java +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeRule.java @@ -1,5 +1,7 @@ package net.tfminecraft.rpcharacters.paidchange; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.List; /** @@ -10,13 +12,13 @@ public final class PaidChangeRule { private final String id; private final String stageId; private final String label; - private final List costs; + private final List costs; - public PaidChangeRule(String id, String stageId, String label, List costs) { + public PaidChangeRule(String id, String stageId, String label, List costs) { this.id = id; this.stageId = stageId.toLowerCase(java.util.Locale.ROOT); this.label = label == null || label.isBlank() ? id : label; - this.costs = List.copyOf(costs); + this.costs = costs.stream().map(cost -> cost.setScale(2, RoundingMode.HALF_UP)).toList(); } public String getId() { @@ -33,14 +35,14 @@ public String getLabel() { return label; } - public List getCosts() { + public List getCosts() { return costs; } /** Price of a change after {@code paidSoFar} earlier paid changes. */ - public double costAfter(int paidSoFar) { + public BigDecimal costAfter(int paidSoFar) { if (costs.isEmpty()) { - return 0.0; + return BigDecimal.ZERO.setScale(2); } return costs.get(Math.min(Math.max(0, paidSoFar), costs.size() - 1)); } diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java index 5379bf5..1ce3b50 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java @@ -1,5 +1,6 @@ package net.tfminecraft.rpcharacters.paidchange; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.Collection; import java.util.LinkedHashMap; @@ -34,7 +35,7 @@ public enum ChargeStatus { INSUFFICIENT_FUNDS } - public record ChargeResult(ChargeStatus status, PendingPaidChange pending, double cost) {} + public record ChargeResult(ChargeStatus status, PendingPaidChange pending, BigDecimal cost) {} public enum Outcome { /** The character changed: the payment stays and the change is counted. */ @@ -82,23 +83,23 @@ public static boolean canPayToOpen(Stage stage) { /** Takes the next price from the first account that covers it. */ public static ChargeResult charge(UUID payerId, PaidChangeRule rule, RPCharacter character) { - double cost = rule.costAfter(character.getPaidChangeCount(rule.getStageId())); + BigDecimal cost = rule.costAfter(character.getPaidChangeCount(rule.getStageId())); String before = snapshot(character); - if (cost <= 0.0) { - return new ChargeResult(ChargeStatus.PAID, pending(rule, payerId, null, 0.0, before), 0.0); + if (cost.signum() <= 0) { + return new ChargeResult(ChargeStatus.PAID, pending(rule, payerId, null, cost, before), cost); } if (!wallet.available()) { return new ChargeResult(ChargeStatus.UNAVAILABLE, null, cost); } for (Account account : accountOrder) { - if (wallet.balance(payerId, account) >= cost && wallet.withdraw(payerId, account, cost)) { + if (wallet.balance(payerId, account).compareTo(cost) >= 0 && wallet.withdraw(payerId, account, cost)) { return new ChargeResult(ChargeStatus.PAID, pending(rule, payerId, account, cost, before), cost); } } return new ChargeResult(ChargeStatus.INSUFFICIENT_FUNDS, null, cost); } - private static PendingPaidChange pending(PaidChangeRule rule, UUID payerId, Account account, double amount, + private static PendingPaidChange pending(PaidChangeRule rule, UUID payerId, Account account, BigDecimal amount, String before) { return new PendingPaidChange(rule.getStageId(), rule.getLabel(), payerId, account, amount, before); } @@ -117,7 +118,7 @@ public static Outcome resolve(RPCharacter character) { character.setPendingPaidChange(null); return Outcome.KEPT; } - if (pending.account() != null && pending.amount() > 0.0 + if (pending.account() != null && pending.amount().signum() > 0 && !wallet.deposit(pending.payerId(), pending.account(), pending.amount())) { LOG.log(Level.WARNING, "[RPCharacters] Could not refund " + pending.amount() + " denars to " + pending.payerId() + " (" + pending.account() + ") for a " + pending.label() @@ -163,7 +164,7 @@ public static boolean payToOpen(Player player, CharacterCreation cc, Stage stage // balances in memory until its own save, so after a crash it rolls the withdrawal back; a hold // forced to disk now would then be refunded a second time on the next join. character.setPendingPaidChange(pending); - if (pending.amount() > 0.0) { + if (pending.amount().signum() > 0) { RPTexts.send(player, RPTexts.SUCCESS + "Paid " + formatDenars(pending.amount()) + " from your " + pending.account().displayName() + " to change your " + rule.getLabel() + "."); RPTexts.send(player, RPTexts.MUTED + "Leave without changing it and you get the denars back."); @@ -204,7 +205,7 @@ private static String outcomeMessage(PendingPaidChange pending, Outcome outcome, return RPTexts.SUCCESS + "Your " + pending.label() + " change is paid for." + next; } case REFUNDED -> { - if (pending.amount() <= 0.0) { + if (pending.amount().signum() <= 0) { return null; } return RPTexts.SUCCESS + "Your " + pending.label() + " is unchanged, so " @@ -255,11 +256,12 @@ public static List summaryLore(Stage stage, RPCharacter character, boole return lines; } - public static String formatDenars(double amount) { - String number = amount == Math.rint(amount) - ? String.format(Locale.ROOT, "%,d", (long) amount) + public static String formatDenars(BigDecimal amount) { + BigDecimal whole = amount.stripTrailingZeros(); + String number = whole.scale() <= 0 + ? String.format(Locale.ROOT, "%,d", whole.toBigInteger()) : String.format(Locale.ROOT, "%,.2f", amount); - return number + (amount == 1.0 ? " denar" : " denars"); + return number + (amount.compareTo(BigDecimal.ONE) == 0 ? " denar" : " denars"); } /** Everything a creation stage can change, so any edit made after paying shows up. */ diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PendingPaidChange.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PendingPaidChange.java index 424bcc9..54d0bbd 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PendingPaidChange.java +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PendingPaidChange.java @@ -1,5 +1,6 @@ package net.tfminecraft.rpcharacters.paidchange; +import java.math.BigDecimal; import java.util.UUID; import net.tfminecraft.rpcharacters.paidchange.DenarWallet.Account; @@ -9,5 +10,5 @@ * is kept or refunded. {@code account} is null when the change was free. {@code before} is the * character snapshot at payment. */ -public record PendingPaidChange(String stageId, String label, UUID payerId, Account account, double amount, +public record PendingPaidChange(String stageId, String label, UUID payerId, Account account, BigDecimal amount, String before) {} diff --git a/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeServiceTest.java b/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeServiceTest.java index f42c44a..2abbee4 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeServiceTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeServiceTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.math.BigDecimal; import java.time.Instant; import java.util.EnumMap; import java.util.HashMap; @@ -34,7 +35,7 @@ class PaidChangeServiceTest { private static final UUID PLAYER = UUID.randomUUID(); private static final String CLASS_STAGE = "class_selection_stage"; private static final PaidChangeRule CLASS_RULE = - new PaidChangeRule("class", "class_selection_stage", "class", List.of(100.0, 1000.0, 3000.0)); + new PaidChangeRule("class", "class_selection_stage", "class", List.of(d("100"), d("1000"), d("3000"))); private final FakeWallet wallet = new FakeWallet(); @@ -51,46 +52,46 @@ void tearDown() { @Test void pricesRiseThenRepeatTheLastCost() { - assertEquals(100.0, CLASS_RULE.costAfter(0)); - assertEquals(1000.0, CLASS_RULE.costAfter(1)); - assertEquals(3000.0, CLASS_RULE.costAfter(2)); - assertEquals(3000.0, CLASS_RULE.costAfter(7)); + assertEquals(d("100"), CLASS_RULE.costAfter(0)); + assertEquals(d("1000"), CLASS_RULE.costAfter(1)); + assertEquals(d("3000"), CLASS_RULE.costAfter(2)); + assertEquals(d("3000"), CLASS_RULE.costAfter(7)); } @Test void chargesThePouchFirstThenTheBank() { - wallet.set(Account.POUCH, 150); - wallet.set(Account.BANK, 5000); + wallet.set(Account.POUCH, d("150")); + wallet.set(Account.BANK, d("5000")); RPCharacter character = characterWithClass("WARRIOR"); ChargeResult first = PaidChangeService.charge(PLAYER, CLASS_RULE, character); assertEquals(ChargeStatus.PAID, first.status()); assertEquals(Account.POUCH, first.pending().account()); - assertEquals(50.0, wallet.get(Account.POUCH)); + assertEquals(d("50"), wallet.get(Account.POUCH)); character.setPaidChangeCount(CLASS_STAGE, 1); ChargeResult second = PaidChangeService.charge(PLAYER, CLASS_RULE, character); assertEquals(Account.BANK, second.pending().account()); - assertEquals(1000.0, second.pending().amount()); - assertEquals(4000.0, wallet.get(Account.BANK)); + assertEquals(d("1000"), second.pending().amount()); + assertEquals(d("4000"), wallet.get(Account.BANK)); } @Test void refusesWhenNoAccountCoversTheCost() { - wallet.set(Account.POUCH, 60); - wallet.set(Account.BANK, 60); + wallet.set(Account.POUCH, d("60")); + wallet.set(Account.BANK, d("60")); ChargeResult result = PaidChangeService.charge(PLAYER, CLASS_RULE, characterWithClass("WARRIOR")); assertEquals(ChargeStatus.INSUFFICIENT_FUNDS, result.status()); - assertEquals(100.0, result.cost()); - assertEquals(60.0, wallet.get(Account.POUCH)); - assertEquals(60.0, wallet.get(Account.BANK)); + assertEquals(d("100"), result.cost()); + assertEquals(d("60"), wallet.get(Account.POUCH)); + assertEquals(d("60"), wallet.get(Account.BANK)); } @Test void unavailableWithoutDenarEconomy() { wallet.available = false; - wallet.set(Account.POUCH, 500); + wallet.set(Account.POUCH, d("500")); assertFalse(PaidChangeService.canPayToOpen(stage("class_selection_stage", 5 * DAY_MS))); assertEquals(ChargeStatus.UNAVAILABLE, PaidChangeService.charge(PLAYER, CLASS_RULE, characterWithClass("WARRIOR")).status()); @@ -98,40 +99,40 @@ void unavailableWithoutDenarEconomy() { @Test void backingOutWithoutAChangeRefunds() { - wallet.set(Account.POUCH, 100); + wallet.set(Account.POUCH, d("100")); RPCharacter character = characterWithClass("WARRIOR"); ChargeResult result = PaidChangeService.charge(PLAYER, CLASS_RULE, character); - assertEquals(0.0, wallet.get(Account.POUCH)); + assertEquals(d("0"), wallet.get(Account.POUCH)); character.setPendingPaidChange(result.pending()); assertEquals(PaidChangeService.Outcome.REFUNDED, PaidChangeService.resolve(character)); - assertEquals(100.0, wallet.get(Account.POUCH)); + assertEquals(d("100"), wallet.get(Account.POUCH)); assertEquals(0, character.getPaidChangeCount(CLASS_STAGE)); assertNull(character.getPendingPaidChange()); } @Test void aFailedRefundStaysHeldAndIsRetried() { - wallet.set(Account.POUCH, 100); + wallet.set(Account.POUCH, d("100")); RPCharacter character = characterWithClass("WARRIOR"); character.setPendingPaidChange(PaidChangeService.charge(PLAYER, CLASS_RULE, character).pending()); wallet.depositsFail = true; assertEquals(PaidChangeService.Outcome.REFUND_FAILED, PaidChangeService.resolve(character)); - assertEquals(0.0, wallet.get(Account.POUCH)); + assertEquals(d("0"), wallet.get(Account.POUCH)); assertTrue(character.getPendingPaidChange() != null); wallet.depositsFail = false; String message = PaidChangeService.recover(character); assertTrue(message.contains("100 denars went back to your pouch"), message); - assertEquals(100.0, wallet.get(Account.POUCH)); + assertEquals(d("100"), wallet.get(Account.POUCH)); assertNull(character.getPendingPaidChange()); assertNull(PaidChangeService.recover(character)); } @Test void aSavedHoldSurvivesACrashAndSettlesOnRecovery() throws Exception { - wallet.set(Account.BANK, 1000); + wallet.set(Account.BANK, d("1000")); RPCharacter character = characterWithClass("WARRIOR"); character.setPaidChangeCount(CLASS_STAGE, 1); character.setPendingPaidChange(PaidChangeService.charge(PLAYER, CLASS_RULE, character).pending()); @@ -144,27 +145,27 @@ void aSavedHoldSurvivesACrashAndSettlesOnRecovery() throws Exception { CharacterStageChangeFields.load(loaded, reparsed); PendingPaidChange held = loaded.getPendingPaidChange(); assertEquals(Account.BANK, held.account()); - assertEquals(1000.0, held.amount()); + assertEquals(d("1000"), held.amount()); assertEquals(PLAYER, held.payerId()); String message = PaidChangeService.recover(loaded); assertTrue(message.contains("class change is paid for. The next one costs 3,000 denars"), message); assertEquals(2, loaded.getPaidChangeCount(CLASS_STAGE)); - assertEquals(0.0, wallet.get(Account.BANK)); + assertEquals(d("0"), wallet.get(Account.BANK)); assertNull(loaded.getPendingPaidChange()); } @Test void keepingTheChangeKeepsThePaymentAndRaisesTheNextPrice() { - wallet.set(Account.BANK, 100); + wallet.set(Account.BANK, d("100")); RPCharacter character = characterWithClass("WARRIOR"); character.setPendingPaidChange(PaidChangeService.charge(PLAYER, CLASS_RULE, character).pending()); character.setMMOClass("mage"); assertEquals(PaidChangeService.Outcome.KEPT, PaidChangeService.resolve(character)); - assertEquals(0.0, wallet.get(Account.BANK)); + assertEquals(d("0"), wallet.get(Account.BANK)); assertEquals(1, character.getPaidChangeCount(CLASS_STAGE)); - assertEquals(1000.0, CLASS_RULE.costAfter(character.getPaidChangeCount(CLASS_STAGE))); + assertEquals(d("1000"), CLASS_RULE.costAfter(character.getPaidChangeCount(CLASS_STAGE))); } @Test @@ -219,10 +220,10 @@ void configReadsRulesAndAccounts() { assertFalse(PaidChangeService.canPayToOpen(stage("class_selection_stage", 5 * DAY_MS))); PaidChangeRule race = PaidChangeService.ruleFor(stage("race_selection_stage", DAY_MS)); assertEquals("race", race.getLabel()); - assertEquals(75.0, race.costAfter(3)); + assertEquals(d("75"), race.costAfter(3)); - wallet.set(Account.POUCH, 1000); - wallet.set(Account.BANK, 50); + wallet.set(Account.POUCH, d("1000")); + wallet.set(Account.BANK, d("50")); ChargeResult result = PaidChangeService.charge(PLAYER, race, characterWithClass("WARRIOR")); assertEquals(Account.BANK, result.pending().account()); } @@ -288,10 +289,25 @@ void stageChangeStateSurvivesASaveAndLoad() throws Exception { @Test void denarsFormatWithSeparators() { - assertEquals("100 denars", PaidChangeService.formatDenars(100)); - assertEquals("3,000 denars", PaidChangeService.formatDenars(3000)); - assertEquals("1 denar", PaidChangeService.formatDenars(1)); - assertEquals("2.50 denars", PaidChangeService.formatDenars(2.5)); + assertEquals("100 denars", PaidChangeService.formatDenars(d("100"))); + assertEquals("3,000 denars", PaidChangeService.formatDenars(d("3000"))); + assertEquals("1 denar", PaidChangeService.formatDenars(d("1"))); + assertEquals("2.50 denars", PaidChangeService.formatDenars(d("2.5"))); + } + + private static BigDecimal d(String value) { + return new BigDecimal(value).setScale(2); + } + + @Test + void fractionalPricesStayExact() { + PaidChangeRule cheap = new PaidChangeRule("tip", CLASS_STAGE, "class", List.of(d("0.1"))); + wallet.set(Account.POUCH, d("0.3")); + RPCharacter character = characterWithClass("WARRIOR"); + for (int i = 0; i < 3; i++) { + assertEquals(ChargeStatus.PAID, PaidChangeService.charge(PLAYER, cheap, character).status()); + } + assertEquals(d("0"), wallet.get(Account.POUCH)); } private static RPCharacter characterWithClass(String classId) { @@ -308,16 +324,16 @@ private static Stage stage(String id, long lockMs) { } private static final class FakeWallet implements DenarWallet { - private final Map balances = new EnumMap<>(Account.class); + private final Map balances = new EnumMap<>(Account.class); boolean available = true; boolean depositsFail; - void set(Account account, double amount) { - balances.put(account, amount); + void set(Account account, BigDecimal amount) { + balances.put(account, amount.setScale(2)); } - double get(Account account) { - return balances.getOrDefault(account, 0.0); + BigDecimal get(Account account) { + return balances.getOrDefault(account, d("0")); } @Override @@ -326,25 +342,25 @@ public boolean available() { } @Override - public double balance(UUID playerId, Account account) { + public BigDecimal balance(UUID playerId, Account account) { return get(account); } @Override - public boolean withdraw(UUID playerId, Account account, double amount) { - if (get(account) < amount) { + public boolean withdraw(UUID playerId, Account account, BigDecimal amount) { + if (get(account).compareTo(amount) < 0) { return false; } - balances.put(account, get(account) - amount); + balances.put(account, get(account).subtract(amount)); return true; } @Override - public boolean deposit(UUID playerId, Account account, double amount) { + public boolean deposit(UUID playerId, Account account, BigDecimal amount) { if (depositsFail) { return false; } - balances.put(account, get(account) + amount); + balances.put(account, get(account).add(amount)); return true; } }