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 c0666f58..4711b3e7 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 @@ -109,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; @@ -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,8 @@ 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); + return used; } /** Living characters, most recently played first, then memorials. */ @@ -482,22 +484,30 @@ static String createBlockReason(Player player, PlayerData data) { return null; } - 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"); + /** The guild line, or null when guilds can't be read. */ + 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("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 ""; } - return Component.text("Shared by all your characters", DIM) - .append(Component.newline()) - .append(Component.text(String.join(" • ", parts), MUTED)); + 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,9 @@ 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); + 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()), @@ -621,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; } @@ -635,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/classpick/ClassPickService.java b/src/main/java/net/tfminecraft/rpcharacters/classpick/ClassPickService.java index 96b74df8..7c6e7d2e 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,133 @@ 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.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); - 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) { + // 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()); + } 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, false)); + 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 (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(); + 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.refundRequired() + && 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 +382,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..ca28686a --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/classpick/PendingClassPick.java @@ -0,0 +1,10 @@ +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. + * {@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 8b99a7da..4088f038 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,34 @@ 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")), + 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."); + } + } } @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()); + 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/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/database/Database.java b/src/main/java/net/tfminecraft/rpcharacters/database/Database.java index ae3abe0e..7b62b5c3 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,20 @@ 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. + for (Path parent = target.getParent(); parent != null; parent = parent.getParent()) { + try (FileChannel directory = FileChannel.open(parent, StandardOpenOption.READ)) { + sync.force(directory); + } + } + } return true; } catch (Exception ex) { 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..4080f048 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. @@ -92,16 +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())); + 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) { @@ -111,6 +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)); + 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()); @@ -185,6 +204,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..093ebc8b 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); @@ -133,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/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..5674da07 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 and forces it to disk; false when that failed. */ + public boolean saveCharacterNow(UUID owner, RPCharacter character) { + PlayerData pd = get(owner); + return pd != null && db.trySaveCharacterDurably(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..43804bb3 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWallet.java +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWallet.java @@ -1,79 +1,109 @@ 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 Plugin failedToBind; + 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()) { @@ -82,16 +112,27 @@ private synchronized boolean bind() { if (plugin == boundTo) { return true; } + if (plugin == failedToBind) return false; 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; + failedToBind = null; 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) { + failedToBind = plugin; + 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..a3e552a5 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,81 @@ 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. 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(); 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; + } + // 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; } - 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."); + 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."); return Outcome.REFUND_FAILED; } character.setPendingPaidChange(null); @@ -151,22 +190,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 +243,25 @@ 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; } + 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 " - + 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 +305,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..b49ee713 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,13 @@ 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\nGuild: Ironhand\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()); + + "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()); } @@ -802,30 +805,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.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 • Pouch and bank: unavailable", - text(CharacterSelectView.accountSummary(player))); + assertEquals("Guild: none", text(CharacterSelectView.guildSummary(cora))); } @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 @@ -883,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)); } } @@ -912,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/classpick/ClassPickTest.java b/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassPickTest.java index 8c41c6a5..01cae1bf 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassPickTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/classpick/ClassPickTest.java @@ -57,10 +57,19 @@ 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.PendingPaidChange; +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 +86,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 +106,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 +192,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 +206,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 +229,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 +316,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 +399,118 @@ 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, true)); + 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()); + } + + @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); + 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/database/DurableCharacterSaveTest.java b/src/test/java/net/tfminecraft/rpcharacters/database/DurableCharacterSaveTest.java new file mode 100644 index 00000000..cdf918fa --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/database/DurableCharacterSaveTest.java @@ -0,0 +1,70 @@ +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()); + 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() { + 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)); + } + + @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)); + } +} 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..c8ab954a 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/identity/CharacterDirectoryTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/identity/CharacterDirectoryTest.java @@ -393,6 +393,31 @@ 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()); + 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()); + 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..7b18e226 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)); @@ -258,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); @@ -273,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()); } 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..0ac28340 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.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)); + 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..b1679c92 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,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/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..2535f48e --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/paidchange/FakeLedger.java @@ -0,0 +1,88 @@ +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 beforePosting = () -> {}; + 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) { + beforePosting.run(); + 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..290fba0d 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,242 @@ 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 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)); + PaidChangeService.recover(stale); + assertEquals(1, wallet.count(false)); + assertEquals(10_000, wallet.get(Account.BANK)); + + } + + @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 + 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..a4787f5e --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaymentsFixture.java @@ -0,0 +1,28 @@ +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 paidChangeWallet(DenarWallet wallet) { + PaidChangeService.setWallet(wallet); + } + + public static void heirs(BooleanSupplier ready, Function> heirs) { + Payments.heirsReady = ready; + Payments.heirs = heirs; + } +}