From 5ca07d959d59e52350eed3000aece4373892d8b9 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:57:34 +0000 Subject: [PATCH 1/5] Charge paid changes to the paying character's ledger Paid stage changes and class picks now take denars from the changed character's own pouch or bank through DenarEconomy 0.3.0's Denars API, still reached by reflection. Each payment is saved on the character with its debit and refund operation IDs before the debit is posted; settling asks the ledger whether the debit applied, discards refused or missing debits without a refund, and refunds with the stored ID so retries never pay twice. Pending payments from the account-wide wallet are refunded to the account's legacy heir once heirs are known. The character selector shows each character's own balances. Co-Authored-By: Claude Opus 5.5 --- .../body/CharacterSelectView.java | 35 +- .../classpick/ClassPickService.java | 123 +++++-- .../classpick/PendingClassPick.java | 6 + .../database/CharacterClassPickFields.java | 24 +- .../database/CharacterStageChangeFields.java | 38 +- .../managers/InventoryManager.java | 4 +- .../rpcharacters/managers/PlayerManager.java | 14 +- .../rpcharacters/objects/RPCharacter.java | 10 + .../paidchange/DenarEconomyWallet.java | 111 ++++-- .../rpcharacters/paidchange/DenarWallet.java | 45 ++- .../paidchange/PaidChangeListener.java | 2 +- .../paidchange/PaidChangeService.java | 106 ++++-- .../rpcharacters/paidchange/Payment.java | 73 ++++ .../rpcharacters/paidchange/Payments.java | 150 ++++++++ .../paidchange/PendingPaidChange.java | 21 +- src/main/resources/config.yml | 5 +- .../body/CharacterSelectViewTest.java | 36 +- .../rpcharacters/classpick/ClassPickTest.java | 127 +++++-- .../database/CharacterFieldsRuntimeTest.java | 9 +- .../managers/InventoryManagerItemsTest.java | 6 +- .../managers/PlayerManagerLifecycleTest.java | 10 + .../objects/RPCharacterRuntimeTest.java | 4 +- .../paidchange/DenarEconomyWalletTest.java | 150 +++++--- .../rpcharacters/paidchange/FakeLedger.java | 86 +++++ .../paidchange/PaidChangeRuntimeTest.java | 48 +-- .../paidchange/PaidChangeServiceTest.java | 324 ++++++++++++++---- .../paidchange/PaymentsFixture.java | 24 ++ 27 files changed, 1249 insertions(+), 342 deletions(-) create mode 100644 src/main/java/net/tfminecraft/rpcharacters/classpick/PendingClassPick.java create mode 100644 src/main/java/net/tfminecraft/rpcharacters/paidchange/Payment.java create mode 100644 src/main/java/net/tfminecraft/rpcharacters/paidchange/Payments.java create mode 100644 src/test/java/net/tfminecraft/rpcharacters/paidchange/FakeLedger.java create mode 100644 src/test/java/net/tfminecraft/rpcharacters/paidchange/PaymentsFixture.java diff --git a/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java b/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java index c0666f58..18eced15 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java +++ b/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java @@ -58,6 +58,7 @@ import net.tfminecraft.rpcharacters.playerlist.SkinPortrait.SkinTexture; import net.tfminecraft.rpcharacters.utils.AgeFormatter; import net.tfminecraft.rpcharacters.utils.ClueFormatter; +import net.tfminecraft.tlibs.character.CharacterRef; /** * Join-time character picker, shown while the player has no active body. It @@ -247,7 +248,7 @@ private static void renderRoster(Session session) { } else { reason = session.actions.blockReason(selected); body.add(DialogBody.plainMessage(portrait(session, selected), SkinPortrait.ROW_WIDTH)); - body.add(text(characterCard(selected, reason))); + body.add(text(characterCard(player.getUniqueId(), selected, reason))); } body.add(text(footer(player, data, alive.size()))); @@ -438,7 +439,9 @@ private static void fill(BufferedImage image, int x, int y, int width, int heigh private static Component footer(Player player, PlayerData data, int alive) { int slots = CharacterSlotService.getMaxAliveCharacters(player); - return Component.text(alive + " of " + slots + " character slots used • ", DIM).append(accountSummary(player)); + Component used = Component.text(alive + " of " + slots + " character slots used", DIM); + Component account = accountSummary(player); + return account == null ? used : used.append(Component.text(" • ", DIM)).append(account); } /** Living characters, most recently played first, then memorials. */ @@ -482,22 +485,29 @@ static String createBlockReason(Player player, PlayerData data) { return null; } + /** The guild line, or null when guilds can't be read. */ static Component accountSummary(Player player) { - List parts = new ArrayList<>(); String guild = guilds.apply(player); - if (guild != null) parts.add("Guild: " + (guild.isBlank() ? "none" : guild)); - if (wallet.available()) { - parts.add("Pouch: " + PaidChangeService.formatDenars(wallet.balance(player.getUniqueId(), Account.POUCH))); - parts.add("Bank: " + PaidChangeService.formatDenars(wallet.balance(player.getUniqueId(), Account.BANK))); - } else { - parts.add("Pouch and bank: unavailable"); - } + if (guild == null) return null; return Component.text("Shared by all your characters", DIM) .append(Component.newline()) - .append(Component.text(String.join(" • ", parts), MUTED)); + .append(Component.text("Guild: " + (guild.isBlank() ? "none" : guild), MUTED)); + } + + /** This character's own pouch and bank; empty for a record without a character ID. */ + static String balances(UUID owner, RPCharacter character) { + CharacterRef ref; + try { + ref = new CharacterRef(owner, UUID.fromString(character.getId())); + } catch (IllegalArgumentException | NullPointerException notACharacterId) { + return ""; + } + if (!wallet.available()) return "Pouch and bank: unavailable"; + return "Pouch: " + PaidChangeService.formatCents(wallet.balance(ref, Account.POUCH)) + + " • Bank: " + PaidChangeService.formatCents(wallet.balance(ref, Account.BANK)); } - static Component characterCard(RPCharacter character, String blockReason) { + static Component characterCard(UUID owner, RPCharacter character, String blockReason) { TextComponent.Builder card = Component.text() .append(Component.text(name(character), PALE, TextDecoration.BOLD)); line(card, joined(race(character), className(character), age(character)), TEAL); @@ -508,6 +518,7 @@ static Component characterCard(RPCharacter character, String blockReason) { history.add("Last played " + AgeFormatter.formatAge(seconds) + " ago"); } line(card, String.join(" • ", history), MUTED); + line(card, balances(owner, character), MUTED); if (character.hasLastLocation()) { line(card, String.format(Locale.ROOT, "Last location: %s %d, %d, %d", character.getLastLocationWorld(), (long) Math.floor(character.getLastLocationX()), (long) Math.floor(character.getLastLocationY()), diff --git a/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassPickService.java b/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassPickService.java index 96b74df8..558202c9 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassPickService.java +++ b/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassPickService.java @@ -30,8 +30,11 @@ import net.tfminecraft.rpcharacters.paidchange.DenarEconomyWallet; import net.tfminecraft.rpcharacters.paidchange.DenarWallet; import net.tfminecraft.rpcharacters.paidchange.DenarWallet.Account; +import net.tfminecraft.rpcharacters.paidchange.DenarWallet.Posting; import net.tfminecraft.rpcharacters.paidchange.PaidChangeRule; import net.tfminecraft.rpcharacters.paidchange.PaidChangeService; +import net.tfminecraft.rpcharacters.paidchange.Payment; +import net.tfminecraft.rpcharacters.paidchange.Payments; import net.tfminecraft.rpcharacters.utils.AgeFormatter; import net.tfminecraft.rpcharacters.utils.RPTexts; @@ -62,7 +65,7 @@ public enum Status { NOT_APPLIED } - /** {@code paidFrom} is null when the pick was free. */ + /** {@code paidFrom} is null when the pick was free, or when a pick that was not applied was refunded. */ public record Result(Status status, PlayerClass target, BigDecimal cost, Account paidFrom) {} /** A displayed base class and the level its subclasses unlock at. */ @@ -229,62 +232,122 @@ public static Result choose(Player player, String classId) { if (net.Indyuce.mmocore.api.player.PlayerData.get(player).getLevel() < option.requiredLevel()) { return new Result(Status.LEVEL_TOO_LOW, target, BigDecimal.ZERO, null); } + UUID owner = player.getUniqueId(); + tell(player, recover(owner, character)); + if (character.getPendingClassPick() != null) { + return new Result(Status.UNAVAILABLE, target, BigDecimal.ZERO, null); + } BigDecimal cost = price(character, option); - Account paidFrom = null; + Payment payment = null; if (cost.signum() > 0) { if (!wallet.available()) { return new Result(Status.UNAVAILABLE, target, cost, null); } - paidFrom = withdraw(player.getUniqueId(), cost); - if (paidFrom == null) { - return new Result(Status.INSUFFICIENT_FUNDS, target, cost, null); + Payments.Charge taken = Payments.take(wallet, DenarWallet.CLASS_PICK, "class-pick", + Payments.payer(owner, character), settings.accounts(), Payments.cents(cost), + held -> hold(owner, character, target.getId(), held)); + if (taken.status() != Payments.Taken.PAID) { + Status status = taken.status() == Payments.Taken.UNAVAILABLE ? Status.UNAVAILABLE : Status.INSUFFICIENT_FUNDS; + return new Result(status, target, cost, null); } + payment = taken.payment(); } String oldClassId = character.getMMOClass(); character.setMMOClass(target.getId()); if (!applied(player, target.getId())) { character.setMMOClass(oldClassId); - refund(player.getUniqueId(), paidFrom, cost); + if (payment != null && !refund(owner, character)) { + tell(player, refundLater(payment)); + return new Result(Status.NOT_APPLIED, target, cost, payment.account()); + } return new Result(Status.NOT_APPLIED, target, cost, null); } if (option.isSubclass()) { character.setSubclassPicked(true); } - if (paidFrom != null) { - character.setPaidClassPicks(character.getPaidClassPicks() + 1); - PaidChangeRule rule = PaidChangeService.ruleFor(classStage()); - if (rule != null) { - // Shared with paid class-stage edits, so the next change costs the next price. - character.setPaidChangeCount(rule.getStageId(), character.getPaidChangeCount(rule.getStageId()) + 1); - } + if (payment != null) { + countPaid(character); } CharacterLifecycle.notifyClassChange(player, pd.getUniqueId(), character, oldClassId, target.getId()); RPCharacters.getPlayerManager().savePlayer(player); - return new Result(Status.CHOSEN, target, cost, paidFrom); + return new Result(Status.CHOSEN, target, cost, payment == null ? null : payment.account()); } - private static boolean applied(Player player, String classId) { - try { - return ClassService.applyClass(player, classId); - } catch (RuntimeException e) { - LOG.log(Level.WARNING, "[RPCharacters] Class " + classId + " could not be applied for " + player.getName(), e); - return false; + /** Holds the payment on the character and saves it; null drops a payment that was never taken. */ + private static boolean hold(UUID owner, RPCharacter character, String targetClass, Payment payment) { + character.setPendingClassPick(payment == null ? null : new PendingClassPick(targetClass, payment)); + if (Payments.save(owner, character) || payment == null) { + return true; } + character.setPendingClassPick(null); + return false; } - private static Account withdraw(UUID playerId, BigDecimal cost) { - for (Account account : settings.accounts()) { - if (wallet.balance(playerId, account).compareTo(cost) >= 0 && wallet.withdraw(playerId, account, cost)) { - return account; - } + /** Counts a kept paid pick and clears its hold. */ + private static void countPaid(RPCharacter character) { + character.setPendingClassPick(null); + character.setPaidClassPicks(character.getPaidClassPicks() + 1); + PaidChangeRule rule = PaidChangeService.ruleFor(classStage()); + if (rule != null) { + // Shared with paid class-stage edits, so the next change costs the next price. + character.setPaidChangeCount(rule.getStageId(), character.getPaidChangeCount(rule.getStageId()) + 1); + } + } + + /** + * Settles a paid pick left held by a crash or a failed refund. A debit that never applied is + * dropped; a pick whose class stuck is kept and counted; anything else is refunded with the + * stored refund ID. Returns the player message, or null when there is nothing to say. + */ + public static String recover(UUID owner, RPCharacter character) { + PendingClassPick pending = character == null ? null : character.getPendingClassPick(); + if (pending == null) { + return null; + } + Payments.Debit debit = Payments.debited(wallet, pending.payment()); + if (debit == Payments.Debit.NOT_APPLIED) { + character.setPendingClassPick(null); + } else if (debit == Payments.Debit.APPLIED && pending.targetClass().equalsIgnoreCase(character.getMMOClass())) { + countPaid(character); + } else if (debit == Payments.Debit.APPLIED) { + Payment payment = pending.payment(); + return refund(owner, character) ? RPTexts.SUCCESS + "Your class didn't change, so " + + PaidChangeService.formatCents(payment.cents()) + " went back to your " + + payment.account().displayName() + "." : refundLater(payment); } return null; } - private static void refund(UUID playerId, Account account, BigDecimal cost) { - if (account != null && !wallet.deposit(playerId, account, cost)) { - LOG.log(Level.WARNING, "[RPCharacters] Could not refund " + cost + " denars to " + playerId + " (" - + account.displayName() + ") for a class pick that was not applied. Refund it by hand."); + /** Refunds the held pick with its stored ID. False keeps the hold for the next join. */ + private static boolean refund(UUID owner, RPCharacter character) { + Payment payment = character.getPendingClassPick().payment(); + if (Payments.refund(wallet, payment) != Posting.APPLIED) { + LOG.warning("[RPCharacters] Could not post " + payment.refundOpId() + " for a class pick that was not applied;" + + " it is retried when the owner next joins."); + return false; + } + character.setPendingClassPick(null); + Payments.save(owner, character); + return true; + } + + private static String refundLater(Payment payment) { + return RPTexts.ERROR + "Your " + PaidChangeService.formatCents(payment.cents()) + + " class change refund couldn't go through yet. It will be retried when you next join."; + } + + private static void tell(Player player, String message) { + if (message != null) { + RPTexts.send(player, message); + } + } + + private static boolean applied(Player player, String classId) { + try { + return ClassService.applyClass(player, classId); + } catch (RuntimeException e) { + LOG.log(Level.WARNING, "[RPCharacters] Class " + classId + " could not be applied for " + player.getName(), e); + return false; } } @@ -308,7 +371,7 @@ public static String message(Result result) { case INSUFFICIENT_FUNDS -> RPTexts.ERROR + "Changing your class costs " + PaidChangeService.formatDenars(result.cost()) + ". You don't have that much in your pouch or the bank."; case NOT_APPLIED -> RPTexts.ERROR + "Your class couldn't be changed right now. Try again in a moment." - + (result.cost().signum() > 0 ? " You were not charged." : ""); + + (result.cost().signum() > 0 && result.paidFrom() == null ? " You were not charged." : ""); }; } diff --git a/src/main/java/net/tfminecraft/rpcharacters/classpick/PendingClassPick.java b/src/main/java/net/tfminecraft/rpcharacters/classpick/PendingClassPick.java new file mode 100644 index 00000000..f7fb4657 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/classpick/PendingClassPick.java @@ -0,0 +1,6 @@ +package net.tfminecraft.rpcharacters.classpick; + +import net.tfminecraft.rpcharacters.paidchange.Payment; + +/** A paid class pick between its debit and the class being applied, saved with the character. */ +public record PendingClassPick(String targetClass, Payment payment) {} diff --git a/src/main/java/net/tfminecraft/rpcharacters/database/CharacterClassPickFields.java b/src/main/java/net/tfminecraft/rpcharacters/database/CharacterClassPickFields.java index 8b99a7da..75f96b7e 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/database/CharacterClassPickFields.java +++ b/src/main/java/net/tfminecraft/rpcharacters/database/CharacterClassPickFields.java @@ -1,14 +1,18 @@ package net.tfminecraft.rpcharacters.database; import java.util.Map; +import java.util.logging.Logger; import org.json.simple.JSONObject; +import net.tfminecraft.rpcharacters.classpick.PendingClassPick; import net.tfminecraft.rpcharacters.objects.RPCharacter; +import net.tfminecraft.rpcharacters.paidchange.Payment; /** * Class picker state in the character file, under "class-picks": whether the free subclass pick - * is used ("subclass-picked") and how many picks were paid for ("paid"). + * is used ("subclass-picked"), how many picks were paid for ("paid") and a paid pick saved before + * its debit ("pending"). */ public final class CharacterClassPickFields { private CharacterClassPickFields() {} @@ -22,16 +26,32 @@ public static void load(RPCharacter character, JSONObject characterJson) { if (picks.get("paid") instanceof Number paid) { character.setPaidClassPicks(paid.intValue()); } + if (picks.get("pending") instanceof Map pending) { + try { + character.setPendingClassPick(new PendingClassPick((String) pending.get("target"), + Payment.fromJson((Map) pending.get("payment")))); + } catch (RuntimeException e) { + Logger.getLogger("RPCharacters").warning("[RPCharacters] Unreadable pending class pick " + pending + + "; refund it by hand if denars were taken."); + } + } } @SuppressWarnings("unchecked") public static void save(Map defaults, RPCharacter character) { - if (!character.hasPickedSubclass() && character.getPaidClassPicks() == 0) { + PendingClassPick pending = character.getPendingClassPick(); + if (!character.hasPickedSubclass() && character.getPaidClassPicks() == 0 && pending == null) { return; } JSONObject picks = new JSONObject(); picks.put("subclass-picked", character.hasPickedSubclass()); picks.put("paid", character.getPaidClassPicks()); + if (pending != null) { + JSONObject held = new JSONObject(); + held.put("target", pending.targetClass()); + held.put("payment", pending.payment().toJson()); + picks.put("pending", held); + } defaults.put("class-picks", picks); } } diff --git a/src/main/java/net/tfminecraft/rpcharacters/database/CharacterStageChangeFields.java b/src/main/java/net/tfminecraft/rpcharacters/database/CharacterStageChangeFields.java index c60c9925..b4852c27 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/database/CharacterStageChangeFields.java +++ b/src/main/java/net/tfminecraft/rpcharacters/database/CharacterStageChangeFields.java @@ -1,6 +1,7 @@ package net.tfminecraft.rpcharacters.database; import java.math.BigDecimal; +import java.util.Locale; import java.util.Map; import java.util.UUID; import java.util.logging.Logger; @@ -9,16 +10,31 @@ import net.tfminecraft.rpcharacters.objects.RPCharacter; import net.tfminecraft.rpcharacters.paidchange.DenarWallet.Account; +import net.tfminecraft.rpcharacters.paidchange.Payment; +import net.tfminecraft.rpcharacters.paidchange.Payments; import net.tfminecraft.rpcharacters.paidchange.PendingPaidChange; /** * 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". + * and denars held for an open paid stage under "paid-change-pending". A pending entry from before + * the character ledger (an account "payer" and an "amount", no operation IDs) loads as a legacy + * payment, refunded to the account's legacy heir with {@code refund-legacy::}. */ public final class CharacterStageChangeFields { private CharacterStageChangeFields() {} + /** The old wallet already took this from the account's money; null when it was free. */ + private static Payment legacyPayment(RPCharacter character, String stage, Map held) { + UUID accountId = UUID.fromString((String) held.get("payer")); + long cents = Payments.cents(new BigDecimal(String.valueOf(held.get("amount")))); + if (!(held.get("account") instanceof String name) || cents <= 0) { + return null; + } + return Payment.legacy(accountId, Account.valueOf(name), cents, + "refund-legacy:" + character.getId() + ":" + stage.toLowerCase(Locale.ROOT)); + } + public static void load(RPCharacter character, JSONObject characterJson) { if (character == null || characterJson == null) { return; @@ -31,7 +47,7 @@ public static void load(RPCharacter character, JSONObject characterJson) { } } if (characterJson.get("paid-change-pending") instanceof Map pending) { - character.setPendingPaidChange(readPending(pending)); + character.setPendingPaidChange(readPending(character, pending)); } if (characterJson.get("stage-revisions") instanceof Map revisions) { for (Map.Entry entry : revisions.entrySet()) { @@ -69,22 +85,22 @@ public static void save(Map defaults, RPCharacter character) { 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()); + if (pending.payment() != null) { + held.put("payment", pending.payment().toJson()); } - held.put("amount", pending.amount().toPlainString()); held.put("before", pending.before()); defaults.put("paid-change-pending", held); } } - private static PendingPaidChange readPending(Map held) { + private static PendingPaidChange readPending(RPCharacter character, Map held) { try { - Account account = held.get("account") instanceof String name ? Account.valueOf(name) : null; - 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")); + if (!(held.get("stage") instanceof String stage) || !(held.get("before") instanceof String)) { + throw new IllegalArgumentException("No stage or snapshot"); + } + Payment payment = held.get("payment") instanceof Map json ? Payment.fromJson(json) + : held.containsKey("payer") ? legacyPayment(character, stage, held) : null; + return new PendingPaidChange(stage, (String) held.get("label"), payment, (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."); diff --git a/src/main/java/net/tfminecraft/rpcharacters/managers/InventoryManager.java b/src/main/java/net/tfminecraft/rpcharacters/managers/InventoryManager.java index 5687efe5..cb12f0f9 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/managers/InventoryManager.java +++ b/src/main/java/net/tfminecraft/rpcharacters/managers/InventoryManager.java @@ -924,8 +924,8 @@ 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().signum() > 0) { - lore.add(t(RPTexts.MUTED + "and get " + RPTexts.GUI_WARN + PaidChangeService.formatDenars(pending.amount()) + if (pending.cents() > 0) { + lore.add(t(RPTexts.MUTED + "and get " + RPTexts.GUI_WARN + PaidChangeService.formatCents(pending.cents()) + RPTexts.MUTED + " back")); } } else if(cc != null) { diff --git a/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java b/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java index bc16ea51..236b8dd5 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java +++ b/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java @@ -379,6 +379,12 @@ public void savePlayer(Player p) { } } + /** Writes one loaded character's file now; false when it could not be written. */ + public boolean saveCharacterNow(UUID owner, RPCharacter character) { + PlayerData pd = get(owner); + return pd != null && db.trySaveCharacter(pd, character); + } + public static void stampActiveCharacterLocation(Player p) { PlayerData pd = get(p); if (pd == null || p == null) { @@ -424,12 +430,12 @@ 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. + // the normal schedule; settling it again after a crash replays the same ledger operation. for (RPCharacter c : loaded.getCharacters()) { // 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); + for (String recovered : new String[] {net.tfminecraft.rpcharacters.paidchange.PaidChangeService.recover(c), + net.tfminecraft.rpcharacters.classpick.ClassPickService.recover(p.getUniqueId(), c)}) { + if (recovered != null) RPTexts.send(p, recovered); } dirty |= net.tfminecraft.rpcharacters.creation.StageRevisions.refresh( c, net.tfminecraft.rpcharacters.loaders.StageLoader.oList); diff --git a/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java b/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java index eb768f77..56e5a0c8 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java +++ b/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java @@ -117,6 +117,8 @@ public class RPCharacter { 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; + /** A paid class pick saved before its debit, cleared once the class is applied or refunded. */ + private net.tfminecraft.rpcharacters.classpick.PendingClassPick pendingClassPick; /** True once the character has picked a subclass, which uses up its free subclass pick. */ private boolean subclassPicked; /** Class picks this character has paid for. */ @@ -266,6 +268,14 @@ public void setPaidClassPicks(int paidClassPicks) { this.paidClassPicks = Math.max(0, paidClassPicks); } + public net.tfminecraft.rpcharacters.classpick.PendingClassPick getPendingClassPick() { + return pendingClassPick; + } + + public void setPendingClassPick(net.tfminecraft.rpcharacters.classpick.PendingClassPick pending) { + this.pendingClassPick = pending; + } + public net.tfminecraft.rpcharacters.paidchange.PendingPaidChange getPendingPaidChange() { return pendingPaidChange; } diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWallet.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWallet.java index d07e486b..8b3abc45 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWallet.java +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWallet.java @@ -1,79 +1,108 @@ 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.Optional; import java.util.logging.Level; import java.util.logging.Logger; import org.bukkit.Bukkit; import org.bukkit.plugin.Plugin; +import net.tfminecraft.tlibs.character.CharacterRef; + /** - * 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. + * DenarEconomy's ledger API ({@code Denars}), 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. {@link CharacterRef} comes from TLibs, which both plugins share. */ public final class DenarEconomyWallet implements DenarWallet { private static final Logger LOG = Logger.getLogger("RPCharacters"); private Plugin boundTo; + private Method ready; + private Method pouch; + private Method bank; private Method balance; - private Method apply; - private Class accounts; + private Method transfer; + private Method status; + private String mint; @Override public boolean available() { - return bind(); + return bind() && ready(); } @Override - public BigDecimal balance(UUID playerId, Account account) { + public long balance(CharacterRef character, Account account) { if (!bind()) { - return BigDecimal.ZERO; + return 0L; } try { - 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); + return ((Number) balance.invoke(null, accountId(character, account))).longValue(); } catch (ReflectiveOperationException | RuntimeException e) { LOG.log(Level.WARNING, "[RPCharacters] Could not read a DenarEconomy balance", e); - return BigDecimal.ZERO; + return 0L; } } @Override - public boolean withdraw(UUID playerId, Account account, BigDecimal amount) { - return amount.signum() > 0 && apply(playerId, account, amount.negate()); + public Posting withdraw(String opId, String reason, CharacterRef character, Account account, long cents) { + return cents <= 0 ? Posting.REFUSED : transfer(opId, reason, character, account, cents, true); } @Override - public boolean deposit(UUID playerId, Account account, BigDecimal amount) { - return amount.signum() > 0 && apply(playerId, account, amount); + public Posting deposit(String opId, String reason, CharacterRef character, Account account, long cents) { + return cents <= 0 ? Posting.REFUSED : transfer(opId, reason, character, account, cents, false); } - /** OfflineModifier.apply refuses a withdrawal the account can't cover. */ - private boolean apply(UUID playerId, Account account, BigDecimal amount) { + private Posting transfer(String opId, String reason, CharacterRef character, Account account, long cents, + boolean debit) { if (!bind()) { - return false; + return Posting.UNAVAILABLE; } try { - // 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())); + String own = accountId(character, account); + Object result = transfer.invoke(null, opId, reason, debit ? own : mint, debit ? mint : own, cents); + return Posting.valueOf(((Enum) result).name()); } catch (ReflectiveOperationException | RuntimeException e) { - LOG.log(Level.WARNING, "[RPCharacters] Could not change a DenarEconomy balance", e); - return false; + LOG.log(Level.WARNING, "[RPCharacters] Could not post " + opId + " to DenarEconomy", e); + return Posting.UNAVAILABLE; } } - @SuppressWarnings({"unchecked", "rawtypes"}) - private Object toDenar(Account account) { - return Enum.valueOf((Class) accounts, account == Account.BANK ? "BANK" : "POUCH"); + @Override + public Optional status(String opId) { + // A closed ledger also answers "never posted", so only trust the answer from an open one. + if (!available()) { + return Optional.of(Posting.UNAVAILABLE); + } + try { + Optional stored = (Optional) status.invoke(null, opId); + if (!ready()) { + return Optional.of(Posting.UNAVAILABLE); + } + return stored.map(result -> Posting.valueOf(((Enum) result).name())); + } catch (ReflectiveOperationException | RuntimeException e) { + LOG.log(Level.WARNING, "[RPCharacters] Could not read " + opId + " from DenarEconomy", e); + return Optional.of(Posting.UNAVAILABLE); + } + } + + private String accountId(CharacterRef character, Account account) throws ReflectiveOperationException { + return (String) (account == Account.BANK ? bank : pouch).invoke(null, character); + } + + private boolean ready() { + try { + return Boolean.TRUE.equals(ready.invoke(null)); + } catch (ReflectiveOperationException | RuntimeException e) { + LOG.log(Level.WARNING, "[RPCharacters] Could not ask DenarEconomy whether its ledger is open", e); + return false; + } } /** 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()) { @@ -83,15 +112,23 @@ private synchronized boolean bind() { 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); + Class denars = Class.forName("net.tfminecraft.denareconomy.api.Denars", true, + plugin.getClass().getClassLoader()); + Method[] api = {denars.getMethod("ready"), denars.getMethod("pouch", CharacterRef.class), + denars.getMethod("bank", CharacterRef.class), denars.getMethod("balance", String.class), + denars.getMethod("transfer", String.class, String.class, String.class, String.class, long.class), + denars.getMethod("status", String.class)}; + mint = (String) denars.getField("MINT").get(null); + ready = api[0]; + pouch = api[1]; + bank = api[2]; + balance = api[3]; + transfer = api[4]; + status = api[5]; boundTo = plugin; return true; - } catch (ReflectiveOperationException | RuntimeException e) { - LOG.log(Level.WARNING, "[RPCharacters] DenarEconomy has no OfflineModifier API; paid changes are off", e); + } catch (ReflectiveOperationException | RuntimeException | LinkageError e) { + LOG.log(Level.WARNING, "[RPCharacters] DenarEconomy has no Denars ledger API; paid changes are off", 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 28a836a9..aed8ba25 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarWallet.java +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarWallet.java @@ -1,10 +1,22 @@ package net.tfminecraft.rpcharacters.paidchange; -import java.math.BigDecimal; -import java.util.UUID; +import java.util.Optional; -/** The denar accounts a paid change is charged to. Amounts are exact, in cents (scale 2). */ +import net.tfminecraft.tlibs.character.CharacterRef; + +/** + * A character's denar accounts on the DenarEconomy ledger. Amounts are whole cents. Every change + * is a posting with an operation ID: posting the same ID again returns the first result without + * moving anything, so a retry never pays twice. + */ public interface DenarWallet { + /** Ledger reason for a paid character-stage change. */ + String PAID_CHANGE = "rpc:paid-change"; + /** Ledger reason for a paid class pick. */ + String CLASS_PICK = "rpc:class-pick"; + /** Ledger reason for money given back. */ + String REFUND = "rpc:refund"; + enum Account { POUCH, BANK; @@ -14,12 +26,31 @@ public String displayName() { } } + /** What became of a posting, mirroring DenarEconomy's result. Only APPLIED moved money. */ + enum Posting { + APPLIED, + /** The account could not cover it, or the account is frozen. */ + REFUSED, + /** The operation ID was already used for different money. */ + CONFLICT, + /** The ledger could not be reached, or could not say. */ + UNAVAILABLE + } + boolean available(); - BigDecimal balance(UUID playerId, Account account); + /** Balance in cents; zero when the ledger is unavailable. */ + long balance(CharacterRef character, Account account); + + /** Takes {@code cents} from the character's account into the mint, all or nothing. */ + Posting withdraw(String opId, String reason, CharacterRef character, Account account, long cents); - /** Takes the whole amount, or nothing when the account cannot cover it. */ - boolean withdraw(UUID playerId, Account account, BigDecimal amount); + /** Gives {@code cents} from the mint to the character's account. */ + Posting deposit(String opId, String reason, CharacterRef character, Account account, long cents); - boolean deposit(UUID playerId, Account account, BigDecimal amount); + /** + * The stored result of an operation: empty when it was never posted, UNAVAILABLE when the + * ledger can't say right now. + */ + Optional status(String opId); } diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeListener.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeListener.java index 7535bf21..ef523c2c 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeListener.java +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeListener.java @@ -11,7 +11,7 @@ /** * DenarEconomy disables before RPCharacters on shutdown, so held payments are settled here, - * while its accounts can still take a refund. + * while its ledger can still take a refund. */ public final class PaidChangeListener implements Listener { diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java index 0d6ff5b1..e0bc9f83 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java @@ -19,13 +19,16 @@ import net.tfminecraft.rpcharacters.objects.RPCharacter; import net.tfminecraft.rpcharacters.objects.trait.Trait; import net.tfminecraft.rpcharacters.paidchange.DenarWallet.Account; +import net.tfminecraft.rpcharacters.paidchange.DenarWallet.Posting; import net.tfminecraft.rpcharacters.utils.AgeFormatter; import net.tfminecraft.rpcharacters.utils.RPTexts; +import net.tfminecraft.tlibs.character.CharacterRef; /** - * 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. + * Pay to reopen a locked creation stage. The edited character pays from its own pouch or bank + * when the stage opens, and the payment stays held on the character until the edit session ends. + * A changed character keeps the payment and raises the next price; an unchanged one gets the + * denars back. */ public final class PaidChangeService { @@ -43,7 +46,11 @@ public enum Outcome { /** Nothing changed and the denars went back. */ REFUNDED, /** Nothing changed but the refund failed; the payment stays held for a later retry. */ - REFUND_FAILED + REFUND_FAILED, + /** The debit never went through, so there is nothing to keep or refund. */ + DISCARDED, + /** The ledger can't say whether the debit went through; the payment stays held. */ + UNCONFIRMED } private static final Logger LOG = Logger.getLogger("RPCharacters"); @@ -81,49 +88,73 @@ 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) { + /** + * Takes the next price from the character's first account that covers it. A paid charge is + * held on the character and saved before the debit is posted. + */ + public static ChargeResult charge(UUID ownerId, PaidChangeRule rule, RPCharacter character) { BigDecimal cost = rule.costAfter(character.getPaidChangeCount(rule.getStageId())); String before = snapshot(character); if (cost.signum() <= 0) { - return new ChargeResult(ChargeStatus.PAID, pending(rule, payerId, null, cost, before), cost); + return new ChargeResult(ChargeStatus.PAID, + new PendingPaidChange(rule.getStageId(), rule.getLabel(), null, before), cost); } if (!wallet.available()) { return new ChargeResult(ChargeStatus.UNAVAILABLE, null, cost); } - for (Account account : accountOrder) { - 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); + Payments.Charge taken = Payments.take(wallet, DenarWallet.PAID_CHANGE, rule.getStageId(), + Payments.payer(ownerId, character), accountOrder, Payments.cents(cost), + payment -> hold(ownerId, character, rule, payment, before)); + return switch (taken.status()) { + case PAID -> new ChargeResult(ChargeStatus.PAID, character.getPendingPaidChange(), cost); + case UNAVAILABLE -> new ChargeResult(ChargeStatus.UNAVAILABLE, null, cost); + default -> new ChargeResult(ChargeStatus.INSUFFICIENT_FUNDS, null, cost); + }; } - private static PendingPaidChange pending(PaidChangeRule rule, UUID payerId, Account account, BigDecimal amount, + /** Holds the payment on the character and saves it; null drops a payment that was never taken. */ + private static boolean hold(UUID ownerId, RPCharacter character, PaidChangeRule rule, Payment payment, String before) { - return new PendingPaidChange(rule.getStageId(), rule.getLabel(), payerId, account, amount, before); + PendingPaidChange previous = character.getPendingPaidChange(); + character.setPendingPaidChange( + payment == null ? null : new PendingPaidChange(rule.getStageId(), rule.getLabel(), payment, before)); + if (Payments.save(ownerId, character) || payment == null) { + return true; + } + character.setPendingPaidChange(previous); + return false; } /** - * 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. + * Settles the payment held on the character. A payment whose debit never applied is dropped + * without a refund. Otherwise a changed character keeps it and counts the change, and an + * unchanged one is refunded with the stored refund ID. The hold is cleared only once one of + * those happened. */ public static Outcome resolve(RPCharacter character) { PendingPaidChange pending = character.getPendingPaidChange(); if (pending == null) { return null; } + Payment payment = pending.payment(); + Payments.Debit debit = payment == null ? Payments.Debit.APPLIED : Payments.debited(wallet, payment); + if (debit == Payments.Debit.UNKNOWN) { + return Outcome.UNCONFIRMED; + } + if (debit == Payments.Debit.NOT_APPLIED) { + LOG.info("[RPCharacters] " + payment.debitOpId() + " never went through; dropping its hold."); + character.setPendingPaidChange(null); + return Outcome.DISCARDED; + } if (!snapshot(character).equals(pending.before())) { character.setPaidChangeCount(pending.stageId(), (int) Math.min(Integer.MAX_VALUE, (long) character.getPaidChangeCount(pending.stageId()) + 1)); character.setPendingPaidChange(null); return Outcome.KEPT; } - 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() - + " change; keeping it held to retry."); + if (payment != null && Payments.refund(wallet, payment) != Posting.APPLIED) { + LOG.log(Level.WARNING, "[RPCharacters] Could not post " + payment.refundOpId() + " for a " + + pending.label() + " change; keeping it held to retry."); return Outcome.REFUND_FAILED; } character.setPendingPaidChange(null); @@ -151,22 +182,20 @@ public static boolean payToOpen(Player player, CharacterCreation cc, Stage stage return false; } case INSUFFICIENT_FUNDS -> { - UUID id = player.getUniqueId(); + CharacterRef payer = Payments.payer(player.getUniqueId(), character); 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."); + + formatDenars(result.cost()) + ". You have " + formatCents(wallet.balance(payer, Account.POUCH)) + + " in your pouch and " + formatCents(wallet.balance(payer, Account.BANK)) + " in the bank."); return false; } default -> { } } 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. + // A paid hold is already set and saved; a free one needs no save. character.setPendingPaidChange(pending); - if (pending.amount().signum() > 0) { - RPTexts.send(player, RPTexts.SUCCESS + "Paid " + formatDenars(pending.amount()) + " from your " + if (pending.cents() > 0) { + RPTexts.send(player, RPTexts.SUCCESS + "Paid " + formatCents(pending.cents()) + " 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."); } @@ -206,14 +235,21 @@ private static String outcomeMessage(PendingPaidChange pending, Outcome outcome, return RPTexts.SUCCESS + "Your " + pending.label() + " change is paid for." + next; } case REFUNDED -> { - if (pending.amount().signum() <= 0) { + if (pending.cents() <= 0) { return null; } return RPTexts.SUCCESS + "Your " + pending.label() + " is unchanged, so " - + formatDenars(pending.amount()) + " went back to your " + pending.account().displayName() + "."; + + formatCents(pending.cents()) + " went back to your " + pending.account().displayName() + "."; + } + case DISCARDED -> { + return null; + } + case UNCONFIRMED -> { + return RPTexts.ERROR + "Your " + formatCents(pending.cents()) + " payment for a " + pending.label() + + " change couldn't be checked yet. It will be settled when you next join."; } default -> { - return RPTexts.ERROR + "Your " + formatDenars(pending.amount()) + " refund couldn't go through yet. " + return RPTexts.ERROR + "Your " + formatCents(pending.cents()) + " refund couldn't go through yet. " + "It will be retried when you next join."; } } @@ -257,6 +293,10 @@ public static List summaryLore(Stage stage, RPCharacter character, boole return lines; } + public static String formatCents(long cents) { + return formatDenars(Payments.denars(cents)); + } + public static String formatDenars(BigDecimal amount) { BigDecimal whole = amount.stripTrailingZeros(); String number = whole.scale() <= 0 diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/Payment.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/Payment.java new file mode 100644 index 00000000..53d27e06 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/Payment.java @@ -0,0 +1,73 @@ +package net.tfminecraft.rpcharacters.paidchange; + +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +import org.json.simple.JSONObject; + +import net.tfminecraft.rpcharacters.paidchange.DenarWallet.Account; +import net.tfminecraft.tlibs.character.CharacterRef; + +/** + * Denars a character pays, with the operation IDs that take and return them. The record is saved + * before the debit is posted, so a crash leaves enough to ask the ledger what happened. + * + *

A legacy payment was taken by the old account-wide wallet before characters had their own + * money: {@code payer} and {@code debitOpId} are null, and a refund goes to the account's legacy + * heir. + */ +public record Payment(CharacterRef payer, UUID legacyAccount, Account account, long cents, String debitOpId, + String refundOpId) { + + /** A new payment for {@code what} (a stage ID, or "class-pick"), with fresh operation IDs. */ + public static Payment open(CharacterRef payer, Account account, long cents, String what) { + String debit = "paid:" + payer.characterId() + ":" + what.toLowerCase(Locale.ROOT) + ":" + UUID.randomUUID(); + return new Payment(payer, null, account, cents, debit, "refund:" + debit.substring("paid:".length())); + } + + /** A payment the old wallet took from {@code accountId}; it refunds once with {@code refundOpId}. */ + public static Payment legacy(UUID accountId, Account account, long cents, String refundOpId) { + return new Payment(null, accountId, account, cents, null, refundOpId); + } + + public boolean isLegacy() { + return payer == null; + } + + @SuppressWarnings("unchecked") + public JSONObject toJson() { + JSONObject json = new JSONObject(); + if (isLegacy()) { + json.put("legacy-account", legacyAccount.toString()); + } else { + json.put("owner", payer.ownerId().toString()); + json.put("character", payer.characterId().toString()); + json.put("debit-op", debitOpId); + } + json.put("account", account.name()); + json.put("cents", cents); + json.put("refund-op", refundOpId); + return json; + } + + /** @throws RuntimeException when the entry is incomplete */ + public static Payment fromJson(Map json) { + Account account = Account.valueOf((String) json.get("account")); + long cents = ((Number) json.get("cents")).longValue(); + String refund = requireText(json.get("refund-op")); + if (json.get("legacy-account") instanceof String legacy) { + return legacy(UUID.fromString(legacy), account, cents, refund); + } + CharacterRef payer = new CharacterRef(UUID.fromString((String) json.get("owner")), + UUID.fromString((String) json.get("character"))); + return new Payment(payer, null, account, cents, requireText(json.get("debit-op")), refund); + } + + private static String requireText(Object value) { + if (!(value instanceof String text) || text.isBlank()) { + throw new IllegalArgumentException("Missing operation ID"); + } + return text; + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/Payments.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/Payments.java new file mode 100644 index 00000000..245ba0f8 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/Payments.java @@ -0,0 +1,150 @@ +package net.tfminecraft.rpcharacters.paidchange; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.function.BiPredicate; +import java.util.function.BooleanSupplier; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.logging.Logger; + +import net.tfminecraft.rpcharacters.RPCharacters; +import net.tfminecraft.rpcharacters.managers.PlayerManager; +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import net.tfminecraft.rpcharacters.paidchange.DenarWallet.Account; +import net.tfminecraft.rpcharacters.paidchange.DenarWallet.Posting; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.Characters; + +/** + * Charging a character and giving the money back, crash-safe. The payment is held on the + * character and written to disk before its debit is posted; settling asks the ledger whether that + * debit applied, and a refund always reuses the stored refund ID. + */ +public final class Payments { + public enum Taken { + PAID, + UNAVAILABLE, + INSUFFICIENT_FUNDS + } + + /** {@code payment} is set when PAID, and when the ledger could not say whether it was taken. */ + public record Charge(Taken status, Payment payment) {} + + /** Whether a held payment's debit went through. */ + public enum Debit { + APPLIED, + /** Never posted, or refused: nothing was taken. */ + NOT_APPLIED, + /** The ledger can't say right now. */ + UNKNOWN + } + + private static final Logger LOG = Logger.getLogger("RPCharacters"); + + /** Writes one character file now; false when it could not be written. */ + static BiPredicate persist = Payments::saveNow; + static BooleanSupplier heirsReady = Characters::legacyHeirsReady; + static Function> heirs = Characters::legacyHeir; + + private Payments() {} + + public static CharacterRef payer(UUID ownerId, RPCharacter character) { + return new CharacterRef(ownerId, UUID.fromString(character.getId())); + } + + /** Whole cents of a configured price. */ + public static long cents(BigDecimal amount) { + return amount.setScale(2, RoundingMode.HALF_UP).movePointRight(2).longValueExact(); + } + + public static BigDecimal denars(long cents) { + return BigDecimal.valueOf(cents, 2); + } + + /** Writes the character's file now, so a held payment survives a crash. */ + public static boolean save(UUID ownerId, RPCharacter character) { + return persist.test(ownerId, character); + } + + private static boolean saveNow(UUID ownerId, RPCharacter character) { + PlayerManager manager = RPCharacters.getPlayerManager(); + return manager != null && manager.saveCharacterNow(ownerId, character); + } + + /** + * Takes {@code cents} from the first account in {@code order} that covers it. {@code hold} + * records a payment on the character and saves it, returning false when it couldn't be saved; + * it is called with null to drop a payment whose debit never applied. + */ + public static Charge take(DenarWallet wallet, String reason, String what, CharacterRef payer, List order, + long cents, Predicate hold) { + for (Account account : order) { + if (wallet.balance(payer, account) < cents) { + continue; + } + Payment payment = Payment.open(payer, account, cents, what); + if (!hold.test(payment)) { + LOG.warning("[RPCharacters] Could not save the payment for " + payment.debitOpId() + "; nothing was taken."); + return new Charge(Taken.UNAVAILABLE, null); + } + Posting posted = wallet.withdraw(payment.debitOpId(), reason, payer, account, cents); + Debit debit = posted == Posting.APPLIED ? Debit.APPLIED : debited(wallet, payment); + if (debit == Debit.APPLIED) { + return new Charge(Taken.PAID, payment); + } + if (debit == Debit.UNKNOWN) { + LOG.warning("[RPCharacters] DenarEconomy could not confirm " + payment.debitOpId() + + "; the payment stays held and is settled when the owner next joins."); + return new Charge(Taken.UNAVAILABLE, payment); + } + hold.test(null); + if (posted != Posting.REFUSED) { + return new Charge(Taken.UNAVAILABLE, null); + } + } + return new Charge(Taken.INSUFFICIENT_FUNDS, null); + } + + /** Asks the ledger whether the payment's debit applied. A legacy payment was always taken. */ + public static Debit debited(DenarWallet wallet, Payment payment) { + if (payment.isLegacy()) { + return Debit.APPLIED; + } + Optional stored = wallet.status(payment.debitOpId()); + if (stored.isEmpty()) { + return Debit.NOT_APPLIED; + } + return switch (stored.get()) { + case APPLIED -> Debit.APPLIED; + case UNAVAILABLE -> Debit.UNKNOWN; + default -> Debit.NOT_APPLIED; + }; + } + + /** + * Gives the payment back with its stored refund ID, so a repeat never pays twice. A legacy + * payment goes to the account's legacy heir, and waits (UNAVAILABLE) until heirs are known. + */ + public static Posting refund(DenarWallet wallet, Payment payment) { + CharacterRef to = payment.payer(); + if (payment.isLegacy()) { + Optional heir = heirsReady.getAsBoolean() ? heirs.apply(payment.legacyAccount()) + : Optional.empty(); + if (heir.isEmpty()) { + LOG.warning("[RPCharacters] No legacy heir yet for " + payment.legacyAccount() + "; " + + payment.refundOpId() + " waits."); + return Posting.UNAVAILABLE; + } + to = heir.get(); + } + Posting result = wallet.deposit(payment.refundOpId(), DenarWallet.REFUND, to, payment.account(), payment.cents()); + if (result == Posting.CONFLICT) { + LOG.severe("[RPCharacters] Refund " + payment.refundOpId() + " clashes with an earlier posting; check it by hand."); + } + return result; + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PendingPaidChange.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PendingPaidChange.java index 54d0bbd4..bd54db61 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PendingPaidChange.java +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PendingPaidChange.java @@ -1,14 +1,19 @@ package net.tfminecraft.rpcharacters.paidchange; -import java.math.BigDecimal; -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. + * A paid stage held open on the character (and saved with it) until the change is kept or + * refunded. {@code payment} 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, BigDecimal amount, - String before) {} +public record PendingPaidChange(String stageId, String label, Payment payment, String before) { + + public long cents() { + return payment == null ? 0L : payment.cents(); + } + + public Account account() { + return payment == null ? null : payment.account(); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 6d837c24..ef3f18b6 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -102,9 +102,10 @@ class-selection: # Pay to change a choice after its stage's lock-time runs out (class is locked after 5d). # With class-selection enabled, the class rule prices the class picker instead of the edit stage. -# Players pay when they open the choice, and get the denars back if they leave without changing it. +# The changed character pays when the choice opens, and gets the denars back if it is left unchanged. # 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. +# accounts: the character's accounts to take the denars from, tried in order. The whole cost comes +# from one account. paid-changes: accounts: [pouch, bank] rules: diff --git a/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java b/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java index c85be783..ea93c3d8 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java @@ -56,6 +56,7 @@ import net.tfminecraft.rpcharacters.objects.races.Race; import net.tfminecraft.rpcharacters.paidchange.DenarWallet; import net.tfminecraft.rpcharacters.paidchange.DenarWallet.Account; +import net.tfminecraft.tlibs.character.CharacterRef; import net.tfminecraft.rpcharacters.persona.CharacterSlotService; import net.tfminecraft.rpcharacters.persona.PermissionGroupService; import net.tfminecraft.rpcharacters.playerlist.SkinPortrait; @@ -97,8 +98,8 @@ void setup() { when(actions.isCurrent(player, data)).thenReturn(true); wallet = mock(DenarWallet.class); when(wallet.available()).thenReturn(true); - when(wallet.balance(OWNER, Account.POUCH)).thenReturn(new BigDecimal("12.50")); - when(wallet.balance(OWNER, Account.BANK)).thenReturn(new BigDecimal("300.00")); + when(wallet.balance(any(CharacterRef.class), eq(Account.POUCH))).thenReturn(1_250L); + when(wallet.balance(any(CharacterRef.class), eq(Account.BANK))).thenReturn(30_000L); CharacterSelectView.wallet = wallet; CharacterSelectView.guilds = p -> "Ironhand"; CharacterSelectView.accountTextures = p -> p == player ? ACCOUNT_SKIN : null; @@ -223,7 +224,8 @@ void doesNothingForMissingArgumentsOrAStaleOwner() { void focusesTheMostRecentCharacterWithStripBustCardActionsAndSharedFooter() { Race race = mock(Race.class); when(race.getName()).thenReturn("&6Human"); - RPCharacter wren = character("wren", "Lady Wren", Status.ALIVE); + String wrenId = UUID.randomUUID().toString(); + RPCharacter wren = character(wrenId, "Lady Wren", Status.ALIVE); when(wren.getRace()).thenReturn(race); when(wren.getMMOClass()).thenReturn("SWORD_MASTER"); String birthday = FantasyCalendar.toIso(FantasyCalendar.getCurrentDate().minusYears(27).minusDays(3)); @@ -251,12 +253,14 @@ void focusesTheMostRecentCharacterWithStripBustCardActionsAndSharedFooter() { assertEquals(HoverEvent.Action.SHOW_TEXT, strip().get("‹").hoverEvent().action()); assertEquals(SkinPortrait.ROW_WIDTH, ui.last().portraitPart().width()); assertEquals(CharacterSelectView.placeholder(CharacterSelectView.LOADING), ui.last().portrait()); - assertEquals(List.of("wren|" + ACCOUNT_SKIN.url()), requested); + assertEquals(List.of(wrenId + "|" + ACCOUNT_SKIN.url()), requested); assertEquals("‹ Lady Wren Rowan ›\nLady Wren\nHuman • Sword master • Age 27, born " + FantasyCalendar.formatBirthday(birthday) + "\nPlayed 2d 3h 4m • Last played 2d 2h ago\n" + + "Pouch: 12.50 denars • Bank: 300 denars\n" + "Last location: world 120, 64, -31\n" + "2 of 4 character slots used • Shared by all your characters\n" - + "Guild: Ironhand • Pouch: 12.50 denars • Bank: 300 denars", ui.last().bodyText()); + + "Guild: Ironhand", ui.last().bodyText()); + verify(wallet, atLeastOnce()).balance(new CharacterRef(OWNER, UUID.fromString(wrenId)), Account.BANK); for (var part : ui.last().parts()) { if (part != ui.last().portraitPart()) assertEquals(300, part.width()); } @@ -802,30 +806,34 @@ void ordersLivingByLastPlayedThenNameAndMemorialsByDeath() { when(newer.getDiedAt()).thenReturn(Instant.parse("2026-06-01T00:00:00Z")); RPCharacter missing = character("m", "Missing", Status.MISSING); assertEquals(List.of(recent, unnamed, alpha, beta, newer, older, missing), CharacterSelectView.cards(data)); - assertEquals("Unnamed\nPlayed 0d 0h 0m", text(CharacterSelectView.characterCard(unnamed, null))); + assertEquals("Unnamed\nPlayed 0d 0h 0m", text(CharacterSelectView.characterCard(OWNER, unnamed, null))); when(unnamed.getName()).thenReturn(" "); - assertTrue(text(CharacterSelectView.characterCard(unnamed, null)).startsWith("Unnamed")); + assertTrue(text(CharacterSelectView.characterCard(OWNER, unnamed, null)).startsWith("Unnamed")); assertEquals(List.of(), CharacterSelectView.page(List.of(), 3)); } @Test - void accountValuesSayUnavailableInsteadOfZeroAndOmitUnreadableGuild() { + void balancesSayUnavailableInsteadOfZeroAndTheFooterOmitsAnUnreadableGuild() { when(wallet.available()).thenReturn(false); - CharacterSelectView.guilds = p -> null; - assertEquals("Shared by all your characters\nPouch and bank: unavailable", text(CharacterSelectView.accountSummary(player))); + RPCharacter cora = character(UUID.randomUUID().toString(), "Cora", Status.ALIVE); + assertEquals("Pouch and bank: unavailable", CharacterSelectView.balances(OWNER, cora)); + assertEquals("", CharacterSelectView.balances(OWNER, character("not-an-id", "Odd", Status.ALIVE))); verify(wallet, never()).balance(any(), any()); + CharacterSelectView.guilds = p -> null; + assertNull(CharacterSelectView.accountSummary(player)); + CharacterSelectView.open(player, data, actions); + assertTrue(ui.last().bodyText().endsWith("Pouch and bank: unavailable\n2 of 4 character slots used"), ui.last().bodyText()); CharacterSelectView.guilds = p -> ""; - assertEquals("Shared by all your characters\nGuild: none • Pouch and bank: unavailable", - text(CharacterSelectView.accountSummary(player))); + assertEquals("Shared by all your characters\nGuild: none", text(CharacterSelectView.accountSummary(player))); } @Test void cardsSkipBirthdaysThatCannotBeRead() { RPCharacter c = character("c", "Cora", Status.ALIVE); when(c.getBirthday()).thenReturn(" "); - assertEquals("Cora\nPlayed 0d 0h 0m", text(CharacterSelectView.characterCard(c, null))); + assertEquals("Cora\nPlayed 0d 0h 0m", text(CharacterSelectView.characterCard(OWNER, c, null))); when(c.getBirthday()).thenReturn("not a date"); - assertEquals("Cora\nPlayed 0d 0h 0m", text(CharacterSelectView.characterCard(c, null))); + assertEquals("Cora\nPlayed 0d 0h 0m", text(CharacterSelectView.characterCard(OWNER, c, null))); } @Test diff --git a/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassPickTest.java b/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassPickTest.java index 8c41c6a5..209496f4 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassPickTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassPickTest.java @@ -57,10 +57,18 @@ import net.tfminecraft.rpcharacters.objects.RPCharacter; import net.tfminecraft.rpcharacters.paidchange.DenarWallet; import net.tfminecraft.rpcharacters.paidchange.DenarWallet.Account; +import net.tfminecraft.rpcharacters.paidchange.FakeLedger; +import net.tfminecraft.rpcharacters.paidchange.Payment; +import net.tfminecraft.rpcharacters.paidchange.Payments; +import net.tfminecraft.rpcharacters.paidchange.PaymentsFixture; +import net.tfminecraft.rpcharacters.database.CharacterClassPickFields; +import net.tfminecraft.tlibs.character.CharacterRef; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; class ClassPickTest { ServerMock server; PlayerMock player; RuntimeTestState state; MMOCore previousMmo; ClassManager classes; - PlayerData account; RPCharacter character; PlayerManager playerManager; FakeWallet wallet; + PlayerData account; RPCharacter character; PlayerManager playerManager; FakeLedger wallet; final List saves = new ArrayList<>(); PlayerClass warrior, mage, berserker, spellblade, hidden; final AtomicInteger level = new AtomicInteger(1); final Map classMap = new LinkedHashMap<>(); @@ -77,7 +85,7 @@ class ClassPickTest { MockedStatic boundary(Class type) { var result = mockStatic(type); boundaries.add(result); return result; } @BeforeEach void setup() throws Exception { - server = MockBukkit.mock(); state = new RuntimeTestState(RPCharacters.class, StageLoader.class, PaidChangeService.class); previousMmo = MMOCore.plugin; + server = MockBukkit.mock(); state = new RuntimeTestState(RPCharacters.class, StageLoader.class, PaidChangeService.class, Payments.class); previousMmo = MMOCore.plugin; Cache.attributes = new ArrayList<>(); Cache.professions = new ArrayList<>(); player = server.addPlayer("Picker"); MMOCore.plugin = mock(MMOCore.class); classes = mock(ClassManager.class); @@ -97,7 +105,8 @@ class ClassPickTest { classService = boundary(ClassService.class); classService.when(() -> ClassService.applyClass(eq(player), anyString())).thenReturn(true); var mmo = mock(net.Indyuce.mmocore.api.player.PlayerData.class); when(mmo.getLevel()).thenAnswer(call -> level.get()); boundary(net.Indyuce.mmocore.api.player.PlayerData.class).when(() -> net.Indyuce.mmocore.api.player.PlayerData.get(player)).thenReturn(mmo); - wallet = new FakeWallet(); ClassPickService.setWallet(wallet); ClassPickService.configure(Settings.DEFAULTS); + wallet = new FakeLedger(); ClassPickService.setWallet(wallet); ClassPickService.configure(Settings.DEFAULTS); + PaymentsFixture.saves((owner, saved) -> saves.add(owner + "/" + saved.getId())); Logger.getLogger("RPCharacters").addHandler(handler); } @@ -182,7 +191,7 @@ Settings settings(boolean enabled, boolean infinite) { locks.when(() -> StageEditLock.lockRemainingMs(stage, character)).thenReturn(0L); assertEquals("Head / First subclass: Free / Class changes: Free", lore.get()); locks.when(() -> StageEditLock.canEdit(player, stage, character)).thenReturn(false); assertEquals(new BigDecimal("100.00"), ClassPickService.price(character, magePick)); assertEquals("Head / First subclass: Free / Class changes: 100 denars / Then: 1,000 denars", lore.get()); - wallet.balances.put(Account.POUCH, new BigDecimal("150")); var paid = ClassPickService.choose(player, "mage"); + wallet.set(Account.POUCH, 15_000); var paid = ClassPickService.choose(player, "mage"); assertEquals(Status.CHOSEN, paid.status()); assertEquals(new BigDecimal("100.00"), paid.cost()); assertEquals(1, character.getPaidChangeCount("class_selection_stage")); assertEquals(new BigDecimal("1000.00"), ClassPickService.price(character, ClassPickService.option("warrior")), "Each paid change raises the next price"); } @@ -196,11 +205,12 @@ Settings settings(boolean enabled, boolean infinite) { } @Test void paidPicksTakeTheWholeCostFromTheFirstAccountThatCoversIt() { - level.set(2); character.setSubclassPicked(true); wallet.balances.put(Account.POUCH, new BigDecimal("100")); wallet.balances.put(Account.BANK, new BigDecimal("500")); + level.set(2); character.setSubclassPicked(true); wallet.set(Account.POUCH, 10_000); wallet.set(Account.BANK, 50_000); var result = ClassPickService.choose(player, "mage"); - assertEquals(Status.CHOSEN, result.status()); assertEquals(Account.BANK, result.paidFrom()); assertEquals(new BigDecimal("300.00"), wallet.balances.get(Account.BANK).setScale(2)); + assertEquals(Status.CHOSEN, result.status()); assertEquals(Account.BANK, result.paidFrom()); assertEquals(30_000, wallet.get(Account.BANK)); assertNull(character.getPendingClassPick()); assertEquals(1, character.getPaidClassPicks()); assertTrue(ChatColor.stripColor(ClassPickService.message(result)).contains("Paid 200 denars from your bank.")); - wallet.balances.put(Account.POUCH, new BigDecimal("1000")); wallet.failWithdraw = true; assertEquals(Status.INSUFFICIENT_FUNDS, ClassPickService.choose(player, "warrior").status()); + wallet.set(Account.POUCH, 100_000); wallet.set(Account.BANK, 0); wallet.rejectPouch = true; assertEquals(Status.INSUFFICIENT_FUNDS, ClassPickService.choose(player, "warrior").status()); + assertNull(character.getPendingClassPick()); } @Test void picksAreRefusedWithoutChargingWhenTheyCannotGoAhead() { @@ -218,12 +228,15 @@ Settings settings(boolean enabled, boolean infinite) { } @Test void aPickMmoCoreRejectsKeepsTheOldClassAndRefundsThePayment() { - classService.when(() -> ClassService.applyClass(player, "mage")).thenReturn(false); wallet.balances.put(Account.POUCH, new BigDecimal("250")); + classService.when(() -> ClassService.applyClass(player, "mage")).thenReturn(false); wallet.set(Account.POUCH, 25_000); var result = ClassPickService.choose(player, "mage"); - assertEquals(Status.NOT_APPLIED, result.status()); assertEquals("WARRIOR", character.getMMOClass()); assertEquals(0, new BigDecimal("250").compareTo(wallet.balances.get(Account.POUCH))); + assertEquals(Status.NOT_APPLIED, result.status()); assertEquals("WARRIOR", character.getMMOClass()); assertEquals(25_000, wallet.get(Account.POUCH)); assertNull(character.getPendingClassPick()); assertTrue(ChatColor.stripColor(ClassPickService.message(result)).contains("You were not charged.")); - wallet.failDeposit = true; ClassPickService.choose(player, "mage"); - assertTrue(logs.stream().anyMatch(record -> record.getMessage().contains("Refund it by hand"))); + wallet.depositsDown = true; var held = ClassPickService.choose(player, "mage"); + assertTrue(logs.stream().anyMatch(record -> record.getMessage().contains("retried when the owner next joins"))); + assertEquals(Account.POUCH, held.paidFrom()); assertFalse(ChatColor.stripColor(ClassPickService.message(held)).contains("charged")); + assertTrue(messages().contains("refund couldn't go through yet")); wallet.depositsDown = false; + assertTrue(ClassPickService.recover(player.getUniqueId(), character).contains("went back to your pouch")); assertEquals(25_000, wallet.get(Account.POUCH)); ClassPickService.configure(settings(true, true)); var free = ClassPickService.choose(player, "mage"); assertEquals(Status.NOT_APPLIED, free.status()); assertFalse(ChatColor.stripColor(ClassPickService.message(free)).contains("charged")); var classless = new RPCharacter(player); when(account.getActiveCharacter()).thenReturn(classless); @@ -302,7 +315,7 @@ Inventory openSubclasses() { ClassPickGui.click(player, holder, 10); assertEquals("MAGE", character.getMMOClass()); assertTrue(messages().contains("can't be picked")); assertEquals(Status.UNKNOWN_CLASS, ClassPickService.choose(player, "spellblade").status()); - classService.verifyNoInteractions(); assertTrue(wallet.balances.isEmpty()); + classService.verifyNoInteractions(); assertTrue(wallet.attempts.isEmpty()); } @Test void hiddenCurrentBaseStillAllowsItsOwnSubclasses() { @@ -385,18 +398,82 @@ Inventory openSubclasses() { listener.onCommand(disabled); assertFalse(disabled.isCancelled()); } - static final class FakeWallet implements DenarWallet { - final Map balances = new EnumMap<>(Account.class); - boolean available = true, failWithdraw, failDeposit; - @Override public boolean available() { return available; } - @Override public BigDecimal balance(UUID playerId, Account account) { return balances.getOrDefault(account, BigDecimal.ZERO); } - @Override public boolean withdraw(UUID playerId, Account account, BigDecimal amount) { - if (failWithdraw) return false; - balances.put(account, balance(playerId, account).subtract(amount)); return true; - } - @Override public boolean deposit(UUID playerId, Account account, BigDecimal amount) { - if (failDeposit) return false; - balances.put(account, balance(playerId, account).add(amount)); return true; - } + @Test void aPaidPickIsSavedBeforeItsDebitAndClearedOnceApplied() { + wallet.set(Account.POUCH, 50_000); List seen = new ArrayList<>(); + PaymentsFixture.saves((owner, saved) -> { + PendingClassPick held = saved.getPendingClassPick(); + seen.add(held.targetClass() + " " + held.payment().debitOpId().startsWith("paid:" + saved.getId() + ":class-pick:") + + " posted=" + wallet.stored.containsKey(held.payment().debitOpId())); + return true; + }); + assertEquals(Status.CHOSEN, ClassPickService.choose(player, "mage").status()); + assertEquals(List.of("mage true posted=false"), seen); assertNull(character.getPendingClassPick()); + assertEquals(DenarWallet.CLASS_PICK, wallet.applied.get(0).reason()); + assertEquals(new CharacterRef(player.getUniqueId(), UUID.fromString(character.getId())), wallet.applied.get(0).character()); + } + + @Test void anUnsavablePickTakesNothing() { + wallet.set(Account.POUCH, 50_000); PaymentsFixture.saves((owner, saved) -> false); + assertEquals(Status.UNAVAILABLE, ClassPickService.choose(player, "mage").status()); + assertTrue(wallet.attempts.isEmpty()); assertNull(character.getPendingClassPick()); assertEquals("WARRIOR", character.getMMOClass()); + } + + @Test void aCrashBetweenDebitAndClassRefundsOnceOrKeepsAnAppliedPick() { + wallet.set(Account.POUCH, 50_000); List onDisk = new ArrayList<>(); + PaymentsFixture.saves((owner, saved) -> onDisk.add(picksJson(saved))); + assertEquals(Status.CHOSEN, ClassPickService.choose(player, "mage").status()); + RPCharacter crashed = new RPCharacter(player); crashed.setId(character.getId()); crashed.setMMOClass("warrior"); + CharacterClassPickFields.load(crashed, onDisk.get(0)); + RPCharacter again = new RPCharacter(player); again.setId(character.getId()); again.setMMOClass("warrior"); + CharacterClassPickFields.load(again, onDisk.get(0)); + assertEquals("mage", crashed.getPendingClassPick().targetClass()); + + assertTrue(ClassPickService.recover(player.getUniqueId(), crashed).contains("200 denars went back to your pouch")); + assertNull(crashed.getPendingClassPick()); assertEquals(50_000, wallet.get(Account.POUCH)); + ClassPickService.recover(player.getUniqueId(), again); + assertEquals(50_000, wallet.get(Account.POUCH)); assertEquals(1, wallet.count(false)); + + RPCharacter stuck = new RPCharacter(player); stuck.setMMOClass("mage"); + CharacterClassPickFields.load(stuck, onDisk.get(0)); + assertNull(ClassPickService.recover(player.getUniqueId(), stuck)); + assertNull(stuck.getPendingClassPick()); assertEquals(1, stuck.getPaidClassPicks()); assertEquals(1, wallet.count(false)); + assertNull(ClassPickService.recover(player.getUniqueId(), null)); assertNull(ClassPickService.recover(player.getUniqueId(), stuck)); + } + + @Test void aRefusedOrUncertainDebitNeverRefundsAndBlocksPicksUntilKnown() { + wallet.set(Account.POUCH, 50_000); List onDisk = new ArrayList<>(); + PaymentsFixture.saves((owner, saved) -> onDisk.add(picksJson(saved))); + wallet.loseReplies = true; wallet.statusDown = true; + assertEquals(Status.UNAVAILABLE, ClassPickService.choose(player, "mage").status()); + assertEquals("WARRIOR", character.getMMOClass()); assertEquals(30_000, wallet.get(Account.POUCH)); + assertEquals(Status.UNAVAILABLE, ClassPickService.choose(player, "mage").status(), "an unconfirmed pick blocks the next"); + assertEquals(1, wallet.attempts.size()); + wallet.loseReplies = false; wallet.statusDown = false; + assertEquals(Status.CHOSEN, ClassPickService.choose(player, "mage").status()); + assertTrue(messages().contains("200 denars went back to your pouch")); assertEquals(30_000, wallet.get(Account.POUCH)); + + RPCharacter refused = new RPCharacter(player); refused.setMMOClass("warrior"); + CharacterClassPickFields.load(refused, onDisk.get(0)); + wallet.stored.put(refused.getPendingClassPick().payment().debitOpId(), DenarWallet.Posting.REFUSED); + long deposits = wallet.count(false); + assertNull(ClassPickService.recover(player.getUniqueId(), refused)); assertNull(refused.getPendingClassPick()); + assertEquals(deposits, wallet.count(false)); + } + + @Test void pendingPicksSurviveASaveAndUnreadableOnesAreDropped() { + Payment payment = Payment.open(new CharacterRef(player.getUniqueId(), UUID.fromString(character.getId())), Account.BANK, 500, "class-pick"); + character.setPendingClassPick(new PendingClassPick("MAGE", payment)); + RPCharacter loaded = new RPCharacter(player); CharacterClassPickFields.load(loaded, picksJson(character)); + assertEquals(character.getPendingClassPick(), loaded.getPendingClassPick()); + RPCharacter broken = new RPCharacter(player); + JSONObject json = new JSONObject(); json.put("class-picks", Map.of("pending", Map.of("target", "MAGE"))); + CharacterClassPickFields.load(broken, json); assertNull(broken.getPendingClassPick()); + } + + @SuppressWarnings("unchecked") + private static JSONObject picksJson(RPCharacter character) { + HashMap saved = new HashMap<>(); CharacterClassPickFields.save(saved, character); + try { return (JSONObject) new JSONParser().parse(new JSONObject(saved).toJSONString()); } + catch (Exception e) { throw new IllegalStateException(e); } } } diff --git a/src/test/java/net/tfminecraft/rpcharacters/database/CharacterFieldsRuntimeTest.java b/src/test/java/net/tfminecraft/rpcharacters/database/CharacterFieldsRuntimeTest.java index 9e437941..a99fb683 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/database/CharacterFieldsRuntimeTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/database/CharacterFieldsRuntimeTest.java @@ -9,7 +9,9 @@ import net.tfminecraft.rpcharacters.objects.PlayerData; import net.tfminecraft.rpcharacters.objects.RPCharacter; import net.tfminecraft.rpcharacters.paidchange.DenarWallet.Account; +import net.tfminecraft.rpcharacters.paidchange.Payment; import net.tfminecraft.rpcharacters.paidchange.PendingPaidChange; +import net.tfminecraft.tlibs.character.CharacterRef; import net.tfminecraft.rpcharacters.tutorial.TutorialService; import org.json.simple.JSONObject; import org.json.simple.parser.JSONParser; @@ -67,10 +69,11 @@ class CharacterFieldsRuntimeTest { } @Test void paymentAndRevisionStateRoundTripsThroughJsonWithoutLosingDecimalAmounts() throws Exception { - var character = new RPCharacter(null); var payer = UUID.randomUUID(); + var character = new RPCharacter(null); var payer = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); character.setPaidChangeCount("class", 2); character.setStageRevision("class", 3, 1234); - for (Account account : Arrays.asList(Account.BANK, null)) { - var pending = new PendingPaidChange("class", "Class", payer, account, new BigDecimal("12.34"), "snapshot"); + for (Payment payment : Arrays.asList(Payment.open(payer, Account.BANK, 1_234, "Class"), + Payment.legacy(payer.ownerId(), Account.POUCH, 1_234, "refund-legacy:x:class"), null)) { + var pending = new PendingPaidChange("class", "Class", payment, "snapshot"); character.setPendingPaidChange(pending); var saved = new LinkedHashMap(); CharacterStageChangeFields.save(saved, character); var loaded = new RPCharacter(null); CharacterStageChangeFields.load(loaded, (JSONObject) new JSONParser().parse(json(saved).toJSONString())); assertEquals(pending, loaded.getPendingPaidChange()); assertEquals(2, loaded.getPaidChangeCount("class")); diff --git a/src/test/java/net/tfminecraft/rpcharacters/managers/InventoryManagerItemsTest.java b/src/test/java/net/tfminecraft/rpcharacters/managers/InventoryManagerItemsTest.java index 148e251e..efa25b53 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/managers/InventoryManagerItemsTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/managers/InventoryManagerItemsTest.java @@ -54,9 +54,9 @@ class InventoryManagerItemsTest extends InventoryFixture { assertTrue(lore(manager.createCancelItem(null)).contains("Cancel the edit")); assertEquals("Cancel Creation", name(manager.createCancelItem(creation))); assertTrue(lore(manager.createCancelItem(creation)).contains("not reversible")); - when(creation.getPendingPaidChange()).thenReturn(new PendingPaidChange("class", "old class", player.getUniqueId(), DenarWallet.Account.POUCH, new BigDecimal("125"), "{}")); + when(creation.getPendingPaidChange()).thenReturn(new PendingPaidChange("class", "old class", net.tfminecraft.rpcharacters.paidchange.Payment.legacy(player.getUniqueId(), DenarWallet.Account.POUCH, 12_500, "refund-legacy:x:class"), "{}")); String refund = lore(manager.createCancelItem(creation)); assertTrue(refund.contains("Keep your old class")); assertTrue(refund.contains("125")); - when(creation.getPendingPaidChange()).thenReturn(new PendingPaidChange("class", "old class", player.getUniqueId(), null, BigDecimal.ZERO, "{}")); + when(creation.getPendingPaidChange()).thenReturn(new PendingPaidChange("class", "old class", null, "{}")); assertFalse(lore(manager.createCancelItem(creation)).contains("back")); } @@ -251,7 +251,7 @@ abstract class InventoryFixture { rpc = boundary(RPCharacters.class); rpc.when(() -> RPCharacters.getSkull(any())).thenAnswer(call -> new ItemStack(Material.PLAYER_HEAD)); slots = boundary(CharacterSlotService.class); slots.when(() -> CharacterSlotService.hasFreeSlot(any(), any())).thenReturn(true); slots.when(() -> CharacterSlotService.getMaxAliveCharacters(any())).thenReturn(2); slots.when(CharacterSlotService::getDisplaySlotCount).thenAnswer(call -> Cache.characterSlots.size()); slots.when(() -> CharacterSlotService.shouldShowLockedSlot(anyInt(), anyInt())).thenReturn(true); slots.when(() -> CharacterSlotService.getUnlockRequirementLore(anyInt())).thenAnswer(call -> "Unlock slot " + call.getArgument(0)); cooldowns = boundary(PermissionGroupService.class); permissions = boundary(Permissions.class); locks = boundary(StageEditLock.class); locks.when(() -> StageEditLock.canEdit(any(), any(), any())).thenReturn(true); - paid = boundary(PaidChangeService.class); paid.when(() -> PaidChangeService.summaryLore(any(),any(),anyBoolean())).thenReturn(null); paid.when(() -> PaidChangeService.formatDenars(any())).thenAnswer(call -> ((BigDecimal)call.getArgument(0)).stripTrailingZeros().toPlainString()); + paid = boundary(PaidChangeService.class); paid.when(() -> PaidChangeService.summaryLore(any(),any(),anyBoolean())).thenReturn(null); paid.when(() -> PaidChangeService.formatDenars(any())).thenAnswer(call -> ((BigDecimal)call.getArgument(0)).stripTrailingZeros().toPlainString()); paid.when(() -> PaidChangeService.formatCents(anyLong())).thenAnswer(call -> String.valueOf(call.getArgument(0) / 100)); var identity = boundary(DisplayIdentityService.class); identity.when(() -> DisplayIdentityService.resolveDisplayTab(any(RPCharacter.class))).thenReturn("Alias"); var persona = boundary(PersonaService.class); persona.when(() -> PersonaService.resolveGender(any(RPCharacter.class))).thenReturn("Woman"); persona.when(() -> PersonaService.resolveAge(any(RPCharacter.class))).thenReturn("25"); persona.when(() -> PersonaService.resolveBirthday(any(RPCharacter.class))).thenReturn("1/1/1700"); persona.when(() -> PersonaService.resolveDescription(any(RPCharacter.class))).thenReturn("A thoughtful traveller."); var death = boundary(PermadeathService.class); var risk = mock(PermadeathRisk.class); when(risk.toLoreLines()).thenReturn(List.of("Risk boundary")); death.when(() -> PermadeathService.computeRisk(any())).thenReturn(risk); diff --git a/src/test/java/net/tfminecraft/rpcharacters/managers/PlayerManagerLifecycleTest.java b/src/test/java/net/tfminecraft/rpcharacters/managers/PlayerManagerLifecycleTest.java index 77859041..80087aa0 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/managers/PlayerManagerLifecycleTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/managers/PlayerManagerLifecycleTest.java @@ -450,6 +450,16 @@ void unsuccessfulMmoSetupNeverMarksASharedSessionReady() { attributes.verify(() -> AttributePointService.syncOnActivate(character), never()); } + @Test + void savingOneCharacterNowWritesOnlyALoadedOwnersFile() { + UUID id = player.getUniqueId(); when(database.trySaveCharacter(data, character)).thenReturn(true); + assertTrue(manager.saveCharacterNow(id, character)); + assertTrue(net.tfminecraft.rpcharacters.paidchange.Payments.save(id, character)); + assertFalse(manager.saveCharacterNow(UUID.randomUUID(), character)); + rpc.when(RPCharacters::getPlayerManager).thenReturn(null); + assertFalse(net.tfminecraft.rpcharacters.paidchange.Payments.save(id, character)); + } + @Test @SuppressWarnings("deprecation") void joiningLoadsOnceRecoversPaidChangesAndDefersMmoUntilReady() { diff --git a/src/test/java/net/tfminecraft/rpcharacters/objects/RPCharacterRuntimeTest.java b/src/test/java/net/tfminecraft/rpcharacters/objects/RPCharacterRuntimeTest.java index 05b9afa4..7dcf8a09 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/objects/RPCharacterRuntimeTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/objects/RPCharacterRuntimeTest.java @@ -133,7 +133,9 @@ class RPCharacterRuntimeTest { @Test void progressCountersClampAndExposeReadOnlyStageState() { var c=character(); c.setMMOClass(null); assertFalse(c.hasMMOClass()); c.setSubclassPicked(true); assertTrue(c.hasPickedSubclass()); c.setPaidClassPicks(-2); assertEquals(0,c.getPaidClassPicks()); c.setPaidClassPicks(3); assertEquals(3,c.getPaidClassPicks()); c.setPvpLethal(false); assertFalse(c.isPvpLethal()); c.setPaidChangeCount(null,3); assertEquals(0,c.getPaidChangeCount(null)); c.setPaidChangeCount("class",2); assertEquals(2,c.getPaidChangeCounts().get("class")); c.setPaidChangeCount("class",0); assertEquals(0,c.getPaidChangeCount("class")); assertThrows(UnsupportedOperationException.class,() -> c.getPaidChangeCounts().put("bad",1)); - var pending=new PendingPaidChange("class","class",player.getUniqueId(),null,BigDecimal.ZERO,"{}"); c.setPendingPaidChange(pending); assertSame(pending,c.getPendingPaidChange()); c.setPendingPaidChange(null); assertNull(c.getPendingPaidChange()); + var pending=new PendingPaidChange("class","class",null,"{}"); c.setPendingPaidChange(pending); assertSame(pending,c.getPendingPaidChange()); c.setPendingPaidChange(null); assertNull(c.getPendingPaidChange()); + assertEquals(0,pending.cents()); assertNull(pending.account()); + var pick=new net.tfminecraft.rpcharacters.classpick.PendingClassPick("mage",null); c.setPendingClassPick(pick); assertSame(pick,c.getPendingClassPick()); c.setPendingClassPick(null); assertNull(c.getPendingClassPick()); c.setStageRevision(null,3,9); assertEquals(0,c.getStageRevision(null)); assertEquals(0,c.getStageRevisionSince(null)); c.setStageRevision("class",2,9); assertEquals(2,c.getStageRevision("class")); assertEquals(9,c.getStageRevisionSince("class")); c.setStageRevision("class",3,0); assertEquals(0,c.getStageRevisionSince("class")); assertEquals(Map.of("class",3),c.getStageRevisions()); assertThrows(UnsupportedOperationException.class,() -> c.getStageRevisions().clear()); c.setEvilRpStrikes(-1); assertEquals(0,c.getEvilRpStrikes()); c.setEvilRpStrikes(3); assertEquals(3,c.getEvilRpStrikes()); c.setEvilRpSessionEndsAtMs(-1); assertEquals(0,c.getEvilRpSessionEndsAtMs()); c.setEvilRpSessionEndsAtMs(50); assertEquals(50,c.getEvilRpSessionEndsAtMs()); c.setLastStrikeAtMs(-1); assertEquals(0,c.getLastStrikeAtMs()); c.setLastStrikeAtMs(20); assertEquals(20,c.getLastStrikeAtMs()); c.setFoodValue(-1); assertEquals(0,c.getFoodValue()); c.setFoodValue(1000); assertEquals(200,c.getFoodValue()); c.setDietScore(-1); assertEquals(0,c.getDietScore()); c.setDietScore(100); assertEquals(40,c.getDietScore()); c.setRawDietScore(100); assertEquals(100,c.getRawDietScore()); c.setRawDietScore(-1); assertEquals(0,c.getRawDietScore()); c.setLastDietTierId("healthy"); assertEquals("healthy",c.getLastDietTierId()); diff --git a/src/test/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWalletTest.java b/src/test/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWalletTest.java index 79181d6b..49c53b7c 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWalletTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWalletTest.java @@ -5,12 +5,13 @@ import java.io.ByteArrayOutputStream; import java.lang.reflect.Proxy; -import java.math.BigDecimal; import java.net.URL; import java.net.URLClassLoader; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Map; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; @@ -24,95 +25,143 @@ import org.junit.jupiter.api.io.TempDir; import net.tfminecraft.rpcharacters.paidchange.DenarWallet.Account; +import net.tfminecraft.rpcharacters.paidchange.DenarWallet.Posting; +import net.tfminecraft.tlibs.character.CharacterRef; class DenarEconomyWalletTest { @TempDir static Path classes; + static final CharacterRef REF = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + static final String POUCH = "char:" + REF.characterId() + ":pouch", BANK = "char:" + REF.characterId() + ":bank"; - /** Exact public OfflineModifier signatures from DenarEconomy, isolated like a plugin loader. */ + /** DenarEconomy 0.3.0's public Denars signatures over a tiny ledger, isolated like a plugin loader. */ @BeforeAll static void compileExternalApi() throws Exception { - Path enums = classes.resolve("net/tfminecraft/denareconomy/enums/Accounts.java"); - Path api = classes.resolve("net/tfminecraft/denareconomy/accounts/OfflineModifier.java"); - Files.createDirectories(enums.getParent()); Files.createDirectories(api.getParent()); - Files.writeString(enums, "package net.tfminecraft.denareconomy.enums; public enum Accounts { POUCH, BANK }"); + Path result = classes.resolve("net/tfminecraft/denareconomy/api/Result.java"); + Path api = classes.resolve("net/tfminecraft/denareconomy/api/Denars.java"); + Files.createDirectories(api.getParent()); + Files.writeString(result, "package net.tfminecraft.denareconomy.api; public enum Result { APPLIED, REFUSED, CONFLICT, UNAVAILABLE }"); Files.writeString(api, """ - package net.tfminecraft.denareconomy.accounts; - import java.util.UUID; - import net.tfminecraft.denareconomy.enums.Accounts; - public final class OfflineModifier { - public static double pouch = 12.30, bank = 100.00, lastAmount; - public static UUID lastPlayer; - public static Accounts lastAccount; - public static boolean failBalance, failApply; - public static int reads, changes; - public static double balance(UUID player, Accounts account) { - reads++; lastPlayer = player; lastAccount = account; + package net.tfminecraft.denareconomy.api; + import java.util.*; + import net.tfminecraft.tlibs.character.CharacterRef; + public final class Denars { + public static final String MINT = "server:mint"; + public static boolean ready = true, failReady, failBalance, failTransfer, failStatus, closeOnStatus; + public static final Map balances = new HashMap<>(); + public static final Map stored = new HashMap<>(); + public static final Map legs = new HashMap<>(); + public static String lastFrom, lastTo, lastReason; + public static int postings; + public static boolean ready() { if (failReady) throw new IllegalStateException("ready failure"); return ready; } + public static String pouch(CharacterRef c) { return "char:" + c.characterId() + ":pouch"; } + public static String bank(CharacterRef c) { return "char:" + c.characterId() + ":bank"; } + public static long balance(String account) { if (failBalance) throw new IllegalStateException("read failure"); - return account == Accounts.BANK ? bank : pouch; + return balances.getOrDefault(account, 0L); } - public static boolean apply(UUID player, Accounts account, double amount) { - changes++; lastPlayer = player; lastAccount = account; lastAmount = amount; - if (failApply) throw new IllegalStateException("write failure"); - double current = account == Accounts.BANK ? bank : pouch; - if (current + amount < 0) return false; - if (account == Accounts.BANK) bank += amount; else pouch += amount; - return true; + public static Result transfer(String opId, String reason, String from, String to, long cents) { + postings++; lastFrom = from; lastTo = to; lastReason = reason; + if (failTransfer) throw new IllegalStateException("write failure"); + String fingerprint = reason + from + to + cents; + if (stored.containsKey(opId)) return fingerprint.equals(legs.get(opId)) ? stored.get(opId) : Result.CONFLICT; + Result result = !from.startsWith("server:") && balances.getOrDefault(from, 0L) < cents ? Result.REFUSED : Result.APPLIED; + if (result == Result.APPLIED) { balances.merge(from, -cents, Long::sum); balances.merge(to, cents, Long::sum); } + stored.put(opId, result); legs.put(opId, fingerprint); return result; + } + public static Optional status(String opId) { + if (failStatus) throw new IllegalStateException("status failure"); + if (closeOnStatus) ready = false; + return Optional.ofNullable(stored.get(opId)); } } """); + Path tlibs = Path.of(CharacterRef.class.getProtectionDomain().getCodeSource().getLocation().toURI()); ByteArrayOutputStream diagnostics = new ByteArrayOutputStream(); - int exit = ToolProvider.getSystemJavaCompiler().run(null, diagnostics, diagnostics, - "--release", "21", "-d", classes.toString(), enums.toString(), api.toString()); + int exit = ToolProvider.getSystemJavaCompiler().run(null, diagnostics, diagnostics, "--release", "21", + "-cp", tlibs.toString(), "-d", classes.toString(), result.toString(), api.toString()); assertEquals(0, exit, diagnostics.toString(StandardCharsets.UTF_8)); } @Test void absentOrDisabledPluginCannotReadOrChangeBalances() { - PluginManager manager = mock(PluginManager.class); DenarEconomyWallet wallet = new DenarEconomyWallet(); UUID id = UUID.randomUUID(); + PluginManager manager = mock(PluginManager.class); DenarEconomyWallet wallet = new DenarEconomyWallet(); try (var bukkit = mockStatic(Bukkit.class)) { bukkit.when(Bukkit::getPluginManager).thenReturn(manager); - assertFalse(wallet.available()); assertEquals(BigDecimal.ZERO, wallet.balance(id, Account.POUCH)); assertFalse(wallet.withdraw(id, Account.POUCH, new BigDecimal("1"))); assertFalse(wallet.deposit(id, Account.BANK, new BigDecimal("1"))); + assertFalse(wallet.available()); assertEquals(0L, wallet.balance(REF, Account.POUCH)); + assertEquals(Posting.UNAVAILABLE, wallet.withdraw("op", DenarWallet.PAID_CHANGE, REF, Account.POUCH, 1)); + assertEquals(Posting.UNAVAILABLE, wallet.deposit("op", DenarWallet.REFUND, REF, Account.BANK, 1)); + assertEquals(Optional.of(Posting.UNAVAILABLE), wallet.status("op")); Plugin disabled = mock(Plugin.class); when(manager.getPlugin("DenarEconomy")).thenReturn(disabled); assertFalse(wallet.available()); } } - @Test void bindsPluginOwnedApiAndPassesExactUuidAccountAndSignedAmount() throws Exception { - PluginManager manager = mock(PluginManager.class); UUID id = UUID.randomUUID(); + @Test void postsCharacterAccountsAgainstTheMintAndReplaysOperationIds() throws Exception { + PluginManager manager = mock(PluginManager.class); try (ExternalPlugin external = new ExternalPlugin(true); var bukkit = mockStatic(Bukkit.class)) { bukkit.when(Bukkit::getPluginManager).thenReturn(manager); when(manager.getPlugin("DenarEconomy")).thenReturn(external.plugin); + external.balances().put(POUCH, 1_230L); external.balances().put(BANK, 10_000L); DenarEconomyWallet wallet = new DenarEconomyWallet(); assertTrue(wallet.available()); assertTrue(wallet.available()); - assertEquals(new BigDecimal("12.30"), wallet.balance(id, Account.POUCH)); assertEquals(id, external.get("lastPlayer")); assertEquals("POUCH", external.get("lastAccount").toString()); - assertEquals(new BigDecimal("100.00"), wallet.balance(id, Account.BANK)); assertEquals("BANK", external.get("lastAccount").toString()); - assertTrue(wallet.withdraw(id, Account.BANK, new BigDecimal("3.25"))); assertEquals(-3.25, external.get("lastAmount")); assertEquals(new BigDecimal("96.75"), wallet.balance(id, Account.BANK)); - assertTrue(wallet.deposit(id, Account.POUCH, new BigDecimal("2.70"))); assertEquals(2.70, external.get("lastAmount")); assertEquals(new BigDecimal("15.00"), wallet.balance(id, Account.POUCH)); - assertFalse(wallet.withdraw(id, Account.POUCH, new BigDecimal("1000"))); assertEquals(new BigDecimal("15.00"), wallet.balance(id, Account.POUCH)); - int changes = (int) external.get("changes"); assertFalse(wallet.withdraw(id, Account.BANK, BigDecimal.ZERO)); assertFalse(wallet.withdraw(id, Account.BANK, BigDecimal.ONE.negate())); assertFalse(wallet.deposit(id, Account.BANK, BigDecimal.ZERO)); assertFalse(wallet.deposit(id, Account.BANK, BigDecimal.ONE.negate())); assertEquals(changes, external.get("changes")); + assertEquals(1_230L, wallet.balance(REF, Account.POUCH)); assertEquals(10_000L, wallet.balance(REF, Account.BANK)); + + assertEquals(Posting.APPLIED, wallet.withdraw("paid:1", DenarWallet.PAID_CHANGE, REF, Account.BANK, 325)); + assertEquals(BANK, external.get("lastFrom")); assertEquals("server:mint", external.get("lastTo")); assertEquals("rpc:paid-change", external.get("lastReason")); + assertEquals(Posting.APPLIED, wallet.withdraw("paid:1", DenarWallet.PAID_CHANGE, REF, Account.BANK, 325)); + assertEquals(9_675L, wallet.balance(REF, Account.BANK)); + assertEquals(Posting.APPLIED, wallet.deposit("refund:1", DenarWallet.REFUND, REF, Account.POUCH, 270)); + assertEquals("server:mint", external.get("lastFrom")); assertEquals(POUCH, external.get("lastTo")); + assertEquals(Posting.CONFLICT, wallet.deposit("refund:1", DenarWallet.REFUND, REF, Account.POUCH, 271)); + assertEquals(1_500L, wallet.balance(REF, Account.POUCH)); + assertEquals(Posting.REFUSED, wallet.withdraw("paid:2", DenarWallet.CLASS_PICK, REF, Account.POUCH, 100_000)); + assertEquals(1_500L, wallet.balance(REF, Account.POUCH)); + + assertEquals(Optional.of(Posting.APPLIED), wallet.status("paid:1")); + assertEquals(Optional.of(Posting.REFUSED), wallet.status("paid:2")); + assertEquals(Optional.empty(), wallet.status("never")); + + int postings = (int) external.get("postings"); + assertEquals(Posting.REFUSED, wallet.withdraw("x", DenarWallet.PAID_CHANGE, REF, Account.BANK, 0)); + assertEquals(Posting.REFUSED, wallet.deposit("x", DenarWallet.REFUND, REF, Account.BANK, -1)); + assertEquals(postings, external.get("postings")); assertNotSame(getClass().getClassLoader(), external.api.getClassLoader()); } } @Test void pluginReloadRebindsToNewLoaderAndDisabledBoundPluginRemainsUnavailable() throws Exception { - PluginManager manager = mock(PluginManager.class); UUID id = UUID.randomUUID(); DenarEconomyWallet wallet = new DenarEconomyWallet(); + PluginManager manager = mock(PluginManager.class); DenarEconomyWallet wallet = new DenarEconomyWallet(); try (ExternalPlugin first = new ExternalPlugin(true); ExternalPlugin second = new ExternalPlugin(true); var bukkit = mockStatic(Bukkit.class)) { - bukkit.when(Bukkit::getPluginManager).thenReturn(manager); when(manager.getPlugin("DenarEconomy")).thenReturn(first.plugin); first.set("bank", 9.50); assertEquals(new BigDecimal("9.50"), wallet.balance(id, Account.BANK)); - first.enabled.set(false); assertFalse(wallet.available()); assertEquals(BigDecimal.ZERO, wallet.balance(id, Account.BANK)); - second.set("bank", 42.75); when(manager.getPlugin("DenarEconomy")).thenReturn(second.plugin); assertTrue(wallet.available()); assertEquals(new BigDecimal("42.75"), wallet.balance(id, Account.BANK)); assertTrue(wallet.deposit(id, Account.BANK, BigDecimal.ONE)); assertEquals(43.75, second.get("bank")); assertEquals(9.50, first.get("bank")); + bukkit.when(Bukkit::getPluginManager).thenReturn(manager); when(manager.getPlugin("DenarEconomy")).thenReturn(first.plugin); + first.balances().put(BANK, 950L); assertEquals(950L, wallet.balance(REF, Account.BANK)); + first.enabled.set(false); assertFalse(wallet.available()); assertEquals(0L, wallet.balance(REF, Account.BANK)); + second.balances().put(BANK, 4_275L); when(manager.getPlugin("DenarEconomy")).thenReturn(second.plugin); + assertTrue(wallet.available()); assertEquals(4_275L, wallet.balance(REF, Account.BANK)); + assertEquals(Posting.APPLIED, wallet.deposit("r", DenarWallet.REFUND, REF, Account.BANK, 100)); + assertEquals(4_375L, second.balances().get(BANK)); assertEquals(950L, first.balances().get(BANK)); } } - @Test void reflectionAndAccountFailuresFailClosedAndCanBeRetried() throws Exception { - PluginManager manager = mock(PluginManager.class); UUID id = UUID.randomUUID(); + @Test void failuresAndAClosedLedgerFailClosed() throws Exception { + PluginManager manager = mock(PluginManager.class); try (ExternalPlugin external = new ExternalPlugin(true); var bukkit = mockStatic(Bukkit.class)) { - bukkit.when(Bukkit::getPluginManager).thenReturn(manager); when(manager.getPlugin("DenarEconomy")).thenReturn(external.plugin); DenarEconomyWallet wallet = new DenarEconomyWallet(); - external.set("failBalance", true); assertEquals(BigDecimal.ZERO, wallet.balance(id, Account.POUCH)); external.set("failBalance", false); - external.set("failApply", true); assertFalse(wallet.withdraw(id, Account.POUCH, BigDecimal.ONE)); assertFalse(wallet.deposit(id, Account.BANK, BigDecimal.ONE)); assertEquals(12.30, external.get("pouch")); assertEquals(100.0, external.get("bank")); - external.set("failApply", false); assertTrue(wallet.deposit(id, Account.BANK, BigDecimal.ONE)); assertEquals(new BigDecimal("101.00"), wallet.balance(id, Account.BANK)); + bukkit.when(Bukkit::getPluginManager).thenReturn(manager); when(manager.getPlugin("DenarEconomy")).thenReturn(external.plugin); + DenarEconomyWallet wallet = new DenarEconomyWallet(); external.balances().put(POUCH, 500L); + assertEquals(Posting.APPLIED, wallet.withdraw("paid", DenarWallet.PAID_CHANGE, REF, Account.POUCH, 100)); + external.set("failBalance", true); assertEquals(0L, wallet.balance(REF, Account.POUCH)); external.set("failBalance", false); + external.set("failTransfer", true); assertEquals(Posting.UNAVAILABLE, wallet.deposit("r", DenarWallet.REFUND, REF, Account.POUCH, 1)); + assertEquals(400L, external.balances().get(POUCH)); external.set("failTransfer", false); + external.set("failStatus", true); assertEquals(Optional.of(Posting.UNAVAILABLE), wallet.status("paid")); external.set("failStatus", false); + external.set("closeOnStatus", true); assertEquals(Optional.of(Posting.UNAVAILABLE), wallet.status("paid")); + assertFalse(wallet.available()); assertEquals(Optional.of(Posting.UNAVAILABLE), wallet.status("paid")); + external.set("closeOnStatus", false); external.set("ready", true); + external.set("failReady", true); assertFalse(wallet.available()); external.set("failReady", false); + assertEquals(Optional.of(Posting.APPLIED), wallet.status("paid")); } } @Test void missingApiDoesNotBindAndLaterCompatiblePluginCanRecover() throws Exception { PluginManager manager = mock(PluginManager.class); DenarEconomyWallet wallet = new DenarEconomyWallet(); try (ExternalPlugin missing = new ExternalPlugin(false); ExternalPlugin present = new ExternalPlugin(true); var bukkit = mockStatic(Bukkit.class)) { - bukkit.when(Bukkit::getPluginManager).thenReturn(manager); when(manager.getPlugin("DenarEconomy")).thenReturn(missing.plugin); assertFalse(wallet.available()); assertEquals(BigDecimal.ZERO, wallet.balance(UUID.randomUUID(), Account.BANK)); - when(manager.getPlugin("DenarEconomy")).thenReturn(present.plugin); assertTrue(wallet.available()); assertEquals(new BigDecimal("12.30"), wallet.balance(UUID.randomUUID(), Account.POUCH)); + bukkit.when(Bukkit::getPluginManager).thenReturn(manager); when(manager.getPlugin("DenarEconomy")).thenReturn(missing.plugin); + assertFalse(wallet.available()); assertEquals(0L, wallet.balance(REF, Account.BANK)); + when(manager.getPlugin("DenarEconomy")).thenReturn(present.plugin); present.balances().put(POUCH, 1_230L); + assertTrue(wallet.available()); assertEquals(1_230L, wallet.balance(REF, Account.POUCH)); } } @@ -137,10 +186,11 @@ private static final class ExternalPlugin implements AutoCloseable { case "equals" -> proxy == args[0]; default -> null; }); - api = withApi ? Class.forName("net.tfminecraft.denareconomy.accounts.OfflineModifier", true, loader) : null; + api = withApi ? Class.forName("net.tfminecraft.denareconomy.api.Denars", true, loader) : null; } Object get(String field) throws Exception { return api.getField(field).get(null); } void set(String field, Object value) throws Exception { api.getField(field).set(null, value); } + @SuppressWarnings("unchecked") Map balances() throws Exception { return (Map) get("balances"); } @Override public void close() throws Exception { loader.close(); } } } diff --git a/src/test/java/net/tfminecraft/rpcharacters/paidchange/FakeLedger.java b/src/test/java/net/tfminecraft/rpcharacters/paidchange/FakeLedger.java new file mode 100644 index 00000000..9a373645 --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/paidchange/FakeLedger.java @@ -0,0 +1,86 @@ +package net.tfminecraft.rpcharacters.paidchange; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import net.tfminecraft.tlibs.character.CharacterRef; + +/** An in-memory ledger with DenarEconomy's rules: idempotent operation IDs, no overdrafts. */ +public final class FakeLedger implements DenarWallet { + public record Call(String opId, String reason, CharacterRef character, Account account, long cents, boolean debit) {} + + public final Map balances = new EnumMap<>(Account.class); + public final Map stored = new LinkedHashMap<>(); + public final List applied = new ArrayList<>(); + public final List attempts = new ArrayList<>(); + private final Map fingerprints = new HashMap<>(); + /** available() answer. */ + public boolean available = true; + /** Every posting answers UNAVAILABLE and records nothing. */ + public boolean postingsDown; + /** Only deposits answer UNAVAILABLE. */ + public boolean depositsDown; + /** Postings apply but the caller hears UNAVAILABLE, as if the reply was lost. */ + public boolean loseReplies; + /** status() can't say. */ + public boolean statusDown; + /** Debits from the pouch are refused regardless of balance. */ + public boolean rejectPouch; + public Runnable afterPosting = () -> {}; + + public long get(Account account) { + return balances.getOrDefault(account, 0L); + } + + public void set(Account account, long cents) { + balances.put(account, cents); + } + + public long count(boolean debit) { + return applied.stream().filter(call -> call.debit() == debit).count(); + } + + @Override public boolean available() { return available; } + + @Override public long balance(CharacterRef character, Account account) { return get(account); } + + @Override public Posting withdraw(String opId, String reason, CharacterRef character, Account account, long cents) { + return post(new Call(opId, reason, character, account, cents, true)); + } + + @Override public Posting deposit(String opId, String reason, CharacterRef character, Account account, long cents) { + return post(new Call(opId, reason, character, account, cents, false)); + } + + @Override public Optional status(String opId) { + return statusDown ? Optional.of(Posting.UNAVAILABLE) : Optional.ofNullable(stored.get(opId)); + } + + private Posting post(Call call) { + attempts.add(call); + if (postingsDown || (depositsDown && !call.debit())) { + return Posting.UNAVAILABLE; + } + String fingerprint = call.reason() + "|" + call.character() + "|" + call.account() + "|" + call.cents() + "|" + call.debit(); + Posting result; + if (stored.containsKey(call.opId())) { + result = fingerprints.get(call.opId()).equals(fingerprint) ? stored.get(call.opId()) : Posting.CONFLICT; + } else { + boolean refused = call.debit() && (get(call.account()) < call.cents() || (rejectPouch && call.account() == Account.POUCH)); + result = refused ? Posting.REFUSED : Posting.APPLIED; + if (!refused) { + balances.merge(call.account(), call.debit() ? -call.cents() : call.cents(), Long::sum); + applied.add(call); + } + stored.put(call.opId(), result); + fingerprints.put(call.opId(), fingerprint); + } + afterPosting.run(); + return loseReplies ? Posting.UNAVAILABLE : result; + } +} diff --git a/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeRuntimeTest.java b/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeRuntimeTest.java index 7fb289c4..b6ed9546 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeRuntimeTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeRuntimeTest.java @@ -7,7 +7,6 @@ import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; -import java.util.EnumMap; import java.util.List; import java.util.Locale; import java.util.UUID; @@ -36,7 +35,7 @@ class PaidChangeRuntimeTest { private RuntimeTestState state; - private final RecordingWallet wallet = new RecordingWallet(); + private final FakeLedger wallet = new FakeLedger(); private Player player; private RPCharacter character; private CharacterCreation creation; @@ -45,7 +44,8 @@ class PaidChangeRuntimeTest { @BeforeEach void setup() { MockBukkit.mock(); - state = new RuntimeTestState(PaidChangeService.class, StageLoader.class, CreationManager.class); + state = new RuntimeTestState(PaidChangeService.class, StageLoader.class, CreationManager.class, Payments.class); + PaymentsFixture.saves((owner, saved) -> true); StageLoader.oList.clear(); CreationManager.activeCreators.clear(); player = mock(Player.class); when(player.getUniqueId()).thenReturn(UUID.randomUUID()); character = new RPCharacter(player); character.setMMOClass("WARRIOR"); @@ -53,48 +53,48 @@ class PaidChangeRuntimeTest { stage = stage("class_stage", 0); rule = new PaidChangeRule("class", "CLASS_STAGE", "class", List.of(d("10"), d("25"))); PaidChangeService.setWallet(wallet); PaidChangeService.configure(List.of(rule), null); - wallet.balances.put(Account.POUCH, d("100")); wallet.balances.put(Account.BANK, d("200")); + wallet.set(Account.POUCH, 10_000); wallet.set(Account.BANK, 20_000); } @AfterEach void teardown() { state.close(); MockBukkit.unmock(); } @Test void paidOpeningHoldsExactPaymentAndUnchangedSettlementRefundsOnlyOnce() { assertTrue(PaidChangeService.payToOpen(player, creation, stage)); - PendingPaidChange held = character.getPendingPaidChange(); assertEquals(d("10"), held.amount()); assertEquals(Account.POUCH, held.account()); assertEquals(player.getUniqueId(), held.payerId()); - assertEquals(d("90"), wallet.balances.get(Account.POUCH)); verify(player).sendMessage(contains("Paid 10 denars from your pouch")); verify(player).sendMessage(contains("Leave without changing")); - PaidChangeService.settle(creation); assertNull(character.getPendingPaidChange()); assertEquals(d("100"), wallet.balances.get(Account.POUCH)); assertEquals(1, wallet.deposits); - verify(player).sendMessage(contains("10 denars went back to your pouch")); PaidChangeService.settle(creation); assertEquals(1, wallet.deposits); + PendingPaidChange held = character.getPendingPaidChange(); assertEquals(1_000, held.cents()); assertEquals(Account.POUCH, held.account()); assertEquals(player.getUniqueId(), held.payment().payer().ownerId()); + assertEquals(9_000, wallet.get(Account.POUCH)); verify(player).sendMessage(contains("Paid 10 denars from your pouch")); verify(player).sendMessage(contains("Leave without changing")); + PaidChangeService.settle(creation); assertNull(character.getPendingPaidChange()); assertEquals(10_000, wallet.get(Account.POUCH)); assertEquals(1, wallet.count(false)); + verify(player).sendMessage(contains("10 denars went back to your pouch")); PaidChangeService.settle(creation); assertEquals(1, wallet.count(false)); } @Test void changedSettlementCountsItAndChargesTheNextPriceOnReopen() { assertTrue(PaidChangeService.payToOpen(player, creation, stage)); character.setMMOClass("MAGE"); - assertTrue(PaidChangeService.payToOpen(player, creation, stage)); assertEquals(1, character.getPaidChangeCount("class_stage")); assertEquals(d("25"), character.getPendingPaidChange().amount()); assertEquals(d("65"), wallet.balances.get(Account.POUCH)); + assertTrue(PaidChangeService.payToOpen(player, creation, stage)); assertEquals(1, character.getPaidChangeCount("class_stage")); assertEquals(2_500, character.getPendingPaidChange().cents()); assertEquals(6_500, wallet.get(Account.POUCH)); verify(player).sendMessage(contains("The next one costs 25 denars")); clearInvocations(player); character.setMMOClass("ROGUE"); PaidChangeService.configure(List.of(), List.of()); PaidChangeService.settle(creation); verify(player).sendMessage(contains("Your class change is paid for.")); assertEquals(2, character.getPaidChangeCount("class_stage")); } @Test void failedRefundLeavesOriginalHoldAndRejectsAnotherPaymentUntilRetryWorks() { - assertTrue(PaidChangeService.payToOpen(player, creation, stage)); PendingPaidChange held = character.getPendingPaidChange(); wallet.refundsFail = true; - assertFalse(PaidChangeService.payToOpen(player, creation, stage)); assertSame(held, character.getPendingPaidChange()); assertEquals(1, wallet.withdrawals); assertEquals(d("90"), wallet.balances.get(Account.POUCH)); - verify(player).sendMessage(contains("refund couldn't go through")); verify(player).sendMessage(contains("last refund hasn't gone through")); wallet.refundsFail = false; - assertTrue(PaidChangeService.recover(character).contains("went back")); assertNull(character.getPendingPaidChange()); assertEquals(d("100"), wallet.balances.get(Account.POUCH)); + assertTrue(PaidChangeService.payToOpen(player, creation, stage)); PendingPaidChange held = character.getPendingPaidChange(); wallet.depositsDown = true; + assertFalse(PaidChangeService.payToOpen(player, creation, stage)); assertSame(held, character.getPendingPaidChange()); assertEquals(1, wallet.count(true)); assertEquals(9_000, wallet.get(Account.POUCH)); + verify(player).sendMessage(contains("refund couldn't go through")); verify(player).sendMessage(contains("last refund hasn't gone through")); wallet.depositsDown = false; + assertTrue(PaidChangeService.recover(character).contains("went back")); assertNull(character.getPendingPaidChange()); assertEquals(10_000, wallet.get(Account.POUCH)); } @Test void unavailableEconomyMissingRuleAndInsufficientFundsNeverCreateAHold() { assertFalse(PaidChangeService.payToOpen(player, creation, stage("other", 0))); verify(player).sendMessage(contains("can no longer be edited")); wallet.available = false; assertFalse(PaidChangeService.payToOpen(player, creation, stage)); verify(player).sendMessage(contains("can't pay to change your class right now")); - wallet.available = true; wallet.balances.put(Account.POUCH, d("1")); wallet.balances.put(Account.BANK, d("2.5")); - assertFalse(PaidChangeService.payToOpen(player, creation, stage)); verify(player).sendMessage(contains("costs 10 denars. You have 1 denar in your pouch and 2.50 denars in the bank")); assertNull(character.getPendingPaidChange()); assertEquals(0, wallet.withdrawals); + wallet.available = true; wallet.set(Account.POUCH, 100); wallet.set(Account.BANK, 250); + assertFalse(PaidChangeService.payToOpen(player, creation, stage)); verify(player).sendMessage(contains("costs 10 denars. You have 1 denar in your pouch and 2.50 denars in the bank")); assertNull(character.getPendingPaidChange()); assertTrue(wallet.attempts.isEmpty()); } @Test void failedPouchWithdrawalFallsBackToBankAndConfiguredOrderIsRespected() { - wallet.rejectPouch = true; assertTrue(PaidChangeService.payToOpen(player, creation, stage)); assertEquals(Account.BANK, character.getPendingPaidChange().account()); assertEquals(d("100"), wallet.balances.get(Account.POUCH)); assertEquals(d("190"), wallet.balances.get(Account.BANK)); + wallet.rejectPouch = true; assertTrue(PaidChangeService.payToOpen(player, creation, stage)); assertEquals(Account.BANK, character.getPendingPaidChange().account()); assertEquals(10_000, wallet.get(Account.POUCH)); assertEquals(19_000, wallet.get(Account.BANK)); PaidChangeService.settle(creation); wallet.rejectPouch = false; PaidChangeService.configure(List.of(rule), List.of(Account.BANK)); assertTrue(PaidChangeService.payToOpen(player, creation, stage)); assertEquals(Account.BANK, character.getPendingPaidChange().account()); } @Test void freeChangesNeedNoEconomyAndDoNotSendRefundMessages() { PaidChangeRule free = new PaidChangeRule("free", "CLASS_STAGE", " ", List.of()); assertEquals("free", free.getId()); assertEquals("free", free.getLabel()); assertEquals(List.of(), free.getCosts()); assertEquals(d("0"), free.costAfter(99)); PaidChangeService.configure(List.of(free), List.of()); wallet.available = false; assertTrue(PaidChangeService.payToOpen(player, creation, stage)); assertNull(character.getPendingPaidChange().account()); - PaidChangeService.settle(creation); assertNull(character.getPendingPaidChange()); assertEquals(0, wallet.deposits); assertEquals(0, wallet.withdrawals); verify(player, never()).sendMessage(anyString()); + PaidChangeService.settle(creation); assertNull(character.getPendingPaidChange()); assertTrue(wallet.attempts.isEmpty()); verify(player, never()).sendMessage(anyString()); assertNull(PaidChangeService.recover(null)); PaidChangeService.settle(null); PaidChangeService.settle(CharacterCreation.forEdit(null, null)); character.setPendingPaidChange(PaidChangeService.charge(player.getUniqueId(), free, character).pending()); assertNull(PaidChangeService.recover(character)); } @Test void offlineSessionSettlementClearsHoldWithoutTryingToSendToMissingPlayer() { - character.setPendingPaidChange(PaidChangeService.charge(player.getUniqueId(), rule, character).pending()); PaidChangeService.settle(CharacterCreation.forEdit(null, character)); assertNull(character.getPendingPaidChange()); assertEquals(1, wallet.deposits); + character.setPendingPaidChange(PaidChangeService.charge(player.getUniqueId(), rule, character).pending()); PaidChangeService.settle(CharacterCreation.forEdit(null, character)); assertNull(character.getPendingPaidChange()); assertEquals(1, wallet.count(false)); } @Test void summariesRespectMissingStagesUnavailableEconomyAndFreeWindowBounds() { assertNull(PaidChangeService.ruleFor(null)); assertNull(PaidChangeService.ruleFor(new Stage())); assertNull(PaidChangeService.summaryLore(stage, null, true)); @@ -134,7 +134,7 @@ class PaidChangeRuntimeTest { @Test void economyDisableSettlesAllSessionsFromASnapshotAndIgnoresOtherPlugins() { assertTrue(PaidChangeService.payToOpen(player, creation, stage)); Player other = mock(Player.class); when(other.getUniqueId()).thenReturn(UUID.randomUUID()); RPCharacter second = new RPCharacter(other); CharacterCreation next = CharacterCreation.forEdit(other, second); assertTrue(PaidChangeService.payToOpen(other, next, stage)); CreationManager.activeCreators.put(player, creation); CreationManager.activeCreators.put(other, next); Plugin unrelated = mock(Plugin.class); when(unrelated.getName()).thenReturn("Other"); PaidChangeListener listener = new PaidChangeListener(); listener.onPluginDisable(new PluginDisableEvent(unrelated)); assertNotNull(character.getPendingPaidChange()); - wallet.afterDeposit = CreationManager.activeCreators::clear; Plugin economy = mock(Plugin.class); when(economy.getName()).thenReturn("DenarEconomy"); listener.onPluginDisable(new PluginDisableEvent(economy)); assertNull(character.getPendingPaidChange()); assertNull(second.getPendingPaidChange()); assertEquals(2, wallet.deposits); PaidChangeListener.settleAll(); + wallet.afterPosting = CreationManager.activeCreators::clear; Plugin economy = mock(Plugin.class); when(economy.getName()).thenReturn("DenarEconomy"); listener.onPluginDisable(new PluginDisableEvent(economy)); assertNull(character.getPendingPaidChange()); assertNull(second.getPendingPaidChange()); assertEquals(2, wallet.count(false)); PaidChangeListener.settleAll(); } @Test void summaryEntriesUseConfiguredOrderCaseInsensitiveKeysAndIndependentCopies() { assertNull(SummaryEditSupport.getSummaryStage()); assertEquals(List.of(), SummaryEditSupport.getEditEntryKeys()); assertNull(SummaryEditSupport.resolveStageId(null)); assertNull(SummaryEditSupport.resolveStageId(" ")); assertNull(SummaryEditSupport.resolveStageForEntry("unknown")); @@ -145,14 +145,4 @@ class PaidChangeRuntimeTest { } private static Stage stage(String id, long lockMs) { Stage s = new Stage(); s.setId(id); s.setLockTimeMs(lockMs); return s; } private static BigDecimal d(String value) { return new BigDecimal(value).setScale(2); } - private static final class RecordingWallet implements DenarWallet { - final EnumMap balances = new EnumMap<>(Account.class); - boolean available = true, refundsFail, rejectPouch; - int withdrawals, deposits; - Runnable afterDeposit = () -> {}; - public boolean available() { return available; } - public BigDecimal balance(UUID payer, Account account) { return balances.getOrDefault(account, d("0")); } - public boolean withdraw(UUID payer, Account account, BigDecimal amount) { if ((account == Account.POUCH && rejectPouch) || balance(payer, account).compareTo(amount) < 0) return false; withdrawals++; balances.put(account, balance(payer, account).subtract(amount)); return true; } - public boolean deposit(UUID payer, Account account, BigDecimal amount) { if (refundsFail) return false; deposits++; balances.put(account, balance(payer, account).add(amount)); afterDeposit.run(); return true; } - } } diff --git a/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeServiceTest.java b/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeServiceTest.java index 2abbee47..ec11adcd 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeServiceTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeServiceTest.java @@ -3,14 +3,16 @@ 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.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import java.math.BigDecimal; import java.time.Instant; -import java.util.EnumMap; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; import org.bukkit.configuration.file.YamlConfiguration; @@ -20,14 +22,17 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import net.tfminecraft.rpcharacters.RuntimeTestState; import net.tfminecraft.rpcharacters.creation.Stage; 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.DenarWallet.Posting; import net.tfminecraft.rpcharacters.paidchange.PaidChangeService.ChargeResult; import net.tfminecraft.rpcharacters.paidchange.PaidChangeService.ChargeStatus; +import net.tfminecraft.tlibs.character.CharacterRef; class PaidChangeServiceTest { @@ -37,17 +42,22 @@ class PaidChangeServiceTest { private static final PaidChangeRule CLASS_RULE = new PaidChangeRule("class", "class_selection_stage", "class", List.of(d("100"), d("1000"), d("3000"))); - private final FakeWallet wallet = new FakeWallet(); + private final FakeLedger wallet = new FakeLedger(); + private final List saves = new ArrayList<>(); + private RuntimeTestState state; @BeforeEach void setUp() { + state = new RuntimeTestState(PaidChangeService.class, Payments.class); PaidChangeService.setWallet(wallet); PaidChangeService.configure(List.of(CLASS_RULE), List.of(Account.POUCH, Account.BANK)); + PaymentsFixture.saves((owner, character) -> saves.add(owner + "/" + character.getId())); } @AfterEach void tearDown() { PaidChangeService.configure(List.of(), List.of()); + state.close(); } @Test @@ -60,38 +70,41 @@ void pricesRiseThenRepeatTheLastCost() { @Test void chargesThePouchFirstThenTheBank() { - wallet.set(Account.POUCH, d("150")); - wallet.set(Account.BANK, d("5000")); + wallet.set(Account.POUCH, 15_000); + wallet.set(Account.BANK, 500_000); RPCharacter character = characterWithClass("WARRIOR"); ChargeResult first = PaidChangeService.charge(PLAYER, CLASS_RULE, character); assertEquals(ChargeStatus.PAID, first.status()); assertEquals(Account.POUCH, first.pending().account()); - assertEquals(d("50"), wallet.get(Account.POUCH)); + assertEquals(5_000, wallet.get(Account.POUCH)); + assertEquals(new CharacterRef(PLAYER, UUID.fromString(character.getId())), wallet.applied.get(0).character()); + assertEquals(DenarWallet.PAID_CHANGE, wallet.applied.get(0).reason()); character.setPaidChangeCount(CLASS_STAGE, 1); ChargeResult second = PaidChangeService.charge(PLAYER, CLASS_RULE, character); assertEquals(Account.BANK, second.pending().account()); - assertEquals(d("1000"), second.pending().amount()); - assertEquals(d("4000"), wallet.get(Account.BANK)); + assertEquals(100_000, second.pending().cents()); + assertEquals(400_000, wallet.get(Account.BANK)); } @Test void refusesWhenNoAccountCoversTheCost() { - wallet.set(Account.POUCH, d("60")); - wallet.set(Account.BANK, d("60")); + wallet.set(Account.POUCH, 6_000); + wallet.set(Account.BANK, 6_000); ChargeResult result = PaidChangeService.charge(PLAYER, CLASS_RULE, characterWithClass("WARRIOR")); assertEquals(ChargeStatus.INSUFFICIENT_FUNDS, result.status()); assertEquals(d("100"), result.cost()); - assertEquals(d("60"), wallet.get(Account.POUCH)); - assertEquals(d("60"), wallet.get(Account.BANK)); + assertEquals(6_000, wallet.get(Account.POUCH)); + assertEquals(6_000, wallet.get(Account.BANK)); + assertTrue(saves.isEmpty()); } @Test void unavailableWithoutDenarEconomy() { wallet.available = false; - wallet.set(Account.POUCH, d("500")); + wallet.set(Account.POUCH, 50_000); assertFalse(PaidChangeService.canPayToOpen(stage("class_selection_stage", 5 * DAY_MS))); assertEquals(ChargeStatus.UNAVAILABLE, PaidChangeService.charge(PLAYER, CLASS_RULE, characterWithClass("WARRIOR")).status()); @@ -99,71 +112,70 @@ void unavailableWithoutDenarEconomy() { @Test void backingOutWithoutAChangeRefunds() { - wallet.set(Account.POUCH, d("100")); + wallet.set(Account.POUCH, 10_000); RPCharacter character = characterWithClass("WARRIOR"); ChargeResult result = PaidChangeService.charge(PLAYER, CLASS_RULE, character); - assertEquals(d("0"), wallet.get(Account.POUCH)); + assertEquals(0, wallet.get(Account.POUCH)); character.setPendingPaidChange(result.pending()); assertEquals(PaidChangeService.Outcome.REFUNDED, PaidChangeService.resolve(character)); - assertEquals(d("100"), wallet.get(Account.POUCH)); + assertEquals(10_000, wallet.get(Account.POUCH)); + assertEquals(result.pending().payment().refundOpId(), wallet.applied.get(1).opId()); + assertEquals(DenarWallet.REFUND, wallet.applied.get(1).reason()); assertEquals(0, character.getPaidChangeCount(CLASS_STAGE)); assertNull(character.getPendingPaidChange()); } @Test void aFailedRefundStaysHeldAndIsRetried() { - wallet.set(Account.POUCH, d("100")); + wallet.set(Account.POUCH, 10_000); RPCharacter character = characterWithClass("WARRIOR"); character.setPendingPaidChange(PaidChangeService.charge(PLAYER, CLASS_RULE, character).pending()); - wallet.depositsFail = true; + wallet.depositsDown = true; assertEquals(PaidChangeService.Outcome.REFUND_FAILED, PaidChangeService.resolve(character)); - assertEquals(d("0"), wallet.get(Account.POUCH)); + assertEquals(0, wallet.get(Account.POUCH)); assertTrue(character.getPendingPaidChange() != null); - wallet.depositsFail = false; + wallet.depositsDown = false; String message = PaidChangeService.recover(character); assertTrue(message.contains("100 denars went back to your pouch"), message); - assertEquals(d("100"), wallet.get(Account.POUCH)); + assertEquals(10_000, wallet.get(Account.POUCH)); assertNull(character.getPendingPaidChange()); assertNull(PaidChangeService.recover(character)); } @Test void aSavedHoldSurvivesACrashAndSettlesOnRecovery() throws Exception { - wallet.set(Account.BANK, d("1000")); + wallet.set(Account.BANK, 100_000); 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); + RPCharacter loaded = reload(character, "MAGE"); PendingPaidChange held = loaded.getPendingPaidChange(); + assertEquals(character.getPendingPaidChange(), held); assertEquals(Account.BANK, held.account()); - assertEquals(d("1000"), held.amount()); - assertEquals(PLAYER, held.payerId()); + assertEquals(100_000, held.cents()); + assertEquals(new CharacterRef(PLAYER, UUID.fromString(character.getId())), held.payment().payer()); 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(d("0"), wallet.get(Account.BANK)); + assertEquals(0, wallet.get(Account.BANK)); assertNull(loaded.getPendingPaidChange()); } @Test void keepingTheChangeKeepsThePaymentAndRaisesTheNextPrice() { - wallet.set(Account.BANK, d("100")); + wallet.set(Account.BANK, 10_000); RPCharacter character = characterWithClass("WARRIOR"); character.setPendingPaidChange(PaidChangeService.charge(PLAYER, CLASS_RULE, character).pending()); character.setMMOClass("mage"); assertEquals(PaidChangeService.Outcome.KEPT, PaidChangeService.resolve(character)); - assertEquals(d("0"), wallet.get(Account.BANK)); + assertEquals(0, wallet.get(Account.BANK)); assertEquals(1, character.getPaidChangeCount(CLASS_STAGE)); assertEquals(d("1000"), CLASS_RULE.costAfter(character.getPaidChangeCount(CLASS_STAGE))); } @@ -222,8 +234,8 @@ void configReadsRulesAndAccounts() { assertEquals("race", race.getLabel()); assertEquals(d("75"), race.costAfter(3)); - wallet.set(Account.POUCH, d("1000")); - wallet.set(Account.BANK, d("50")); + wallet.set(Account.POUCH, 100_000); + wallet.set(Account.BANK, 5_000); ChargeResult result = PaidChangeService.charge(PLAYER, race, characterWithClass("WARRIOR")); assertEquals(Account.BANK, result.pending().account()); } @@ -302,12 +314,14 @@ private static BigDecimal d(String value) { @Test void fractionalPricesStayExact() { PaidChangeRule cheap = new PaidChangeRule("tip", CLASS_STAGE, "class", List.of(d("0.1"))); - wallet.set(Account.POUCH, d("0.3")); + wallet.set(Account.POUCH, 30); RPCharacter character = characterWithClass("WARRIOR"); for (int i = 0; i < 3; i++) { + character.setPendingPaidChange(null); assertEquals(ChargeStatus.PAID, PaidChangeService.charge(PLAYER, cheap, character).status()); } - assertEquals(d("0"), wallet.get(Account.POUCH)); + assertEquals(0, wallet.get(Account.POUCH)); + assertEquals(10, wallet.applied.get(2).cents()); } private static RPCharacter characterWithClass(String classId) { @@ -322,46 +336,220 @@ private static Stage stage(String id, long lockMs) { stage.setLockTimeMs(lockMs); return stage; } + @Test + void theIntentIsSavedBeforeTheDebitIsPosted() { + wallet.set(Account.POUCH, 10_000); + RPCharacter character = characterWithClass("WARRIOR"); + List seenAtSave = new ArrayList<>(); + PaymentsFixture.saves((owner, saved) -> { + Payment payment = saved.getPendingPaidChange().payment(); + seenAtSave.add(payment.debitOpId() + " posted=" + wallet.stored.containsKey(payment.debitOpId())); + return true; + }); + + PendingPaidChange pending = PaidChangeService.charge(PLAYER, CLASS_RULE, character).pending(); + Payment payment = pending.payment(); + String prefix = "paid:" + character.getId() + ":" + CLASS_STAGE + ":"; + assertTrue(payment.debitOpId().startsWith(prefix), payment.debitOpId()); + assertEquals("refund:" + payment.debitOpId().substring("paid:".length()), payment.refundOpId()); + assertEquals(List.of(payment.debitOpId() + " posted=false"), seenAtSave); + assertEquals(Posting.APPLIED, wallet.stored.get(payment.debitOpId())); + assertSame(pending, character.getPendingPaidChange()); + } - private static final class FakeWallet implements DenarWallet { - private final Map balances = new EnumMap<>(Account.class); - boolean available = true; - boolean depositsFail; + @Test + void anUnsavableIntentTakesNothing() { + wallet.set(Account.POUCH, 10_000); + RPCharacter character = characterWithClass("WARRIOR"); + PendingPaidChange earlier = new PendingPaidChange(CLASS_STAGE, "class", null, "x"); + character.setPendingPaidChange(earlier); + PaymentsFixture.saves((owner, saved) -> false); - void set(Account account, BigDecimal amount) { - balances.put(account, amount.setScale(2)); - } + assertEquals(ChargeStatus.UNAVAILABLE, PaidChangeService.charge(PLAYER, CLASS_RULE, character).status()); + assertTrue(wallet.attempts.isEmpty()); + assertSame(earlier, character.getPendingPaidChange()); + } - BigDecimal get(Account account) { - return balances.getOrDefault(account, d("0")); - } + @Test + void aCrashAfterTheDebitRefundsOnceOnRecovery() throws Exception { + wallet.set(Account.POUCH, 10_000); + RPCharacter character = characterWithClass("WARRIOR"); + PaidChangeService.charge(PLAYER, CLASS_RULE, character); + RPCharacter afterCrash = reload(character, "WARRIOR"); + RPCharacter sameFileAgain = reload(character, "WARRIOR"); - @Override - public boolean available() { - return available; - } + String message = PaidChangeService.recover(afterCrash); + assertTrue(message.contains("100 denars went back to your pouch"), message); + assertEquals(10_000, wallet.get(Account.POUCH)); + assertNull(afterCrash.getPendingPaidChange()); + + // Crash again before the settled character was saved: the same refund ID changes nothing. + assertTrue(PaidChangeService.recover(sameFileAgain).contains("went back")); + assertEquals(10_000, wallet.get(Account.POUCH)); + assertEquals(1, wallet.count(false)); + assertEquals(2, wallet.attempts.stream().filter(call -> !call.debit()).count()); + } - @Override - public BigDecimal balance(UUID playerId, Account account) { - return get(account); - } + @Test + void aRefusedOrMissingDebitIsDroppedWithoutARefund() throws Exception { + wallet.set(Account.POUCH, 10_000); + wallet.set(Account.BANK, 10_000); + wallet.rejectPouch = true; + RPCharacter character = characterWithClass("WARRIOR"); + List onDisk = new ArrayList<>(); + PaymentsFixture.saves((owner, saved) -> onDisk.add(serialise(saved))); + + ChargeResult paid = PaidChangeService.charge(PLAYER, CLASS_RULE, character); + assertEquals(Account.BANK, paid.pending().account()); + assertEquals(3, onDisk.size(), "pouch intent, its drop, then the bank intent"); + assertFalse(onDisk.get(1).containsKey("paid-change-pending")); + + RPCharacter refusedOnDisk = characterWithClass("WARRIOR"); + CharacterStageChangeFields.load(refusedOnDisk, onDisk.get(0)); + assertEquals(Posting.REFUSED, wallet.stored.get(refusedOnDisk.getPendingPaidChange().payment().debitOpId())); + assertEquals(PaidChangeService.Outcome.DISCARDED, PaidChangeService.resolve(refusedOnDisk)); + assertNull(refusedOnDisk.getPendingPaidChange()); + + // Saved, then the server stopped before the debit was posted. + RPCharacter neverPosted = characterWithClass("WARRIOR"); + CharacterStageChangeFields.load(neverPosted, onDisk.get(0)); + wallet.stored.clear(); + assertNull(PaidChangeService.recover(neverPosted)); + assertNull(neverPosted.getPendingPaidChange()); + assertEquals(0, wallet.count(false)); + assertEquals(0, neverPosted.getPaidChangeCount(CLASS_STAGE)); + } - @Override - public boolean withdraw(UUID playerId, Account account, BigDecimal amount) { - if (get(account).compareTo(amount) < 0) { - return false; - } - balances.put(account, get(account).subtract(amount)); - return true; - } + @Test + void aLedgerThatGoesDownMidDebitKeepsTheHoldUntilItCanSay() { + wallet.set(Account.POUCH, 10_000); + RPCharacter character = characterWithClass("WARRIOR"); + wallet.postingsDown = true; + assertEquals(ChargeStatus.UNAVAILABLE, PaidChangeService.charge(PLAYER, CLASS_RULE, character).status()); + assertNull(character.getPendingPaidChange(), "a debit the ledger never recorded is dropped"); + + wallet.postingsDown = false; + wallet.loseReplies = true; + wallet.statusDown = true; + assertEquals(ChargeStatus.UNAVAILABLE, PaidChangeService.charge(PLAYER, CLASS_RULE, character).status()); + assertEquals(0, wallet.get(Account.POUCH)); + assertTrue(character.getPendingPaidChange() != null); + assertTrue(PaidChangeService.recover(character).contains("payment for a class change couldn't be checked yet")); + assertTrue(character.getPendingPaidChange() != null); - @Override - public boolean deposit(UUID playerId, Account account, BigDecimal amount) { - if (depositsFail) { - return false; - } - balances.put(account, get(account).add(amount)); - return true; + wallet.loseReplies = false; + wallet.statusDown = false; + assertTrue(PaidChangeService.recover(character).contains("went back to your pouch")); + assertEquals(10_000, wallet.get(Account.POUCH)); + } + + @Test + void aLostReplyWhoseDebitAppliedStillCountsAsPaid() { + wallet.set(Account.POUCH, 10_000); + wallet.loseReplies = true; + RPCharacter character = characterWithClass("WARRIOR"); + assertEquals(ChargeStatus.PAID, PaidChangeService.charge(PLAYER, CLASS_RULE, character).status()); + assertEquals(0, wallet.get(Account.POUCH)); + } + + @Test + void aConflictingRefundStaysHeldAndIsLogged() { + wallet.set(Account.POUCH, 10_000); + RPCharacter character = characterWithClass("WARRIOR"); + Payment payment = PaidChangeService.charge(PLAYER, CLASS_RULE, character).pending().payment(); + wallet.deposit(payment.refundOpId(), "someone else", payment.payer(), Account.BANK, 1); + + assertEquals(PaidChangeService.Outcome.REFUND_FAILED, PaidChangeService.resolve(character)); + assertEquals(0, wallet.get(Account.POUCH)); + assertTrue(character.getPendingPaidChange() != null); + } + + @Test + void legacyHoldsRefundTheAccountsHeirOnceHeirsAreReady() throws Exception { + UUID account = UUID.randomUUID(); + CharacterRef heir = new CharacterRef(account, UUID.randomUUID()); + boolean[] ready = {false}; + Map heirs = new HashMap<>(); + PaymentsFixture.heirs(() -> ready[0], owner -> Optional.ofNullable(heirs.get(owner))); + RPCharacter character = characterWithClass("WARRIOR"); + JSONObject old = new JSONObject(); + old.putAll(Map.of("stage", CLASS_STAGE, "label", "class", "payer", account.toString(), "account", "BANK", + "amount", "100.00", "before", PaidChangeService.snapshot(character))); + CharacterStageChangeFields.load(character, json(Map.of("paid-change-pending", old))); + + Payment legacy = character.getPendingPaidChange().payment(); + assertTrue(legacy.isLegacy()); + assertEquals("refund-legacy:" + character.getId() + ":" + CLASS_STAGE, legacy.refundOpId()); + assertEquals(10_000, legacy.cents()); + assertEquals(legacy, reload(character, "WARRIOR").getPendingPaidChange().payment()); + + assertTrue(PaidChangeService.recover(character).contains("refund couldn't go through yet")); + ready[0] = true; + assertEquals(PaidChangeService.Outcome.REFUND_FAILED, PaidChangeService.resolve(character), "no heir yet"); + heirs.put(account, heir); + RPCharacter stale = reload(character, "WARRIOR"); + assertTrue(PaidChangeService.recover(character).contains("100 denars went back to your bank")); + assertNull(character.getPendingPaidChange()); + assertEquals(new FakeLedger.Call(legacy.refundOpId(), DenarWallet.REFUND, heir, Account.BANK, 10_000, false), + wallet.applied.get(0)); + PaidChangeService.recover(stale); + assertEquals(1, wallet.count(false)); + assertEquals(10_000, wallet.get(Account.BANK)); + + RPCharacter changed = characterWithClass("WARRIOR"); + CharacterStageChangeFields.load(changed, json(Map.of("paid-change-pending", old))); + changed.setMMOClass("MAGE"); + assertEquals(PaidChangeService.Outcome.KEPT, PaidChangeService.resolve(changed)); + assertEquals(1, changed.getPaidChangeCount(CLASS_STAGE)); + } + + @Test + void legacyFreeHoldsAndUnreadableEntriesLoadSafely() { + RPCharacter free = characterWithClass("WARRIOR"); + CharacterStageChangeFields.load(free, json(Map.of("paid-change-pending", Map.of("stage", CLASS_STAGE, + "label", "class", "payer", PLAYER.toString(), "amount", "0", "before", "x")))); + assertNull(free.getPendingPaidChange().payment()); + CharacterStageChangeFields.load(free, json(Map.of("paid-change-pending", Map.of("stage", CLASS_STAGE, + "label", "class", "before", "x")))); + assertNull(free.getPendingPaidChange().payment()); + + RPCharacter broken = characterWithClass("WARRIOR"); + CharacterStageChangeFields.load(broken, json(Map.of("paid-change-pending", Map.of("stage", CLASS_STAGE, + "before", "x", "payment", Map.of("account", "POUCH", "cents", 5L))))); + assertNull(broken.getPendingPaidChange()); + CharacterStageChangeFields.load(broken, json(Map.of("paid-change-pending", Map.of("stage", CLASS_STAGE, + "before", "x", "payment", Map.of("account", "POUCH", "cents", 5L, "refund-op", " "))))); + assertNull(broken.getPendingPaidChange()); + } + + @Test + void pricesConvertToWholeCents() { + assertEquals(1_235, Payments.cents(new BigDecimal("12.345"))); + assertEquals(new BigDecimal("12.34"), Payments.denars(1_234)); + assertEquals("12.34 denars", PaidChangeService.formatCents(1_234)); + } + + private static RPCharacter reload(RPCharacter character, String classId) throws Exception { + RPCharacter loaded = characterWithClass(classId); + loaded.setId(character.getId()); + CharacterStageChangeFields.load(loaded, serialise(character)); + return loaded; + } + + private static JSONObject serialise(RPCharacter character) { + HashMap saved = new HashMap<>(); + CharacterStageChangeFields.save(saved, character); + try { + return (JSONObject) new JSONParser().parse(new JSONObject(saved).toJSONString()); + } catch (Exception e) { + throw new IllegalStateException(e); } } + + @SuppressWarnings("unchecked") + private static JSONObject json(Map values) { + JSONObject json = new JSONObject(); + json.putAll(values); + return json; + } } diff --git a/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaymentsFixture.java b/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaymentsFixture.java new file mode 100644 index 00000000..99e319d4 --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaymentsFixture.java @@ -0,0 +1,24 @@ +package net.tfminecraft.rpcharacters.paidchange; + +import java.util.Optional; +import java.util.UUID; +import java.util.function.BiPredicate; +import java.util.function.BooleanSupplier; +import java.util.function.Function; + +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import net.tfminecraft.tlibs.character.CharacterRef; + +/** Test access to the {@link Payments} seams from other packages. Restore with RuntimeTestState. */ +public final class PaymentsFixture { + private PaymentsFixture() {} + + public static void saves(BiPredicate persist) { + Payments.persist = persist; + } + + public static void heirs(BooleanSupplier ready, Function> heirs) { + Payments.heirsReady = ready; + Payments.heirs = heirs; + } +} From c6f7866d44d622c58f015c1b6a957fef9a72e0e1 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:09:34 +0000 Subject: [PATCH 2/5] Make held payments durable and settle them before other changes - Force the character file and its directory to disk before a debit is posted (Database.trySaveCharacterDurably); a failed sync takes nothing. - A held class pick or paid stage change blocks stage edits and class picks until it settles. A paid pick whose class could not be applied is saved as refund-required, so reaching the same class later is never read as success for that payment. - Legacy pending payments always refund to the legacy heir once known, whatever the character became; they are never cleared as kept. Co-Authored-By: Claude Opus 5.5 --- .../classpick/ClassPickService.java | 21 +++++-- .../classpick/PendingClassPick.java | 8 ++- .../creation/CharacterCreation.java | 12 +++- .../database/CharacterClassPickFields.java | 4 +- .../rpcharacters/database/Database.java | 43 ++++++++++++- .../rpcharacters/managers/PlayerManager.java | 4 +- .../paidchange/PaidChangeService.java | 18 +++++- .../rpcharacters/classpick/ClassPickTest.java | 39 +++++++++++- .../database/DurableCharacterSaveTest.java | 60 +++++++++++++++++++ .../managers/PlayerManagerLifecycleTest.java | 2 +- .../objects/RPCharacterRuntimeTest.java | 2 +- .../rpcharacters/paidchange/FakeLedger.java | 2 + .../paidchange/PaidChangeServiceTest.java | 34 +++++++++-- .../paidchange/PaymentsFixture.java | 4 ++ 14 files changed, 226 insertions(+), 27 deletions(-) create mode 100644 src/test/java/net/tfminecraft/rpcharacters/database/DurableCharacterSaveTest.java diff --git a/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassPickService.java b/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassPickService.java index 558202c9..7c6e7d2e 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassPickService.java +++ b/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassPickService.java @@ -234,7 +234,11 @@ public static Result choose(Player player, String classId) { } UUID owner = player.getUniqueId(); tell(player, recover(owner, character)); - if (character.getPendingClassPick() != null) { + if (character.getPendingPaidChange() != null) { + tell(player, PaidChangeService.recover(character)); + } + // A held payment is settled by comparing the class, so no other change may run meanwhile. + if (character.getPendingClassPick() != null || character.getPendingPaidChange() != null) { return new Result(Status.UNAVAILABLE, target, BigDecimal.ZERO, null); } BigDecimal cost = price(character, option); @@ -256,6 +260,11 @@ public static Result choose(Player player, String classId) { character.setMMOClass(target.getId()); if (!applied(player, target.getId())) { character.setMMOClass(oldClassId); + if (payment != null) { + // Saved first, so a later move to the same class can't pass for this payment. + character.setPendingClassPick(new PendingClassPick(target.getId(), payment, true)); + Payments.save(owner, character); + } if (payment != null && !refund(owner, character)) { tell(player, refundLater(payment)); return new Result(Status.NOT_APPLIED, target, cost, payment.account()); @@ -275,7 +284,7 @@ public static Result choose(Player player, String classId) { /** Holds the payment on the character and saves it; null drops a payment that was never taken. */ private static boolean hold(UUID owner, RPCharacter character, String targetClass, Payment payment) { - character.setPendingClassPick(payment == null ? null : new PendingClassPick(targetClass, payment)); + character.setPendingClassPick(payment == null ? null : new PendingClassPick(targetClass, payment, false)); if (Payments.save(owner, character) || payment == null) { return true; } @@ -296,8 +305,9 @@ private static void countPaid(RPCharacter character) { /** * Settles a paid pick left held by a crash or a failed refund. A debit that never applied is - * dropped; a pick whose class stuck is kept and counted; anything else is refunded with the - * stored refund ID. Returns the player message, or null when there is nothing to say. + * dropped; a pick whose class stuck (and was not marked for refund) is kept and counted; + * anything else is refunded with the stored refund ID. Returns the player message, or null when + * there is nothing to say. */ public static String recover(UUID owner, RPCharacter character) { PendingClassPick pending = character == null ? null : character.getPendingClassPick(); @@ -307,7 +317,8 @@ public static String recover(UUID owner, RPCharacter character) { Payments.Debit debit = Payments.debited(wallet, pending.payment()); if (debit == Payments.Debit.NOT_APPLIED) { character.setPendingClassPick(null); - } else if (debit == Payments.Debit.APPLIED && pending.targetClass().equalsIgnoreCase(character.getMMOClass())) { + } else if (debit == Payments.Debit.APPLIED && !pending.refundRequired() + && pending.targetClass().equalsIgnoreCase(character.getMMOClass())) { countPaid(character); } else if (debit == Payments.Debit.APPLIED) { Payment payment = pending.payment(); diff --git a/src/main/java/net/tfminecraft/rpcharacters/classpick/PendingClassPick.java b/src/main/java/net/tfminecraft/rpcharacters/classpick/PendingClassPick.java index f7fb4657..ca28686a 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/classpick/PendingClassPick.java +++ b/src/main/java/net/tfminecraft/rpcharacters/classpick/PendingClassPick.java @@ -2,5 +2,9 @@ import net.tfminecraft.rpcharacters.paidchange.Payment; -/** A paid class pick between its debit and the class being applied, saved with the character. */ -public record PendingClassPick(String targetClass, Payment payment) {} +/** + * A paid class pick between its debit and the class being applied, saved with the character. + * {@code refundRequired} is set once applying the class failed, so the payment is refunded even if + * the character later reaches the same class some other way. + */ +public record PendingClassPick(String targetClass, Payment payment, boolean refundRequired) {} diff --git a/src/main/java/net/tfminecraft/rpcharacters/creation/CharacterCreation.java b/src/main/java/net/tfminecraft/rpcharacters/creation/CharacterCreation.java index 7f6761c2..52c39b60 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/creation/CharacterCreation.java +++ b/src/main/java/net/tfminecraft/rpcharacters/creation/CharacterCreation.java @@ -505,9 +505,17 @@ public void jumpToStageForEdit(String stageId) { PaidChangeService.settle(this); - if (character.getPendingPaidChange() != null) { + if (p != null) { - // A refund that failed stays held. Any edit now would look like the paid change was used. + String classRefund = net.tfminecraft.rpcharacters.classpick.ClassPickService.recover(p.getUniqueId(), character); + + if (classRefund != null) RPTexts.send(p, classRefund); + + } + + if (character.getPendingPaidChange() != null || character.getPendingClassPick() != null) { + + // A held payment is settled by comparing the character. Any edit now could pass for it. RPTexts.send(p, RPTexts.ERROR + "Your last refund hasn't gone through yet. Try again after you rejoin."); diff --git a/src/main/java/net/tfminecraft/rpcharacters/database/CharacterClassPickFields.java b/src/main/java/net/tfminecraft/rpcharacters/database/CharacterClassPickFields.java index 75f96b7e..4088f038 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/database/CharacterClassPickFields.java +++ b/src/main/java/net/tfminecraft/rpcharacters/database/CharacterClassPickFields.java @@ -29,7 +29,8 @@ public static void load(RPCharacter character, JSONObject characterJson) { if (picks.get("pending") instanceof Map pending) { try { character.setPendingClassPick(new PendingClassPick((String) pending.get("target"), - Payment.fromJson((Map) pending.get("payment")))); + Payment.fromJson((Map) pending.get("payment")), + Boolean.TRUE.equals(pending.get("refund-required")))); } catch (RuntimeException e) { Logger.getLogger("RPCharacters").warning("[RPCharacters] Unreadable pending class pick " + pending + "; refund it by hand if denars were taken."); @@ -50,6 +51,7 @@ public static void save(Map defaults, RPCharacter character) { JSONObject held = new JSONObject(); held.put("target", pending.targetClass()); held.put("payment", pending.payment().toJson()); + held.put("refund-required", pending.refundRequired()); picks.put("pending", held); } defaults.put("class-picks", picks); diff --git a/src/main/java/net/tfminecraft/rpcharacters/database/Database.java b/src/main/java/net/tfminecraft/rpcharacters/database/Database.java index ae3abe0e..d24dafde 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/database/Database.java +++ b/src/main/java/net/tfminecraft/rpcharacters/database/Database.java @@ -12,10 +12,12 @@ import java.util.HashMap; import java.util.List; import java.util.Locale; +import java.nio.channels.FileChannel; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; import java.util.Map; import java.util.TreeMap; import com.google.gson.Gson; @@ -515,9 +517,28 @@ public void saveCharacter(PlayerData pd, RPCharacter c) { trySaveCharacter(pd, c); } + /** Forces a file or directory channel to disk. Replaced in tests to observe the order. */ + interface ChannelSync { + void force(FileChannel channel) throws IOException; + } + + static ChannelSync sync = channel -> channel.force(true); + /** Returns whether this character was persisted before directory publication. */ - @SuppressWarnings("unchecked") public boolean trySaveCharacter(PlayerData pd, RPCharacter c) { + return trySaveCharacter(pd, c, false); + } + + /** + * Like {@link #trySaveCharacter}, but returns true only once the file and its directory entry + * are forced to disk, so a power loss can't undo it. Used before posting a payment. + */ + public boolean trySaveCharacterDurably(PlayerData pd, RPCharacter c) { + return trySaveCharacter(pd, c, true); + } + + @SuppressWarnings("unchecked") + private boolean trySaveCharacter(PlayerData pd, RPCharacter c, boolean durable) { try { File dir = new File("plugins/RPCharacters/data/characterdata", pd.getUniqueId().toString()); if (!dir.exists()) { @@ -577,7 +598,7 @@ public boolean trySaveCharacter(PlayerData pd, RPCharacter c) { CharacterStageChangeFields.save(defaults, c); CharacterClassPickFields.save(defaults, c); saveNutritionFields(defaults, c); - if (!save(file, defaults)) return false; + if (!(durable ? save(file, defaults, true) : save(file, defaults))) return false; net.tfminecraft.rpcharacters.identity.CharacterIdentityService.saved(pd.getUniqueId(), c); net.tfminecraft.rpcharacters.playtime.CharacterPlaytimeDirectory.upsert(pd.getUniqueId(), c); net.tfminecraft.rpcharacters.mail.MailRecipientDirectory.upsert(pd.getUniqueId(), c); @@ -587,8 +608,13 @@ public boolean trySaveCharacter(PlayerData pd, RPCharacter c) { return false; } } - @SuppressWarnings("unchecked") public boolean save(File file, HashMap defaults) { + return save(file, defaults, false); + } + + /** {@code durable} forces the staged file before the rename and the directory after it. */ + @SuppressWarnings("unchecked") + boolean save(File file, HashMap defaults, boolean durable) { Path staged = null; try { JSONObject toSave = new JSONObject(); @@ -621,7 +647,18 @@ public boolean save(File file, HashMap defaults) { Path target = file.toPath().toAbsolutePath(); staged = Files.createTempFile(target.getParent(), ".rpcharacters-", ".tmp"); Files.writeString(staged, prettyJsonString, StandardCharsets.UTF_8); + if (durable) { + try (FileChannel channel = FileChannel.open(staged, StandardOpenOption.WRITE)) { + sync.force(channel); + } + } Files.move(staged, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + if (durable) { + // Fails closed: the caller must not act on a save whose rename may not survive a power loss. + try (FileChannel directory = FileChannel.open(target.getParent(), StandardOpenOption.READ)) { + sync.force(directory); + } + } return true; } catch (Exception ex) { diff --git a/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java b/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java index 236b8dd5..5674da07 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java +++ b/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java @@ -379,10 +379,10 @@ public void savePlayer(Player p) { } } - /** Writes one loaded character's file now; false when it could not be written. */ + /** Writes one loaded character's file and forces it to disk; false when that failed. */ public boolean saveCharacterNow(UUID owner, RPCharacter character) { PlayerData pd = get(owner); - return pd != null && db.trySaveCharacter(pd, character); + return pd != null && db.trySaveCharacterDurably(pd, character); } public static void stampActiveCharacterLocation(Player p) { diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java index e0bc9f83..a3e552a5 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java @@ -127,9 +127,9 @@ private static boolean hold(UUID ownerId, RPCharacter character, PaidChangeRule /** * Settles the payment held on the character. A payment whose debit never applied is dropped - * without a refund. Otherwise a changed character keeps it and counts the change, and an - * unchanged one is refunded with the stored refund ID. The hold is cleared only once one of - * those happened. + * without a refund. A legacy payment is always refunded, to the account's legacy heir once + * known. Otherwise a changed character keeps it and counts the change, and an unchanged one is + * refunded with the stored refund ID. The hold is cleared only once one of those happened. */ public static Outcome resolve(RPCharacter character) { PendingPaidChange pending = character.getPendingPaidChange(); @@ -146,12 +146,20 @@ public static Outcome resolve(RPCharacter character) { character.setPendingPaidChange(null); return Outcome.DISCARDED; } + // The old wallet's payments are refunded to the legacy heir whatever the character became. + if (payment != null && payment.isLegacy()) { + return refund(character, pending, payment); + } if (!snapshot(character).equals(pending.before())) { character.setPaidChangeCount(pending.stageId(), (int) Math.min(Integer.MAX_VALUE, (long) character.getPaidChangeCount(pending.stageId()) + 1)); character.setPendingPaidChange(null); return Outcome.KEPT; } + return refund(character, pending, payment); + } + + private static Outcome refund(RPCharacter character, PendingPaidChange pending, Payment payment) { if (payment != null && Payments.refund(wallet, payment) != Posting.APPLIED) { LOG.log(Level.WARNING, "[RPCharacters] Could not post " + payment.refundOpId() + " for a " + pending.label() + " change; keeping it held to retry."); @@ -238,6 +246,10 @@ private static String outcomeMessage(PendingPaidChange pending, Outcome outcome, if (pending.cents() <= 0) { return null; } + if (pending.payment().isLegacy()) { + return RPTexts.SUCCESS + "Your earlier " + formatCents(pending.cents()) + " payment for a " + + pending.label() + " change went back to your " + pending.account().displayName() + "."; + } return RPTexts.SUCCESS + "Your " + pending.label() + " is unchanged, so " + formatCents(pending.cents()) + " went back to your " + pending.account().displayName() + "."; } diff --git a/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassPickTest.java b/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassPickTest.java index 209496f4..01cae1bf 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassPickTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassPickTest.java @@ -60,6 +60,7 @@ import net.tfminecraft.rpcharacters.paidchange.FakeLedger; import net.tfminecraft.rpcharacters.paidchange.Payment; import net.tfminecraft.rpcharacters.paidchange.Payments; +import net.tfminecraft.rpcharacters.paidchange.PendingPaidChange; import net.tfminecraft.rpcharacters.paidchange.PaymentsFixture; import net.tfminecraft.rpcharacters.database.CharacterClassPickFields; import net.tfminecraft.tlibs.character.CharacterRef; @@ -462,7 +463,7 @@ Inventory openSubclasses() { @Test void pendingPicksSurviveASaveAndUnreadableOnesAreDropped() { Payment payment = Payment.open(new CharacterRef(player.getUniqueId(), UUID.fromString(character.getId())), Account.BANK, 500, "class-pick"); - character.setPendingClassPick(new PendingClassPick("MAGE", payment)); + character.setPendingClassPick(new PendingClassPick("MAGE", payment, true)); RPCharacter loaded = new RPCharacter(player); CharacterClassPickFields.load(loaded, picksJson(character)); assertEquals(character.getPendingClassPick(), loaded.getPendingClassPick()); RPCharacter broken = new RPCharacter(player); @@ -470,6 +471,42 @@ Inventory openSubclasses() { CharacterClassPickFields.load(broken, json); assertNull(broken.getPendingClassPick()); } + @Test void aFailedPaidPickIsRefundedBeforeAnyEditEvenIfTheClassLaterMatches() { + wallet.set(Account.POUCH, 25_000); List onDisk = new ArrayList<>(); + PaymentsFixture.saves((owner, saved) -> onDisk.add(picksJson(saved))); + classService.when(() -> ClassService.applyClass(player, "mage")).thenReturn(false); wallet.depositsDown = true; + var failed = ClassPickService.choose(player, "mage"); + assertEquals(Status.NOT_APPLIED, failed.status()); assertEquals(5_000, wallet.get(Account.POUCH)); + assertTrue(character.getPendingClassPick().refundRequired()); + assertEquals(true, ((Map) ((Map) onDisk.getLast().get("class-picks")).get("pending")).get("refund-required")); + + Stage classStage = new Stage(); classStage.setId("class_selection_stage"); StageLoader.oList.add(classStage); + CharacterCreation edit = CharacterCreation.forEdit(player, character); messages(); + edit.jumpToStageForEdit("class_selection_stage"); + assertTrue(messages().contains("last refund hasn't gone through yet")); assertNotNull(character.getPendingClassPick()); + level.set(2); assertEquals(Status.UNAVAILABLE, ClassPickService.choose(player, "berserker").status()); + + // The ledger is back and the character reached the same class some other way: still a refund. + wallet.depositsDown = false; character.setMMOClass("mage"); + edit.jumpToStageForEdit("class_selection_stage"); + assertTrue(messages().contains("200 denars went back to your pouch")); + assertNull(character.getPendingClassPick()); assertEquals(25_000, wallet.get(Account.POUCH)); assertEquals(0, character.getPaidClassPicks()); + assertEquals(1, wallet.count(false)); + } + + @Test void aHeldPaidStageChangeBlocksClassPicksUntilItSettles() { + wallet.set(Account.POUCH, 50_000); boolean[] ready = {false}; PaymentsFixture.paidChangeWallet(wallet); + CharacterRef heir = new CharacterRef(player.getUniqueId(), UUID.fromString(character.getId())); + PaymentsFixture.heirs(() -> ready[0], owner -> Optional.of(heir)); + character.setPendingPaidChange(new PendingPaidChange("class_selection_stage", "class", + Payment.legacy(player.getUniqueId(), Account.POUCH, 1_000, "refund-legacy:x:class_selection_stage"), "before")); + assertEquals(Status.UNAVAILABLE, ClassPickService.choose(player, "mage").status()); + assertTrue(messages().contains("refund couldn't go through yet")); assertEquals("WARRIOR", character.getMMOClass()); + ready[0] = true; + assertEquals(Status.CHOSEN, ClassPickService.choose(player, "mage").status()); + assertNull(character.getPendingPaidChange()); assertEquals(31_000, wallet.get(Account.POUCH)); + } + @SuppressWarnings("unchecked") private static JSONObject picksJson(RPCharacter character) { HashMap saved = new HashMap<>(); CharacterClassPickFields.save(saved, character); diff --git a/src/test/java/net/tfminecraft/rpcharacters/database/DurableCharacterSaveTest.java b/src/test/java/net/tfminecraft/rpcharacters/database/DurableCharacterSaveTest.java new file mode 100644 index 00000000..19ecb696 --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/database/DurableCharacterSaveTest.java @@ -0,0 +1,60 @@ +package net.tfminecraft.rpcharacters.database; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.rpcharacters.objects.PlayerData; +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import net.tfminecraft.rpcharacters.paidchange.DenarWallet; +import net.tfminecraft.rpcharacters.paidchange.DenarWallet.Account; +import net.tfminecraft.rpcharacters.paidchange.FakeLedger; +import net.tfminecraft.rpcharacters.paidchange.Payments; +import net.tfminecraft.rpcharacters.paidchange.PendingPaidChange; + +class DurableCharacterSaveTest extends DatabaseFixture { + private final Database.ChannelSync original = Database.sync; + private final List events = new ArrayList<>(); + private final FakeLedger wallet = new FakeLedger(); + + @AfterEach void restoreSync() { Database.sync = original; } + + private Payments.Charge charge(RPCharacter character) { + PlayerData account = new PlayerData(owner); + return Payments.take(wallet, DenarWallet.PAID_CHANGE, "class", Payments.payer(owner, character), List.of(Account.POUCH), 1_000, + payment -> { + character.setPendingPaidChange(payment == null ? null : new PendingPaidChange("class", "class", payment, "before")); + return db.trySaveCharacterDurably(account, character); + }); + } + + @Test void theIntentFileAndItsDirectoryAreForcedBeforeTheDebitIsPosted() throws Exception { + RPCharacter character = character(UUID.randomUUID().toString(), false); + Database.sync = channel -> { events.add("force"); channel.force(true); }; + wallet.set(Account.POUCH, 5_000); + wallet.beforePosting = () -> { + try { + events.add("withdraw, on disk: " + Files.readString(characterFile(character.getId())).contains("paid-change-pending")); + } catch (IOException e) { + throw new IllegalStateException(e); + } + }; + assertEquals(Payments.Taken.PAID, charge(character).status()); + assertEquals(List.of("force", "force", "withdraw, on disk: true"), events); + } + + @Test void anIntentWhoseDirectoryCannotBeForcedIsNeverDebited() { + RPCharacter character = character(UUID.randomUUID().toString(), false); + Database.sync = channel -> { events.add("force"); if (events.size() == 2) throw new IOException("directory sync failed"); }; + wallet.set(Account.POUCH, 5_000); + assertEquals(Payments.Taken.UNAVAILABLE, charge(character).status()); + assertTrue(wallet.attempts.isEmpty()); assertEquals(5_000, wallet.get(Account.POUCH)); + } +} diff --git a/src/test/java/net/tfminecraft/rpcharacters/managers/PlayerManagerLifecycleTest.java b/src/test/java/net/tfminecraft/rpcharacters/managers/PlayerManagerLifecycleTest.java index 80087aa0..0ac28340 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/managers/PlayerManagerLifecycleTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/managers/PlayerManagerLifecycleTest.java @@ -452,7 +452,7 @@ void unsuccessfulMmoSetupNeverMarksASharedSessionReady() { @Test void savingOneCharacterNowWritesOnlyALoadedOwnersFile() { - UUID id = player.getUniqueId(); when(database.trySaveCharacter(data, character)).thenReturn(true); + UUID id = player.getUniqueId(); when(database.trySaveCharacterDurably(data, character)).thenReturn(true); assertTrue(manager.saveCharacterNow(id, character)); assertTrue(net.tfminecraft.rpcharacters.paidchange.Payments.save(id, character)); assertFalse(manager.saveCharacterNow(UUID.randomUUID(), character)); diff --git a/src/test/java/net/tfminecraft/rpcharacters/objects/RPCharacterRuntimeTest.java b/src/test/java/net/tfminecraft/rpcharacters/objects/RPCharacterRuntimeTest.java index 7dcf8a09..b1679c92 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/objects/RPCharacterRuntimeTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/objects/RPCharacterRuntimeTest.java @@ -135,7 +135,7 @@ class RPCharacterRuntimeTest { c.setPvpLethal(false); assertFalse(c.isPvpLethal()); c.setPaidChangeCount(null,3); assertEquals(0,c.getPaidChangeCount(null)); c.setPaidChangeCount("class",2); assertEquals(2,c.getPaidChangeCounts().get("class")); c.setPaidChangeCount("class",0); assertEquals(0,c.getPaidChangeCount("class")); assertThrows(UnsupportedOperationException.class,() -> c.getPaidChangeCounts().put("bad",1)); var pending=new PendingPaidChange("class","class",null,"{}"); c.setPendingPaidChange(pending); assertSame(pending,c.getPendingPaidChange()); c.setPendingPaidChange(null); assertNull(c.getPendingPaidChange()); assertEquals(0,pending.cents()); assertNull(pending.account()); - var pick=new net.tfminecraft.rpcharacters.classpick.PendingClassPick("mage",null); c.setPendingClassPick(pick); assertSame(pick,c.getPendingClassPick()); c.setPendingClassPick(null); assertNull(c.getPendingClassPick()); + var pick=new net.tfminecraft.rpcharacters.classpick.PendingClassPick("mage",null,false); c.setPendingClassPick(pick); assertSame(pick,c.getPendingClassPick()); c.setPendingClassPick(null); assertNull(c.getPendingClassPick()); c.setStageRevision(null,3,9); assertEquals(0,c.getStageRevision(null)); assertEquals(0,c.getStageRevisionSince(null)); c.setStageRevision("class",2,9); assertEquals(2,c.getStageRevision("class")); assertEquals(9,c.getStageRevisionSince("class")); c.setStageRevision("class",3,0); assertEquals(0,c.getStageRevisionSince("class")); assertEquals(Map.of("class",3),c.getStageRevisions()); assertThrows(UnsupportedOperationException.class,() -> c.getStageRevisions().clear()); c.setEvilRpStrikes(-1); assertEquals(0,c.getEvilRpStrikes()); c.setEvilRpStrikes(3); assertEquals(3,c.getEvilRpStrikes()); c.setEvilRpSessionEndsAtMs(-1); assertEquals(0,c.getEvilRpSessionEndsAtMs()); c.setEvilRpSessionEndsAtMs(50); assertEquals(50,c.getEvilRpSessionEndsAtMs()); c.setLastStrikeAtMs(-1); assertEquals(0,c.getLastStrikeAtMs()); c.setLastStrikeAtMs(20); assertEquals(20,c.getLastStrikeAtMs()); c.setFoodValue(-1); assertEquals(0,c.getFoodValue()); c.setFoodValue(1000); assertEquals(200,c.getFoodValue()); c.setDietScore(-1); assertEquals(0,c.getDietScore()); c.setDietScore(100); assertEquals(40,c.getDietScore()); c.setRawDietScore(100); assertEquals(100,c.getRawDietScore()); c.setRawDietScore(-1); assertEquals(0,c.getRawDietScore()); c.setLastDietTierId("healthy"); assertEquals("healthy",c.getLastDietTierId()); diff --git a/src/test/java/net/tfminecraft/rpcharacters/paidchange/FakeLedger.java b/src/test/java/net/tfminecraft/rpcharacters/paidchange/FakeLedger.java index 9a373645..2535f48e 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/paidchange/FakeLedger.java +++ b/src/test/java/net/tfminecraft/rpcharacters/paidchange/FakeLedger.java @@ -31,6 +31,7 @@ public record Call(String opId, String reason, CharacterRef character, Account a public boolean statusDown; /** Debits from the pouch are refused regardless of balance. */ public boolean rejectPouch; + public Runnable beforePosting = () -> {}; public Runnable afterPosting = () -> {}; public long get(Account account) { @@ -62,6 +63,7 @@ public long count(boolean debit) { } private Posting post(Call call) { + beforePosting.run(); attempts.add(call); if (postingsDown || (depositsDown && !call.debit())) { return Posting.UNAVAILABLE; diff --git a/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeServiceTest.java b/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeServiceTest.java index ec11adcd..290fba0d 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeServiceTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeServiceTest.java @@ -488,7 +488,7 @@ void legacyHoldsRefundTheAccountsHeirOnceHeirsAreReady() throws Exception { assertEquals(PaidChangeService.Outcome.REFUND_FAILED, PaidChangeService.resolve(character), "no heir yet"); heirs.put(account, heir); RPCharacter stale = reload(character, "WARRIOR"); - assertTrue(PaidChangeService.recover(character).contains("100 denars went back to your bank")); + assertTrue(PaidChangeService.recover(character).contains("100 denars payment for a class change went back to your bank")); assertNull(character.getPendingPaidChange()); assertEquals(new FakeLedger.Call(legacy.refundOpId(), DenarWallet.REFUND, heir, Account.BANK, 10_000, false), wallet.applied.get(0)); @@ -496,11 +496,33 @@ void legacyHoldsRefundTheAccountsHeirOnceHeirsAreReady() throws Exception { assertEquals(1, wallet.count(false)); assertEquals(10_000, wallet.get(Account.BANK)); - RPCharacter changed = characterWithClass("WARRIOR"); - CharacterStageChangeFields.load(changed, json(Map.of("paid-change-pending", old))); - changed.setMMOClass("MAGE"); - assertEquals(PaidChangeService.Outcome.KEPT, PaidChangeService.resolve(changed)); - assertEquals(1, changed.getPaidChangeCount(CLASS_STAGE)); + } + + @Test + void aLegacyHoldWhoseCharacterChangedStillWaitsForTheHeirAndRefundsOnce() { + UUID account = UUID.randomUUID(); + CharacterRef heir = new CharacterRef(account, UUID.randomUUID()); + boolean[] ready = {false}; + PaymentsFixture.heirs(() -> ready[0], owner -> Optional.of(heir)); + RPCharacter character = characterWithClass("WARRIOR"); + JSONObject old = new JSONObject(); + old.putAll(Map.of("stage", CLASS_STAGE, "label", "class", "payer", account.toString(), "account", "POUCH", + "amount", "100", "before", PaidChangeService.snapshot(character))); + CharacterStageChangeFields.load(character, json(Map.of("paid-change-pending", old))); + character.setMMOClass("MAGE"); + + assertEquals(PaidChangeService.Outcome.REFUND_FAILED, PaidChangeService.resolve(character)); + assertTrue(character.getPendingPaidChange() != null, "a legacy hold is never cleared as kept"); + assertEquals(0, character.getPaidChangeCount(CLASS_STAGE)); + assertTrue(wallet.attempts.isEmpty()); + + ready[0] = true; + String message = PaidChangeService.recover(character); + assertTrue(message.contains("earlier 100 denars payment for a class change went back to your pouch"), message); + assertNull(character.getPendingPaidChange()); + assertEquals(0, character.getPaidChangeCount(CLASS_STAGE)); + assertEquals(List.of(new FakeLedger.Call("refund-legacy:" + character.getId() + ":" + CLASS_STAGE, + DenarWallet.REFUND, heir, Account.POUCH, 10_000, false)), wallet.applied); } @Test diff --git a/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaymentsFixture.java b/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaymentsFixture.java index 99e319d4..a4787f5e 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaymentsFixture.java +++ b/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaymentsFixture.java @@ -17,6 +17,10 @@ public static void saves(BiPredicate persist) { Payments.persist = persist; } + public static void paidChangeWallet(DenarWallet wallet) { + PaidChangeService.setWallet(wallet); + } + public static void heirs(BooleanSupplier ready, Function> heirs) { Payments.heirsReady = ready; Payments.heirs = heirs; From 3559f8cd29cc9c95aa6125e3f28bea3849847f95 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:42:44 +0000 Subject: [PATCH 3/5] feat: expose saved deaths and character faction context --- pom.xml | 4 +- .../body/CharacterSelectView.java | 29 +++++++------ .../RealmOocChatRecipientResolver.java | 10 +++-- .../identity/CharacterDirectory.java | 16 +++++++ .../identity/CharacterIdentityService.java | 6 +++ .../body/CharacterSelectViewTest.java | 42 ++++++++++++------- .../RealmChatDependencyListenerTest.java | 10 +++-- .../RealmOocChatRecipientResolverTest.java | 24 ++++++++--- .../identity/CharacterDirectoryTest.java | 21 ++++++++++ .../CharacterIdentityServiceTest.java | 4 ++ 10 files changed, 125 insertions(+), 41 deletions(-) diff --git a/pom.xml b/pom.xml index d26e3a81..d4a6d9ee 100644 --- a/pom.xml +++ b/pom.xml @@ -12,8 +12,8 @@ 21 UTF-8 - 3.0.1 - 2.4.0 + 4.0.0 + 2.4.1 2.7.0 7.0.9 diff --git a/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java b/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java index 18eced15..4711b3e7 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java +++ b/src/main/java/net/tfminecraft/rpcharacters/body/CharacterSelectView.java @@ -110,7 +110,7 @@ interface PortraitLoader { static DenarWallet wallet = new DenarEconomyWallet(); /** Guild name, "" for none, or null when guilds can't be read. */ - static Function guilds = CharacterSelectView::simpleFactionsGuild; + static Function guilds = CharacterSelectView::simpleFactionsGuild; static PortraitLoader portraits = SelectPortraits::load; static Function accountTextures = CharacterSelectView::accountSkin; static BiFunction, ClickCallback.Options, ClickEvent> clickEvents = ClickEvent::callback; @@ -440,8 +440,7 @@ private static void fill(BufferedImage image, int x, int y, int width, int heigh private static Component footer(Player player, PlayerData data, int alive) { int slots = CharacterSlotService.getMaxAliveCharacters(player); Component used = Component.text(alive + " of " + slots + " character slots used", DIM); - Component account = accountSummary(player); - return account == null ? used : used.append(Component.text(" • ", DIM)).append(account); + return used; } /** Living characters, most recently played first, then memorials. */ @@ -486,12 +485,13 @@ static String createBlockReason(Player player, PlayerData data) { } /** The guild line, or null when guilds can't be read. */ - static Component accountSummary(Player player) { - String guild = guilds.apply(player); + static Component guildSummary(RPCharacter character) { + UUID id; + try { id = UUID.fromString(character.getId()); } + catch (IllegalArgumentException | NullPointerException invalid) { return null; } + String guild = guilds.apply(id); if (guild == null) return null; - return Component.text("Shared by all your characters", DIM) - .append(Component.newline()) - .append(Component.text("Guild: " + (guild.isBlank() ? "none" : guild), MUTED)); + return Component.text("Guild: " + (guild.isBlank() ? "none" : guild), MUTED); } /** This character's own pouch and bank; empty for a record without a character ID. */ @@ -519,6 +519,8 @@ static Component characterCard(UUID owner, RPCharacter character, String blockRe } line(card, String.join(" • ", history), MUTED); line(card, balances(owner, character), MUTED); + Component guild = guildSummary(character); + if (guild != null) card.append(Component.newline()).append(guild); if (character.hasLastLocation()) { line(card, String.format(Locale.ROOT, "Last location: %s %d, %d, %d", character.getLastLocationWorld(), (long) Math.floor(character.getLastLocationX()), (long) Math.floor(character.getLastLocationY()), @@ -632,11 +634,11 @@ private static ActionButton button(Component label, String tooltip, int width, S return builder.build(); } - /** Account-wide until characters own their guild membership. */ - static String simpleFactionsGuild(Player player) { + /** The guild belonging to the character shown on this card. */ + static String simpleFactionsGuild(UUID characterId) { if (!Bukkit.getPluginManager().isPluginEnabled("SimpleFactions")) return null; try { - return SimpleFactionsGuild.name(player.getName()); + return SimpleFactionsGuild.name(characterId); } catch (NoClassDefFoundError | RuntimeException failure) { return null; } @@ -646,9 +648,10 @@ static String simpleFactionsGuild(Player player) { static final class SimpleFactionsGuild { private SimpleFactionsGuild() {} - static String name(String playerName) { + static String name(UUID characterId) { + if (!net.tfminecraft.simplefactions.api.SimpleFactionsApi.ready()) return null; net.tfminecraft.simplefactions.guild.Guild guild = - net.tfminecraft.simplefactions.managers.FactionManager.getGuildByMember(playerName); + net.tfminecraft.simplefactions.managers.FactionManager.getGuildByMember(characterId); return guild == null || guild.getName() == null ? "" : ClueFormatter.stripColor(guild.getName()); } } diff --git a/src/main/java/net/tfminecraft/rpcharacters/factions/RealmOocChatRecipientResolver.java b/src/main/java/net/tfminecraft/rpcharacters/factions/RealmOocChatRecipientResolver.java index de6b1cc6..19a40320 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/factions/RealmOocChatRecipientResolver.java +++ b/src/main/java/net/tfminecraft/rpcharacters/factions/RealmOocChatRecipientResolver.java @@ -14,6 +14,8 @@ import net.tfminecraft.rpcharacters.chat.ChatRecipientFilters; import net.tfminecraft.rpcharacters.chat.ChatRecipientResolver; import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.simplefactions.api.SimpleFactionsApi; import net.tfminecraft.simplefactions.objects.Faction; /** Resolves ROOC recipients to online members of the sender's top-overlord realm. */ @@ -21,11 +23,11 @@ public final class RealmOocChatRecipientResolver implements ChatRecipientResolve @Override public Set resolve(Player sender, ChatChannel channel) { - if (sender == null || channel == null) { + if (sender == null || channel == null || !SimpleFactionsApi.ready()) { return Collections.emptySet(); } - Faction faction = FactionManager.getByMember(sender.getName()); + Faction faction = FactionManager.getByMember(sender); if (faction == null) { return Collections.emptySet(); } @@ -34,13 +36,13 @@ public Set resolve(Player sender, ChatChannel channel) { Set realmMembers = new HashSet<>(); for (String member : overlord.getCompleteMemberList()) { if (member != null) { - realmMembers.add(member.toLowerCase(Locale.ROOT)); + realmMembers.add(member); } } List candidates = new ArrayList<>(); for (Player online : Bukkit.getOnlinePlayers()) { - if (realmMembers.contains(online.getName().toLowerCase(Locale.ROOT))) { + if (realmMembers.contains(Members.key(online))) { candidates.add(online); } } diff --git a/src/main/java/net/tfminecraft/rpcharacters/identity/CharacterDirectory.java b/src/main/java/net/tfminecraft/rpcharacters/identity/CharacterDirectory.java index 3e74ce30..993f38f6 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/identity/CharacterDirectory.java +++ b/src/main/java/net/tfminecraft/rpcharacters/identity/CharacterDirectory.java @@ -39,6 +39,7 @@ public final class CharacterDirectory { private final Map> accounts = new HashMap<>(); private final Map owners = new HashMap<>(); + private final Map deaths = new HashMap<>(); private final Set unavailable = new HashSet<>(); /** Characters saved with the legacy {@code active} flag, which predates session timestamps. */ private final Set flaggedActive = new HashSet<>(); @@ -71,6 +72,18 @@ public Optional info(UUID characterId) { return Optional.ofNullable(accounts.getOrDefault(ownerId, Map.of()).get(characterId)); } + /** Most recent saved death, with a stable migration key for deaths predating death records. */ + public Optional deathId(UUID characterId) { + return info(characterId).flatMap(info -> Optional.ofNullable(deaths.get(characterId))); + } + + private void rememberDeath(CharacterInfo info, UUID recorded) { + if (recorded != null) deaths.put(info.ref().characterId(), recorded); + else if (info.status() == CharacterStatus.DEAD) deaths.putIfAbsent(info.ref().characterId(), + UUID.nameUUIDFromBytes(("rpcharacters:legacy-death:" + info.ref().characterId()) + .getBytes(StandardCharsets.UTF_8))); + } + /** * The latest session start wins. Records saved before session timestamps fall back to the * character still flagged active; any timestamp beats a flag alone. @@ -93,6 +106,7 @@ public void upsert(UUID ownerId, RPCharacter character) { return; } upsert(ownerId, info, Boolean.TRUE.equals(character.isActive())); + rememberDeath(info, character.getDeathId()); } /** Updates from a snapshot taken earlier, such as when a save finished off the main thread. */ @@ -111,6 +125,7 @@ public void remove(UUID ownerId, String characterId) { Map characters = accounts.get(ownerId); if (characters != null) characters.remove(id); flaggedActive.remove(new CharacterRef(ownerId, id)); + deaths.remove(id); if (!unavailable.contains(ownerId)) owners.remove(id, ownerId); } catch (RuntimeException error) { quarantine(ownerId, "Invalid removed character ID: " + error.getMessage()); @@ -185,6 +200,7 @@ private void readCharacter(UUID ownerId, Map characters, Se CharacterInfo info = new CharacterInfo(new CharacterRef(ownerId, id), plainName(string(json.get("name"))), CharacterStatus.valueOf(string(json.get("status")).toUpperCase(Locale.ROOT)), race, traits, seconds, lastPlayed); characters.put(id, info); + rememberDeath(info, json.get("death-id") == null ? null : uuid(string(json.get("death-id")))); flag(info.ref(), Boolean.parseBoolean(String.valueOf(json.get("active")))); } catch (IOException | RuntimeException error) { quarantine(ownerId, "Cannot read character " + file + ": " + error.getMessage()); diff --git a/src/main/java/net/tfminecraft/rpcharacters/identity/CharacterIdentityService.java b/src/main/java/net/tfminecraft/rpcharacters/identity/CharacterIdentityService.java index 7c437131..7a5759dd 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/identity/CharacterIdentityService.java +++ b/src/main/java/net/tfminecraft/rpcharacters/identity/CharacterIdentityService.java @@ -84,6 +84,12 @@ public Optional info(UUID characterId) { return directory.info(characterId); } + @Override + public Optional deathId(UUID characterId) { + refreshCharacter(characterId); + return directory.deathId(characterId); + } + @Override public Optional> characters(UUID ownerId) { refreshOwner(ownerId); diff --git a/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java b/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java index ea93c3d8..b49ee713 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/body/CharacterSelectViewTest.java @@ -256,10 +256,9 @@ void focusesTheMostRecentCharacterWithStripBustCardActionsAndSharedFooter() { assertEquals(List.of(wrenId + "|" + ACCOUNT_SKIN.url()), requested); assertEquals("‹ Lady Wren Rowan ›\nLady Wren\nHuman • Sword master • Age 27, born " + FantasyCalendar.formatBirthday(birthday) + "\nPlayed 2d 3h 4m • Last played 2d 2h ago\n" - + "Pouch: 12.50 denars • Bank: 300 denars\n" + + "Pouch: 12.50 denars • Bank: 300 denars\nGuild: Ironhand\n" + "Last location: world 120, 64, -31\n" - + "2 of 4 character slots used • Shared by all your characters\n" - + "Guild: Ironhand", ui.last().bodyText()); + + "2 of 4 character slots used", ui.last().bodyText()); verify(wallet, atLeastOnce()).balance(new CharacterRef(OWNER, UUID.fromString(wrenId)), Account.BANK); for (var part : ui.last().parts()) { if (part != ui.last().portraitPart()) assertEquals(300, part.width()); @@ -820,11 +819,11 @@ void balancesSayUnavailableInsteadOfZeroAndTheFooterOmitsAnUnreadableGuild() { assertEquals("", CharacterSelectView.balances(OWNER, character("not-an-id", "Odd", Status.ALIVE))); verify(wallet, never()).balance(any(), any()); CharacterSelectView.guilds = p -> null; - assertNull(CharacterSelectView.accountSummary(player)); + assertNull(CharacterSelectView.guildSummary(cora)); CharacterSelectView.open(player, data, actions); assertTrue(ui.last().bodyText().endsWith("Pouch and bank: unavailable\n2 of 4 character slots used"), ui.last().bodyText()); CharacterSelectView.guilds = p -> ""; - assertEquals("Shared by all your characters\nGuild: none", text(CharacterSelectView.accountSummary(player))); + assertEquals("Guild: none", text(CharacterSelectView.guildSummary(cora))); } @Test @@ -891,20 +890,25 @@ void placeholderIsANeutralFigureTheHeightOfABust() { @Test void guildLookupUsesSimpleFactionsOnlyWhenEnabled() { PluginManager plugins = mock(PluginManager.class); - try (var bukkit = mockStatic(Bukkit.class); var factions = mockStatic(FactionManager.class)) { + try (var bukkit = mockStatic(Bukkit.class); var factions = mockStatic(FactionManager.class); + var api = mockStatic(net.tfminecraft.simplefactions.api.SimpleFactionsApi.class)) { + api.when(net.tfminecraft.simplefactions.api.SimpleFactionsApi::ready).thenReturn(true); bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); - assertNull(CharacterSelectView.simpleFactionsGuild(player)); + assertNull(CharacterSelectView.simpleFactionsGuild(OWNER)); factions.verifyNoInteractions(); when(plugins.isPluginEnabled("SimpleFactions")).thenReturn(true); - assertEquals("", CharacterSelectView.simpleFactionsGuild(player)); + api.when(net.tfminecraft.simplefactions.api.SimpleFactionsApi::ready).thenReturn(false); + assertNull(CharacterSelectView.simpleFactionsGuild(OWNER)); + api.when(net.tfminecraft.simplefactions.api.SimpleFactionsApi::ready).thenReturn(true); + assertEquals("", CharacterSelectView.simpleFactionsGuild(OWNER)); Guild guild = mock(Guild.class); - factions.when(() -> FactionManager.getGuildByMember("Owner")).thenReturn(guild); - assertEquals("", CharacterSelectView.simpleFactionsGuild(player)); + factions.when(() -> FactionManager.getGuildByMember(OWNER)).thenReturn(guild); + assertEquals("", CharacterSelectView.simpleFactionsGuild(OWNER)); when(guild.getName()).thenReturn("&6Ironhand"); - assertEquals("Ironhand", CharacterSelectView.simpleFactionsGuild(player)); - factions.when(() -> FactionManager.getGuildByMember("Owner")).thenThrow(new IllegalStateException("loading")); - assertNull(CharacterSelectView.simpleFactionsGuild(player)); + assertEquals("Ironhand", CharacterSelectView.simpleFactionsGuild(OWNER)); + factions.when(() -> FactionManager.getGuildByMember(OWNER)).thenThrow(new IllegalStateException("loading")); + assertNull(CharacterSelectView.simpleFactionsGuild(OWNER)); } } @@ -920,12 +924,22 @@ void defaultsReadDenarEconomySimpleFactionsAndTheBukkitMainThread() { try (var bukkit = mockStatic(Bukkit.class)) { bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); - assertNull(CharacterSelectView.guilds.apply(player)); + assertNull(CharacterSelectView.guilds.apply(OWNER)); CharacterSelectView.mainThread.execute(task); verify(scheduler).runTask(RPCharacters.plugin, task); } } + @Test void eachCharacterCardShowsItsOwnGuild() { + UUID first=UUID.randomUUID(), second=UUID.randomUUID(); + RPCharacter a=character(first.toString(),"First",Status.ALIVE); + RPCharacter b=character(second.toString(),"Second",Status.ALIVE); + CharacterSelectView.guilds=id -> id.equals(first) ? "Ironhand" : "Rivals"; + assertEquals("Guild: Ironhand",text(CharacterSelectView.guildSummary(a))); + assertEquals("Guild: Rivals",text(CharacterSelectView.guildSummary(b))); + assertFalse(text(CharacterSelectView.characterCard(OWNER,b,null)).contains("Ironhand")); + } + /** Captures dialogs built through Paper's static builders. */ static final class DialogCapture implements AutoCloseable { final List opened = new ArrayList<>(); diff --git a/src/test/java/net/tfminecraft/rpcharacters/factions/RealmChatDependencyListenerTest.java b/src/test/java/net/tfminecraft/rpcharacters/factions/RealmChatDependencyListenerTest.java index 92eefde7..85f70d18 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/factions/RealmChatDependencyListenerTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/factions/RealmChatDependencyListenerTest.java @@ -17,7 +17,7 @@ class RealmChatDependencyListenerTest { ServerMock server; PlayerMock sender, member; RuntimeTestState state; - MockedStatic factions; ChatChannel channel; + MockedStatic members; MockedStatic api; MockedStatic factions; ChatChannel channel; RealmChatDependencyListener listener = new RealmChatDependencyListener(); @BeforeEach void setup() { server = MockBukkit.mock(); state = new RuntimeTestState(ChatRecipientResolverRegistry.class); @@ -26,10 +26,14 @@ class RealmChatDependencyListenerTest { var config = new YamlConfiguration(); config.set("require-character", false); config.set("read-perm", "realm.read"); channel = new ChatChannel("rooc", config); Faction realm = mock(Faction.class); when(realm.getId()).thenReturn("realm"); when(realm.getCompleteMemberList()).thenReturn(List.of("Sender", "Member")); - factions = mockStatic(FactionManager.class); factions.when(() -> FactionManager.getByMember("Sender")).thenReturn(realm); + factions = mockStatic(FactionManager.class); factions.when(() -> FactionManager.getByMember(sender)).thenReturn(realm); + members=mockStatic(net.tfminecraft.simplefactions.identity.Members.class); + members.when(() -> net.tfminecraft.simplefactions.identity.Members.key(any(Player.class))).thenAnswer(i -> i.getArgument(0,Player.class).getName()); + api=mockStatic(net.tfminecraft.simplefactions.api.SimpleFactionsApi.class); + api.when(net.tfminecraft.simplefactions.api.SimpleFactionsApi::ready).thenReturn(true); server.getPluginManager().registerEvents(listener, MockBukkit.createMockPlugin()); } - @AfterEach void cleanup() { factions.close(); state.close(); MockBukkit.unmock(); } + @AfterEach void cleanup() { members.close(); api.close(); factions.close(); state.close(); MockBukkit.unmock(); } Set realmRecipients() { return ChatRecipientResolverRegistry.resolve(RealmChatDependencyListener.RESOLVER_ID, sender, channel); } @Test void startupCheckWithoutSimpleFactionsLeavesRealmChatUnresolved() { diff --git a/src/test/java/net/tfminecraft/rpcharacters/factions/RealmOocChatRecipientResolverTest.java b/src/test/java/net/tfminecraft/rpcharacters/factions/RealmOocChatRecipientResolverTest.java index 28ee86e5..fad6cd9b 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/factions/RealmOocChatRecipientResolverTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/factions/RealmOocChatRecipientResolverTest.java @@ -15,7 +15,7 @@ class RealmOocChatRecipientResolverTest { ServerMock server; PlayerMock sender, member, outsider; RuntimeTestState state; - MockedStatic factions; ChatChannel channel; + MockedStatic members; MockedStatic api; MockedStatic factions; ChatChannel channel; RealmOocChatRecipientResolver resolver = new RealmOocChatRecipientResolver(); @BeforeEach void setup() { server = MockBukkit.mock(); state = new RuntimeTestState(); @@ -23,25 +23,39 @@ class RealmOocChatRecipientResolverTest { var config = new YamlConfiguration(); config.set("require-character", false); config.set("read-perm", "realm.read"); channel = new ChatChannel("realm", config); sender.addAttachment(MockBukkit.createMockPlugin(), "realm.read", true); member.setOp(true); factions = mockStatic(FactionManager.class); + members=mockStatic(net.tfminecraft.simplefactions.identity.Members.class); + members.when(() -> net.tfminecraft.simplefactions.identity.Members.key(any(org.bukkit.entity.Player.class))) + .thenAnswer(i -> i.getArgument(0,org.bukkit.entity.Player.class).getName()); + api=mockStatic(net.tfminecraft.simplefactions.api.SimpleFactionsApi.class); + api.when(net.tfminecraft.simplefactions.api.SimpleFactionsApi::ready).thenReturn(true); } - @AfterEach void cleanup() { factions.close(); state.close(); MockBukkit.unmock(); } + @AfterEach void cleanup() { members.close(); api.close(); factions.close(); state.close(); MockBukkit.unmock(); } Faction faction(String id, List names) { Faction faction = mock(Faction.class); when(faction.getId()).thenReturn(id); when(faction.getCompleteMemberList()).thenReturn(names); return faction; } @Test void unknownSenderChannelOrFactionHasNoRealmRecipients() { assertTrue(resolver.resolve(null, channel).isEmpty()); assertTrue(resolver.resolve(sender, null).isEmpty()); assertTrue(resolver.resolve(sender, channel).isEmpty()); } @Test void topOverlordMembershipAndChannelPermissionsFilterOnlineRecipients() { - Faction child = faction("child", List.of("Sender")); Faction top = faction("top", Arrays.asList("SENDER", "MEMBER", "Offline", "Outsider", null)); - when(child.getOverlord()).thenReturn(top); factions.when(() -> FactionManager.getByMember("Sender")).thenReturn(child); + Faction child = faction("child", List.of("Sender")); Faction top = faction("top", Arrays.asList("Sender", "Member", "Offline", "Outsider", null)); + when(child.getOverlord()).thenReturn(top); factions.when(() -> FactionManager.getByMember(sender)).thenReturn(child); Locale.setDefault(Locale.forLanguageTag("tr-TR")); assertEquals(Set.of(sender, member), resolver.resolve(sender, channel)); member.disconnect(); assertEquals(Set.of(sender), resolver.resolve(sender, channel)); } @Test void selfOverlordAndCyclesTerminateAndUseTheReachedRealm() { Faction first = faction("first", List.of("Sender")); Faction second = faction("second", List.of("Member")); - factions.when(() -> FactionManager.getByMember("Sender")).thenReturn(first); + factions.when(() -> FactionManager.getByMember(sender)).thenReturn(first); when(first.getOverlord()).thenReturn(first); assertEquals(Set.of(sender), resolver.resolve(sender, channel)); when(first.getOverlord()).thenReturn(second); when(second.getOverlord()).thenReturn(first); assertEquals(Set.of(sender), resolver.resolve(sender, channel)); } + + @Test void changedCharacterAndUnreadyFactionProviderHaveNoRealmAccess() { + Faction realm=faction("realm",List.of("Sender","Member")); + factions.when(() -> FactionManager.getByMember(sender)).thenReturn(realm); + members.when(() -> net.tfminecraft.simplefactions.identity.Members.key(member)).thenReturn("different-character"); + assertEquals(Set.of(sender),resolver.resolve(sender,channel)); + api.when(net.tfminecraft.simplefactions.api.SimpleFactionsApi::ready).thenReturn(false); + assertTrue(resolver.resolve(sender,channel).isEmpty()); + } } diff --git a/src/test/java/net/tfminecraft/rpcharacters/identity/CharacterDirectoryTest.java b/src/test/java/net/tfminecraft/rpcharacters/identity/CharacterDirectoryTest.java index b5e2e407..f0b275ce 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/identity/CharacterDirectoryTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/identity/CharacterDirectoryTest.java @@ -393,6 +393,27 @@ void badLiveMetadataAndInvalidRemovalNeverHealQuarantine() throws Exception { assertTrue(warnings.stream().anyMatch(warning -> warning.contains("Invalid removed character ID"))); } + @Test + void deathIdsSurviveReloadAndRevivalAndLegacyDeathsHaveStableDistinctKeys() throws Exception { + UUID death=UUID.randomUUID(); + save(owner,id+".json",json(id,"Dead","DEAD","\"death-id\":\""+death+"\"")); + save(owner,second+".json",json(second,"Legacy","DEAD","")); + CharacterDirectory directory=load(); + assertEquals(death,directory.deathId(id).orElseThrow()); + UUID legacy=directory.deathId(second).orElseThrow(); + assertEquals(legacy,load().deathId(second).orElseThrow()); + RPCharacter old=live(second); directory.upsert(owner,old); + assertEquals(legacy,directory.deathId(second).orElseThrow()); + when(old.getStatus()).thenReturn(Status.ALIVE); directory.upsert(owner,old); + assertEquals(legacy,directory.deathId(second).orElseThrow()); + UUID later=UUID.randomUUID(); when(old.getDeathId()).thenReturn(later); + when(old.getStatus()).thenReturn(Status.DEAD); directory.upsert(owner,old); + assertEquals(later,directory.deathId(second).orElseThrow()); assertNotEquals(legacy,later); + directory.remove(owner,second.toString()); assertTrue(directory.deathId(second).isEmpty()); + save(owner,id+".json",json(id,"Dead","DEAD","\"death-id\":\"bad\"")); + assertTrue(load().deathId(id).isEmpty()); + } + private CharacterDirectory load() throws IOException { return CharacterDirectory.load(root, warnings::add); } diff --git a/src/test/java/net/tfminecraft/rpcharacters/identity/CharacterIdentityServiceTest.java b/src/test/java/net/tfminecraft/rpcharacters/identity/CharacterIdentityServiceTest.java index 80380ece..d7629557 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/identity/CharacterIdentityServiceTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/identity/CharacterIdentityServiceTest.java @@ -102,6 +102,8 @@ void cleanup() throws Exception { void allLookupMethodsSeeImmutableLiveEditsIncludingHiddenInactiveAndDeadCharacters() { RPCharacter deceased = character("Deceased", false); deceased.setStatus(Status.DEAD); + UUID death = UUID.randomUUID(); + deceased.setDeathRecord(death, Instant.ofEpochMilli(3000)); deceased.setLastPlayedAt(Instant.ofEpochMilli(4000)); data.addCharacter(deceased); RPCharacter missing = character("Missing", false); @@ -113,6 +115,7 @@ void allLookupMethodsSeeImmutableLiveEditsIncludingHiddenInactiveAndDeadCharacte List first = service.characters(player.getUniqueId()).orElseThrow(); CharacterInfo snapshot = service.info(UUID.fromString(alice.getId())).orElseThrow(); assertEquals(3, first.size()); + assertEquals(Optional.of(death), service.deathId(UUID.fromString(deceased.getId()))); assertEquals("Alice", snapshot.name()); assertEquals(CharacterStatus.DEAD, service.lastPlayed(player.getUniqueId()).orElseThrow().status()); assertTrue(first.stream().anyMatch(character -> character.status() == CharacterStatus.MISSING)); @@ -238,6 +241,7 @@ void allProviderMethodsRequireMainThread() { assertThrows(IllegalStateException.class, () -> service.currentSession(player)); assertThrows(IllegalStateException.class, () -> service.isCurrent(session)); assertThrows(IllegalStateException.class, () -> service.info(id)); + assertThrows(IllegalStateException.class, () -> service.deathId(id)); assertThrows(IllegalStateException.class, () -> service.characters(owner)); assertThrows(IllegalStateException.class, () -> service.lastPlayed(owner)); assertThrows(IllegalStateException.class, () -> service.displayName(player)); From 7c80b69b5f43ba3d09300c46bc259b2a10874e06 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:12:22 +0000 Subject: [PATCH 4/5] fix: persist payment intent directory entries before posting --- .../tfminecraft/rpcharacters/database/Database.java | 6 ++++-- .../database/DurableCharacterSaveTest.java | 12 +++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/main/java/net/tfminecraft/rpcharacters/database/Database.java b/src/main/java/net/tfminecraft/rpcharacters/database/Database.java index d24dafde..7b62b5c3 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/database/Database.java +++ b/src/main/java/net/tfminecraft/rpcharacters/database/Database.java @@ -655,8 +655,10 @@ boolean save(File file, HashMap defaults, boolean durable) { Files.move(staged, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); if (durable) { // Fails closed: the caller must not act on a save whose rename may not survive a power loss. - try (FileChannel directory = FileChannel.open(target.getParent(), StandardOpenOption.READ)) { - sync.force(directory); + for (Path parent = target.getParent(); parent != null; parent = parent.getParent()) { + try (FileChannel directory = FileChannel.open(parent, StandardOpenOption.READ)) { + sync.force(directory); + } } } diff --git a/src/test/java/net/tfminecraft/rpcharacters/database/DurableCharacterSaveTest.java b/src/test/java/net/tfminecraft/rpcharacters/database/DurableCharacterSaveTest.java index 19ecb696..cdf918fa 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/database/DurableCharacterSaveTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/database/DurableCharacterSaveTest.java @@ -47,7 +47,9 @@ private Payments.Charge charge(RPCharacter character) { } }; assertEquals(Payments.Taken.PAID, charge(character).status()); - assertEquals(List.of("force", "force", "withdraw, on disk: true"), events); + assertTrue(events.size() > 3, "New owner directories must be published before the debit"); + assertEquals("withdraw, on disk: true", events.getLast()); + assertTrue(events.subList(0, events.size() - 1).stream().allMatch("force"::equals)); } @Test void anIntentWhoseDirectoryCannotBeForcedIsNeverDebited() { @@ -57,4 +59,12 @@ private Payments.Charge charge(RPCharacter character) { assertEquals(Payments.Taken.UNAVAILABLE, charge(character).status()); assertTrue(wallet.attempts.isEmpty()); assertEquals(5_000, wallet.get(Account.POUCH)); } + + @Test void anIntentWhoseOwnerDirectoryEntryCannotBeForcedIsNeverDebited() { + RPCharacter character = character(UUID.randomUUID().toString(), false); + Database.sync = channel -> { events.add("force"); if (events.size() == 3) throw new IOException("owner directory publication failed"); }; + wallet.set(Account.POUCH, 5_000); + assertEquals(Payments.Taken.UNAVAILABLE, charge(character).status()); + assertTrue(wallet.attempts.isEmpty()); assertEquals(5_000, wallet.get(Account.POUCH)); + } } From 29c9ebe5cc028a62ec2f16aa6e410dcbb4744f0d Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:42:01 +0000 Subject: [PATCH 5/5] fix: retain death identity across queued directory updates --- .../rpcharacters/identity/CharacterDirectory.java | 10 +++++++--- .../identity/CharacterIdentityService.java | 3 ++- .../rpcharacters/paidchange/DenarEconomyWallet.java | 4 ++++ .../rpcharacters/identity/CharacterDirectoryTest.java | 4 ++++ .../identity/CharacterIdentityServiceTest.java | 4 ++++ 5 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/main/java/net/tfminecraft/rpcharacters/identity/CharacterDirectory.java b/src/main/java/net/tfminecraft/rpcharacters/identity/CharacterDirectory.java index 993f38f6..4080f048 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/identity/CharacterDirectory.java +++ b/src/main/java/net/tfminecraft/rpcharacters/identity/CharacterDirectory.java @@ -105,17 +105,21 @@ public void upsert(UUID ownerId, RPCharacter character) { quarantine(ownerId, "Invalid live character: " + error.getMessage()); return; } - upsert(ownerId, info, Boolean.TRUE.equals(character.isActive())); - rememberDeath(info, character.getDeathId()); + upsert(ownerId, info, Boolean.TRUE.equals(character.isActive()), character.getDeathId()); } /** Updates from a snapshot taken earlier, such as when a save finished off the main thread. */ public void upsert(UUID ownerId, CharacterInfo info, boolean active) { + upsert(ownerId, info, active, null); + } + + public void upsert(UUID ownerId, CharacterInfo info, boolean active, UUID deathId) { Objects.requireNonNull(ownerId, "ownerId"); UUID id = info.ref().characterId(); claim(ownerId, id); accounts.computeIfAbsent(ownerId, ignored -> new LinkedHashMap<>()).put(id, info); flag(info.ref(), active); + rememberDeath(info, deathId); } public void remove(UUID ownerId, String characterId) { @@ -125,7 +129,7 @@ public void remove(UUID ownerId, String characterId) { Map characters = accounts.get(ownerId); if (characters != null) characters.remove(id); flaggedActive.remove(new CharacterRef(ownerId, id)); - deaths.remove(id); + if (ownerId.equals(owners.get(id))) deaths.remove(id); if (!unavailable.contains(ownerId)) owners.remove(id, ownerId); } catch (RuntimeException error) { quarantine(ownerId, "Invalid removed character ID: " + error.getMessage()); diff --git a/src/main/java/net/tfminecraft/rpcharacters/identity/CharacterIdentityService.java b/src/main/java/net/tfminecraft/rpcharacters/identity/CharacterIdentityService.java index 7a5759dd..093ebc8b 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/identity/CharacterIdentityService.java +++ b/src/main/java/net/tfminecraft/rpcharacters/identity/CharacterIdentityService.java @@ -139,7 +139,8 @@ public static void saved(UUID owner, RPCharacter character) { return; } boolean active = Boolean.TRUE.equals(character.isActive()); - onMainThread(directory -> directory.upsert(owner, info, active)); + UUID deathId = character.getDeathId(); + onMainThread(directory -> directory.upsert(owner, info, active, deathId)); } public static void removed(UUID owner, String characterId) { diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWallet.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWallet.java index 8b3abc45..43804bb3 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWallet.java +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWallet.java @@ -20,6 +20,7 @@ public final class DenarEconomyWallet implements DenarWallet { private static final Logger LOG = Logger.getLogger("RPCharacters"); private Plugin boundTo; + private Plugin failedToBind; private Method ready; private Method pouch; private Method bank; @@ -111,6 +112,7 @@ private synchronized boolean bind() { if (plugin == boundTo) { return true; } + if (plugin == failedToBind) return false; try { Class denars = Class.forName("net.tfminecraft.denareconomy.api.Denars", true, plugin.getClass().getClassLoader()); @@ -126,8 +128,10 @@ private synchronized boolean bind() { transfer = api[4]; status = api[5]; boundTo = plugin; + failedToBind = null; return true; } catch (ReflectiveOperationException | RuntimeException | LinkageError e) { + failedToBind = plugin; LOG.log(Level.WARNING, "[RPCharacters] DenarEconomy has no Denars ledger API; paid changes are off", e); return false; } diff --git a/src/test/java/net/tfminecraft/rpcharacters/identity/CharacterDirectoryTest.java b/src/test/java/net/tfminecraft/rpcharacters/identity/CharacterDirectoryTest.java index f0b275ce..c8ab954a 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/identity/CharacterDirectoryTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/identity/CharacterDirectoryTest.java @@ -400,7 +400,11 @@ void deathIdsSurviveReloadAndRevivalAndLegacyDeathsHaveStableDistinctKeys() thro save(owner,second+".json",json(second,"Legacy","DEAD","")); CharacterDirectory directory=load(); assertEquals(death,directory.deathId(id).orElseThrow()); + directory.remove(other, id.toString()); + assertEquals(death, directory.deathId(id).orElseThrow()); UUID legacy=directory.deathId(second).orElseThrow(); + directory.upsert(owner, directory.info(second).orElseThrow(), false); + assertEquals(legacy, directory.deathId(second).orElseThrow()); assertEquals(legacy,load().deathId(second).orElseThrow()); RPCharacter old=live(second); directory.upsert(owner,old); assertEquals(legacy,directory.deathId(second).orElseThrow()); diff --git a/src/test/java/net/tfminecraft/rpcharacters/identity/CharacterIdentityServiceTest.java b/src/test/java/net/tfminecraft/rpcharacters/identity/CharacterIdentityServiceTest.java index d7629557..7b18e226 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/identity/CharacterIdentityServiceTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/identity/CharacterIdentityServiceTest.java @@ -262,6 +262,8 @@ void hooksOffTheMainThreadQueueTheirUpdateSoACompletedSaveStillSucceeds() { }); UUID offline = UUID.randomUUID(); RPCharacter character = character("Saved off thread", false); + UUID capturedDeath = UUID.randomUUID(); + character.setDeathRecord(capturedDeath, null); UUID id = UUID.fromString(character.getId()); try (var bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); @@ -277,8 +279,10 @@ void hooksOffTheMainThreadQueueTheirUpdateSoACompletedSaveStillSucceeds() { assertTrue(service.info(id).isEmpty()); // The queued update keeps what was saved, not later edits to the live character. character.setName("Renamed after the save"); + character.setDeathRecord(UUID.randomUUID(), null); queued.getFirst().run(); assertEquals("Saved off thread", service.info(id).orElseThrow().name()); + assertEquals(capturedDeath, service.deathId(id).orElseThrow()); queued.getLast().run(); assertTrue(service.info(id).isEmpty()); }