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;
+ }
+}