diff --git a/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java b/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java index c2741d0..56a7329 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java +++ b/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java @@ -45,6 +45,7 @@ import net.tfminecraft.rpcharacters.loaders.PartyLoader; import net.tfminecraft.rpcharacters.managers.CommandManager; import net.tfminecraft.rpcharacters.managers.CreationManager; +import net.tfminecraft.rpcharacters.paidchange.PaidChangeListener; import net.tfminecraft.rpcharacters.managers.ClueDisturbanceListener; import net.tfminecraft.rpcharacters.managers.MagnifyingGlassListener; import net.tfminecraft.rpcharacters.managers.PlaceClueManager; @@ -291,6 +292,7 @@ public void onDisable() { PvpStrikeService.shutdown(); LastSolidTracker.get().shutdown(); GraveManager.get().saveAll(); + PaidChangeListener.settleAll(); save(); } @@ -310,6 +312,7 @@ public void registerListeners() { getServer().getPluginManager().registerEvents(playerManager, this); getServer().getPluginManager().registerEvents(new net.tfminecraft.rpcharacters.mmocore.MmoCorePlayerReady(), this); getServer().getPluginManager().registerEvents(creationManager, this); + getServer().getPluginManager().registerEvents(new PaidChangeListener(), this); getServer().getPluginManager().registerEvents(clueInputManager, this); getServer().getPluginManager().registerEvents(placeClueManager, this); getServer().getPluginManager().registerEvents(magnifyingGlassListener, this); @@ -532,6 +535,7 @@ public void reload() { private boolean reloadWithFocusStatus() { loadConfigs(); + refreshStageRevisionsOnline(); boolean focusReloaded = reloadFocusConfig(); if (!focusReloaded) { getLogger().warning("Focus did not reload; see the preceding focus error. " @@ -544,7 +548,25 @@ private boolean reloadWithFocusStatus() { return focusReloaded; } - public void reloadConfigs(CommandSender sender) { + /** A raised stage revision opens a fresh lock window for online characters straight away. */ + private void refreshStageRevisionsOnline() { + for (Player p : Bukkit.getOnlinePlayers()) { + net.tfminecraft.rpcharacters.objects.PlayerData pd = PlayerManager.get(p); + if (pd == null) { + continue; + } + boolean changed = false; + for (net.tfminecraft.rpcharacters.objects.RPCharacter c : pd.getCharacters()) { + changed |= net.tfminecraft.rpcharacters.creation.StageRevisions.refresh( + c, net.tfminecraft.rpcharacters.loaders.StageLoader.oList); + } + if (changed) { + playerManager.savePlayer(p); + } + } + } + + public void reloadConfigs(CommandSender sender) { String name = sender != null ? sender.getName() : "unknown"; getLogger().info("Config reload requested by " + name); if (sender != null) { diff --git a/src/main/java/net/tfminecraft/rpcharacters/creation/CharacterCreation.java b/src/main/java/net/tfminecraft/rpcharacters/creation/CharacterCreation.java index 60a185d..cd66355 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/creation/CharacterCreation.java +++ b/src/main/java/net/tfminecraft/rpcharacters/creation/CharacterCreation.java @@ -47,6 +47,9 @@ import net.tfminecraft.rpcharacters.objects.RPCharacter; +import net.tfminecraft.rpcharacters.paidchange.PaidChangeService; +import net.tfminecraft.rpcharacters.paidchange.PendingPaidChange; + import net.tfminecraft.rpcharacters.objects.attributes.AttributeData; import net.tfminecraft.rpcharacters.objects.trait.Trait; import net.tfminecraft.rpcharacters.objects.trait.TraitEffectResolver; @@ -408,6 +411,15 @@ public RPCharacter getCharacter() { } + public Player getPlayer() { + return p; + } + + /** Denars held while a paid stage is open; kept on the character so they are saved. */ + public PendingPaidChange getPendingPaidChange() { + return character == null ? null : character.getPendingPaidChange(); + } + public void openSummary() { @@ -490,9 +502,19 @@ public void jumpToStageForEdit(String stageId) { } - if (!StageEditLock.canEdit(p, template, character)) { + PaidChangeService.settle(this); + + if (character.getPendingPaidChange() != null) { + + // A refund that failed stays held. Any edit now would look like the paid change was used. + + RPTexts.send(p, RPTexts.ERROR + "Your last refund hasn't gone through yet. Try again after you rejoin."); + + return; + + } - RPTexts.send(p, RPTexts.ERROR + "That choice is locked and can no longer be edited."); + if (!StageEditLock.canEdit(p, template, character) && !PaidChangeService.payToOpen(p, this, template)) { return; @@ -504,6 +526,8 @@ public void jumpToStageForEdit(String stageId) { RPTexts.send(p, RPTexts.ERROR + "Could not open editor for that choice."); + PaidChangeService.settle(this); + return; } @@ -542,6 +566,8 @@ public void jumpToStageForEdit(String stageId) { RPTexts.send(p, RPTexts.ERROR + "That choice cannot be edited from the summary."); + PaidChangeService.settle(this); + } } @@ -562,6 +588,8 @@ public void returnToSummary() { editStage = null; + PaidChangeService.settle(this); + if (isEditing()) { persistEdits(); @@ -827,6 +855,8 @@ public void finish() { character.update(); + StageRevisions.stampCurrent(character, StageLoader.oList); + pd.addCharacter(character); net.tfminecraft.rpcharacters.lifecycle.CharacterLifecycle.fireCreated(p, pd.getUniqueId(), character); @@ -857,6 +887,8 @@ public void finish() { public void closeEditSession() { + PaidChangeService.settle(this); + persistEdits(); CreationManager.activeCreators.remove(p); @@ -1063,6 +1095,8 @@ public void cancel() { if (isEditing()) { + PaidChangeService.settle(this); + CreationManager.activeCreators.remove(p); p.closeInventory(); diff --git a/src/main/java/net/tfminecraft/rpcharacters/creation/Stage.java b/src/main/java/net/tfminecraft/rpcharacters/creation/Stage.java index 153b7df..3ad58d1 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/creation/Stage.java +++ b/src/main/java/net/tfminecraft/rpcharacters/creation/Stage.java @@ -27,6 +27,9 @@ public class Stage { private long lockTimeMs = -1L; + /** Raised by hand after a large change; gives every character a fresh lock window. */ + private int revision; + /** Inclusive minimum account-age hours, or null if unset. */ private Integer requireAccountAgeHoursMin; /** Exclusive maximum account-age hours, or null if unset. */ @@ -35,6 +38,14 @@ public class Stage { /** Creation client: both (default), web-only, or game-only. */ private String platform = "both"; + public int getRevision() { + return revision; + } + + public void setRevision(int revision) { + this.revision = Math.max(0, revision); + } + public long getLockTimeMs() { return lockTimeMs; } @@ -114,6 +125,7 @@ protected void copyBaseFields(Stage source) { setDependency(null); } setLockTimeMs(source.getLockTimeMs()); + setRevision(source.getRevision()); setRequireAccountAgeHoursMin(source.getRequireAccountAgeHoursMin()); setRequireAccountAgeHoursMax(source.getRequireAccountAgeHoursMax()); setPlatform(source.getPlatform()); @@ -172,6 +184,7 @@ public static Stage create(String id, ConfigurationSection config) { s.setDependency(new Dependency(config.getConfigurationSection("dependency"))); } s.setLockTimeMs(DurationParser.parseLockTimeMs(config.getString("lock-time", "-1"))); + s.setRevision(config.getInt("revision", 0)); if (config.contains("require-account-age-hours-min")) { s.setRequireAccountAgeHoursMin(config.getInt("require-account-age-hours-min")); } diff --git a/src/main/java/net/tfminecraft/rpcharacters/creation/StageEditLock.java b/src/main/java/net/tfminecraft/rpcharacters/creation/StageEditLock.java index 0e985c9..84e3eb8 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/creation/StageEditLock.java +++ b/src/main/java/net/tfminecraft/rpcharacters/creation/StageEditLock.java @@ -28,7 +28,7 @@ public static boolean canEdit(Stage stage, RPCharacter character) { if (lockMs < 0) { return true; } - return character.getAgeSeconds() * 1000L < lockMs; + return StageRevisions.secondsIntoWindow(stage, character) * 1000L < lockMs; } public static long lockRemainingMs(Stage stage, RPCharacter character) { @@ -39,7 +39,7 @@ public static long lockRemainingMs(Stage stage, RPCharacter character) { if (lockMs < 0) { return 0L; } - long remaining = lockMs - (character.getAgeSeconds() * 1000L); + long remaining = lockMs - (StageRevisions.secondsIntoWindow(stage, character) * 1000L); return Math.max(0L, remaining); } diff --git a/src/main/java/net/tfminecraft/rpcharacters/creation/StageRevisions.java b/src/main/java/net/tfminecraft/rpcharacters/creation/StageRevisions.java new file mode 100644 index 0000000..15a2688 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/creation/StageRevisions.java @@ -0,0 +1,72 @@ +package net.tfminecraft.rpcharacters.creation; + +import java.time.Instant; +import java.util.Collection; +import java.util.Locale; + +import net.tfminecraft.rpcharacters.objects.RPCharacter; + +/** + * Staff raise a stage's {@code revision} in stages.yml after a large change to it. Each character + * then gets a fresh lock window on that stage, starting when it next loads, and its paid-change + * prices start over. + */ +public final class StageRevisions { + + private StageRevisions() {} + + /** Starts a fresh window on every stage whose revision passed the character's. True if any did. */ + public static boolean refresh(RPCharacter character, Collection stages, long nowEpochSeconds) { + if (character == null || stages == null) { + return false; + } + boolean changed = false; + for (Stage stage : stages) { + if (stage == null || stage.getId() == null) { + continue; + } + String id = key(stage); + if (stage.getRevision() > character.getStageRevision(id)) { + character.setStageRevision(id, stage.getRevision(), nowEpochSeconds); + character.setPaidChangeCount(id, 0); + changed = true; + } + } + return changed; + } + + public static boolean refresh(RPCharacter character, Collection stages) { + return refresh(character, stages, Instant.now().getEpochSecond()); + } + + /** New characters start on the current revisions, with the window counted from creation. */ + public static void stampCurrent(RPCharacter character, Collection stages) { + if (character == null || stages == null) { + return; + } + for (Stage stage : stages) { + if (stage != null && stage.getId() != null && stage.getRevision() > 0) { + character.setStageRevision(key(stage), stage.getRevision(), 0L); + } + } + } + + /** Seconds since this stage's lock window opened: creation, or the latest revision reset. */ + public static long secondsIntoWindow(Stage stage, RPCharacter character, long nowEpochSeconds) { + int createdAt = character.getCreatedAtEpochSeconds(); + if (createdAt <= 0) { + return 0L; + } + long start = Math.max(createdAt, character.getStageRevisionSince(key(stage))); + return Math.max(0L, nowEpochSeconds - start); + } + + /** Stage ids are stored lower case, the same key paid-change counts use. */ + public static String key(Stage stage) { + return stage.getId() == null ? null : stage.getId().toLowerCase(Locale.ROOT); + } + + public static long secondsIntoWindow(Stage stage, RPCharacter character) { + return secondsIntoWindow(stage, character, Instant.now().getEpochSecond()); + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/database/CharacterStageChangeFields.java b/src/main/java/net/tfminecraft/rpcharacters/database/CharacterStageChangeFields.java new file mode 100644 index 0000000..ead9256 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/database/CharacterStageChangeFields.java @@ -0,0 +1,94 @@ +package net.tfminecraft.rpcharacters.database; + +import java.math.BigDecimal; +import java.util.Map; +import java.util.UUID; +import java.util.logging.Logger; + +import org.json.simple.JSONObject; + +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import net.tfminecraft.rpcharacters.paidchange.DenarWallet.Account; +import net.tfminecraft.rpcharacters.paidchange.PendingPaidChange; + +/** + * Per-stage change state in the character file: paid change counts under "paid-changes", the + * stage revision each lock window was opened for under "stage-revisions" (both keyed by stage id), + * and denars held for an open paid stage under "paid-change-pending". + */ +public final class CharacterStageChangeFields { + private CharacterStageChangeFields() {} + + public static void load(RPCharacter character, JSONObject characterJson) { + if (character == null || characterJson == null) { + return; + } + if (characterJson.get("paid-changes") instanceof Map counts) { + for (Map.Entry entry : counts.entrySet()) { + if (entry.getKey() instanceof String stageId && entry.getValue() instanceof Number count) { + character.setPaidChangeCount(stageId, count.intValue()); + } + } + } + if (characterJson.get("paid-change-pending") instanceof Map pending) { + character.setPendingPaidChange(readPending(pending)); + } + if (characterJson.get("stage-revisions") instanceof Map revisions) { + for (Map.Entry entry : revisions.entrySet()) { + if (entry.getKey() instanceof String stageId && entry.getValue() instanceof Map mark + && mark.get("revision") instanceof Number revision) { + long since = mark.get("since") instanceof Number n ? n.longValue() : 0L; + character.setStageRevision(stageId, revision.intValue(), since); + } + } + } + } + + @SuppressWarnings("unchecked") + public static void save(Map defaults, RPCharacter character) { + if (!character.getPaidChangeCounts().isEmpty()) { + JSONObject counts = new JSONObject(); + counts.putAll(character.getPaidChangeCounts()); + defaults.put("paid-changes", counts); + } + if (!character.getStageRevisions().isEmpty()) { + JSONObject revisions = new JSONObject(); + for (Map.Entry entry : character.getStageRevisions().entrySet()) { + JSONObject mark = new JSONObject(); + mark.put("revision", entry.getValue()); + long since = character.getStageRevisionSince(entry.getKey()); + if (since > 0L) { + mark.put("since", since); + } + revisions.put(entry.getKey(), mark); + } + defaults.put("stage-revisions", revisions); + } + PendingPaidChange pending = character.getPendingPaidChange(); + if (pending != null) { + JSONObject held = new JSONObject(); + held.put("stage", pending.stageId()); + held.put("label", pending.label()); + held.put("payer", pending.payerId().toString()); + if (pending.account() != null) { + held.put("account", pending.account().name()); + } + held.put("amount", pending.amount().toPlainString()); + held.put("before", pending.before()); + defaults.put("paid-change-pending", held); + } + } + + private static PendingPaidChange readPending(Map held) { + try { + Account account = held.get("account") instanceof String name ? Account.valueOf(name) : null; + BigDecimal amount = new BigDecimal(String.valueOf(held.get("amount"))); + return new PendingPaidChange((String) held.get("stage"), (String) held.get("label"), + UUID.fromString((String) held.get("payer")), account, amount, (String) held.get("before")); + } catch (RuntimeException e) { + Logger.getLogger("RPCharacters").warning("[RPCharacters] Unreadable paid-change-pending entry " + held + + "; refund it by hand if denars were taken."); + return null; + } + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/database/Database.java b/src/main/java/net/tfminecraft/rpcharacters/database/Database.java index dedbea6..5357426 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/database/Database.java +++ b/src/main/java/net/tfminecraft/rpcharacters/database/Database.java @@ -312,6 +312,7 @@ public void loadCharacters(PlayerData pd) { loadLastLocation(c, json); loadPvpLethal(c, json); CharacterEvilRpFields.load(c, json); + CharacterStageChangeFields.load(c, json); loadNutritionFields(c, json); c.ensureTraitStateDefaults(); if (!Boolean.TRUE.equals(c.isActive()) && c.removeExpiredDurationTraits(System.currentTimeMillis())) { @@ -461,6 +462,7 @@ public void saveCharacter(PlayerData pd, RPCharacter c) { saveLastLocation(defaults, c); defaults.put("pvp-lethal", String.valueOf(c.isPvpLethal())); CharacterEvilRpFields.save(defaults, c); + CharacterStageChangeFields.save(defaults, c); saveNutritionFields(defaults, c); if (save(file, defaults)) { net.tfminecraft.rpcharacters.playtime.CharacterPlaytimeDirectory.upsert(pd.getUniqueId(), c); diff --git a/src/main/java/net/tfminecraft/rpcharacters/ingest/CharacterIngestService.java b/src/main/java/net/tfminecraft/rpcharacters/ingest/CharacterIngestService.java index 1abf096..df77b59 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/ingest/CharacterIngestService.java +++ b/src/main/java/net/tfminecraft/rpcharacters/ingest/CharacterIngestService.java @@ -355,6 +355,8 @@ private static ApplyOutcome applyPayload(UUID playerUuid, String createId, JSONO pd.setEighteen((Boolean) eighteenRaw); } + net.tfminecraft.rpcharacters.creation.StageRevisions.stampCurrent( + character, net.tfminecraft.rpcharacters.loaders.StageLoader.oList); pd.addCharacter(character); net.tfminecraft.rpcharacters.lifecycle.CharacterLifecycle.fireCreated(online, pd.getUniqueId(), character); diff --git a/src/main/java/net/tfminecraft/rpcharacters/loaders/ConfigLoader.java b/src/main/java/net/tfminecraft/rpcharacters/loaders/ConfigLoader.java index 9cc540e..4885de6 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/loaders/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/rpcharacters/loaders/ConfigLoader.java @@ -16,6 +16,7 @@ import net.tfminecraft.tlibs.interfaces.LoaderInterface; import net.tfminecraft.rpcharacters.Cache; +import net.tfminecraft.rpcharacters.paidchange.PaidChangeConfig; public class ConfigLoader implements LoaderInterface{ @@ -105,6 +106,7 @@ public void load(File configFile) { Cache.rpInjureRange = 10; Cache.rpInjureTimeoutSeconds = 30; } + PaidChangeConfig.load(config.getConfigurationSection("paid-changes")); validateClueConfig(); } diff --git a/src/main/java/net/tfminecraft/rpcharacters/managers/CreationManager.java b/src/main/java/net/tfminecraft/rpcharacters/managers/CreationManager.java index 05a6e3d..d1fd4d9 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/managers/CreationManager.java +++ b/src/main/java/net/tfminecraft/rpcharacters/managers/CreationManager.java @@ -40,6 +40,7 @@ import net.tfminecraft.rpcharacters.objects.RPCharacter; import net.tfminecraft.rpcharacters.objects.SelectableItem; import net.tfminecraft.rpcharacters.objects.trait.Trait; +import net.tfminecraft.rpcharacters.paidchange.PaidChangeService; import net.tfminecraft.rpcharacters.utils.PlaytimeGate; import net.tfminecraft.rpcharacters.utils.ProstheticTraitRules; import net.tfminecraft.rpcharacters.utils.RPTexts; @@ -141,7 +142,8 @@ public static void initiateEditEntry(Player p, String entryKey) { RPTexts.send(p, RPTexts.ERROR + "You have no active character to edit."); return; } - if (stage != null && !StageEditLock.canEdit(p, stage, pd.getActiveCharacter())) { + if (stage != null && !StageEditLock.canEdit(p, stage, pd.getActiveCharacter()) + && !PaidChangeService.canPayToOpen(stage)) { RPTexts.send(p, RPTexts.ERROR + "That choice is locked and can no longer be edited."); return; } @@ -149,6 +151,10 @@ public static void initiateEditEntry(Player p, String entryKey) { CharacterCreation cc = CharacterCreation.forEdit(p, pd.getActiveCharacter()); activeCreators.put(p, cc); cc.jumpToStageForEdit(stageId); + if (!cc.isEditingFromSummary()) { + // Nothing opened (e.g. the player could not pay), so don't leave a session with no GUI. + activeCreators.remove(p, cc); + } return; } CharacterCreation existing = activeCreators.get(p); @@ -505,7 +511,8 @@ private void handleSummaryClick(Player p, InventoryClickEvent e) { } String stageId = action.substring("edit:".length()); Stage stage = StageLoader.getById(stageId); - if (stage != null && !StageEditLock.canEdit(p, stage, cc.getCharacter())) { + if (stage != null && !StageEditLock.canEdit(p, stage, cc.getCharacter()) + && !PaidChangeService.canPayToOpen(stage)) { RPTexts.send(p, RPTexts.ERROR + "That choice is locked and can no longer be edited."); return; } diff --git a/src/main/java/net/tfminecraft/rpcharacters/managers/InventoryManager.java b/src/main/java/net/tfminecraft/rpcharacters/managers/InventoryManager.java index 2b90976..9d8c407 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/managers/InventoryManager.java +++ b/src/main/java/net/tfminecraft/rpcharacters/managers/InventoryManager.java @@ -25,6 +25,7 @@ import net.tfminecraft.rpcharacters.creation.Dependency; import net.tfminecraft.rpcharacters.creation.Stage; import net.tfminecraft.rpcharacters.creation.StageEditLock; +import net.tfminecraft.rpcharacters.paidchange.PaidChangeService; import net.tfminecraft.rpcharacters.creation.stages.AttributesStage; import net.tfminecraft.rpcharacters.creation.stages.SelectionStage; import net.tfminecraft.rpcharacters.creation.stages.SummaryStage; @@ -209,8 +210,8 @@ public void creationSummaryView(Player player, CharacterCreation creation, Summa } ItemMeta meta = item.getItemMeta(); if (meta != null) { - boolean locked = isSummaryEntryLocked(player, character, stageId); - if (!locked) { + boolean locked = editing && isSummaryEntryLocked(player, character, stageId); + if (!locked || PaidChangeService.canPayToOpen(StageLoader.getById(stageId))) { String action = "clues".equalsIgnoreCase(stageId) ? "clues" : "edit:" + stageId; meta.getPersistentDataContainer().set(actionKey, PersistentDataType.STRING, action); } @@ -295,14 +296,19 @@ private ItemStack buildSummaryEntryItem(Player player, RPCharacter character, St String label = WordUtils.capitalize(entryKey.replace('_', ' ')); List lore = new ArrayList<>(); boolean locked = editing && isSummaryEntryLocked(player, character, stageId); - if (locked) { - lore.add(summaryValue(RPTexts.ERROR + "Locked")); + Stage editedStage = editing && !"clues".equalsIgnoreCase(stageId) ? StageLoader.getById(stageId) : null; + List paidLore = PaidChangeService.summaryLore(editedStage, character, locked); + if (paidLore != null) { + for (String line : paidLore) { + lore.add(summaryValue(line)); + } } else { - lore.add(summaryValue(RPTexts.MUTED + "Click to change")); - } - if (editing && !"clues".equalsIgnoreCase(stageId)) { - Stage stage = StageLoader.getById(stageId); - String lockLore = StageEditLock.lockLore(stage, character); + if (locked) { + lore.add(summaryValue(RPTexts.ERROR + "Locked")); + } else { + lore.add(summaryValue(RPTexts.MUTED + "Click to change")); + } + String lockLore = StageEditLock.lockLore(editedStage, character); if (lockLore != null && !locked) { lore.add(summaryValue(lockLore)); } @@ -903,7 +909,15 @@ public ItemStack createCancelItem(CharacterCreation cc) { ItemStack i = new ItemStack(Material.BARRIER, 1); ItemMeta meta = i.getItemMeta(); List lore = new ArrayList<>(); - if(cc != null) { + net.tfminecraft.rpcharacters.paidchange.PendingPaidChange pending = cc != null ? cc.getPendingPaidChange() : null; + if (pending != null) { + meta.setDisplayName(t(RPTexts.ERROR + "Cancel")); + lore.add(t(RPTexts.MUTED + "Keep your " + pending.label())); + if (pending.amount().signum() > 0) { + lore.add(t(RPTexts.MUTED + "and get " + RPTexts.GUI_WARN + PaidChangeService.formatDenars(pending.amount()) + + RPTexts.MUTED + " back")); + } + } else if(cc != null) { meta.setDisplayName(t(RPTexts.ERROR + "Cancel Creation")); lore.add(t(RPTexts.MUTED + "Cancel the current character creation")); diff --git a/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java b/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java index 091ccec..737a598 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java +++ b/src/main/java/net/tfminecraft/rpcharacters/managers/PlayerManager.java @@ -337,6 +337,12 @@ public void onLeave(PlayerQuitEvent e) { net.tfminecraft.rpcharacters.clues.discovery.ClueAdminModeService.clear(p); TempAliasService.clear(p); MmoCorePlayerReady.cancel(p.getUniqueId()); + CharacterCreation session = CreationManager.activeCreators.get(p); + net.tfminecraft.rpcharacters.paidchange.PaidChangeService.settle(session); + if (session != null && session.isEditing()) { + // Edits apply as they are made, so nothing is lost; a kept session would block the next /rpcharacter edit. + CreationManager.activeCreators.remove(p, session); + } PlayerData pd = get(p); if (pd != null && pd.hasActiveCharacter()) { // Prevent MMOCore from persisting stacked attribute bases for the next login. @@ -373,7 +379,19 @@ public void initiatePlayer(Player p) { } final PlayerData loaded = pd; data.add(loaded); - if (ProstheticTraitRules.sanitize(loaded)) { + boolean dirty = ProstheticTraitRules.sanitize(loaded); + // On join only, not on offline loads, which may never be saved. A settled hold is saved on + // the normal schedule, the same way DenarEconomy saves the online balance it changed. + for (RPCharacter c : loaded.getCharacters()) { + // Settle an old hold first, so it counts toward the old revision, not the fresh window. + String recovered = net.tfminecraft.rpcharacters.paidchange.PaidChangeService.recover(c); + if (recovered != null) { + RPTexts.send(p, recovered); + } + dirty |= net.tfminecraft.rpcharacters.creation.StageRevisions.refresh( + c, net.tfminecraft.rpcharacters.loaders.StageLoader.oList); + } + if (dirty) { savePlayer(p); } if(!loaded.hasActiveCharacter() && loaded.getCharacters(Status.ALIVE).size() > 0) { diff --git a/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java b/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java index e9db746..29f5a7e 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java +++ b/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java @@ -99,6 +99,14 @@ public class RPCharacter { private long evilRpSessionEndsAtMs; /** When the latest strike landed, so strikes can wear off. 0 when unknown. */ private long lastStrikeAtMs; + /** Paid changes made so far, keyed by stage id. Sets the next price. */ + private final Map paidChangeCounts = new HashMap<>(); + /** Denars held for an open paid stage, saved so a crash can't lose the refund or the count. */ + private net.tfminecraft.rpcharacters.paidchange.PendingPaidChange pendingPaidChange; + /** Stage revision each stage's lock window was last opened for, keyed by stage id. */ + private final Map stageRevisions = new HashMap<>(); + /** Epoch seconds a revision reset opened each stage's window; 0 counts from creation. */ + private final Map stageRevisionSince = new HashMap<>(); public static final int MAX_FOOD_VALUE = 200; private static final int MAX_DIET_SCORE = 40; @@ -205,6 +213,57 @@ public void setPvpLethal(boolean pvpLethal) { this.pvpLethal = pvpLethal; } + public int getPaidChangeCount(String ruleId) { + return ruleId == null ? 0 : paidChangeCounts.getOrDefault(ruleId, 0); + } + + public void setPaidChangeCount(String ruleId, int count) { + if (ruleId == null) { + return; + } + if (count <= 0) { + paidChangeCounts.remove(ruleId); + } else { + paidChangeCounts.put(ruleId, count); + } + } + + public Map getPaidChangeCounts() { + return Collections.unmodifiableMap(paidChangeCounts); + } + + public net.tfminecraft.rpcharacters.paidchange.PendingPaidChange getPendingPaidChange() { + return pendingPaidChange; + } + + public void setPendingPaidChange(net.tfminecraft.rpcharacters.paidchange.PendingPaidChange pending) { + this.pendingPaidChange = pending; + } + + public int getStageRevision(String stageId) { + return stageId == null ? 0 : stageRevisions.getOrDefault(stageId, 0); + } + + public long getStageRevisionSince(String stageId) { + return stageId == null ? 0L : stageRevisionSince.getOrDefault(stageId, 0L); + } + + public void setStageRevision(String stageId, int revision, long sinceEpochSeconds) { + if (stageId == null) { + return; + } + stageRevisions.put(stageId, revision); + if (sinceEpochSeconds > 0L) { + stageRevisionSince.put(stageId, sinceEpochSeconds); + } else { + stageRevisionSince.remove(stageId); + } + } + + public Map getStageRevisions() { + return Collections.unmodifiableMap(stageRevisions); + } + public int getEvilRpStrikes() { return evilRpStrikes; } diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWallet.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWallet.java new file mode 100644 index 0000000..6264258 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarEconomyWallet.java @@ -0,0 +1,98 @@ +package net.tfminecraft.rpcharacters.paidchange; + +import java.lang.reflect.Method; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.plugin.Plugin; + +/** + * DenarEconomy accounts, reached through DenarEconomy's own class loader. RPCharacters can't + * declare DenarEconomy as a dependency: DenarEconomy already loads after RPCharacters through + * TLibs, ItemsAdder and BirdMessenger, so either depend would make a load cycle. + */ +final class DenarEconomyWallet implements DenarWallet { + private static final Logger LOG = Logger.getLogger("RPCharacters"); + + private Plugin boundTo; + private Method balance; + private Method apply; + private Class accounts; + + @Override + public boolean available() { + return bind(); + } + + @Override + public BigDecimal balance(UUID playerId, Account account) { + if (!bind()) { + return BigDecimal.ZERO; + } + try { + double value = ((Number) balance.invoke(null, playerId, toDenar(account))).doubleValue(); + // DenarEconomy stores cents in a BigDecimal and hands back its double value. + return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP); + } catch (ReflectiveOperationException | RuntimeException e) { + LOG.log(Level.WARNING, "[RPCharacters] Could not read a DenarEconomy balance", e); + return BigDecimal.ZERO; + } + } + + @Override + public boolean withdraw(UUID playerId, Account account, BigDecimal amount) { + return amount.signum() > 0 && apply(playerId, account, amount.negate()); + } + + @Override + public boolean deposit(UUID playerId, Account account, BigDecimal amount) { + return amount.signum() > 0 && apply(playerId, account, amount); + } + + /** OfflineModifier.apply refuses a withdrawal the account can't cover. */ + private boolean apply(UUID playerId, Account account, BigDecimal amount) { + if (!bind()) { + return false; + } + try { + // The API takes a double; DenarEconomy turns it back into cents with BigDecimal.valueOf. + return Boolean.TRUE.equals(apply.invoke(null, playerId, toDenar(account), amount.doubleValue())); + } catch (ReflectiveOperationException | RuntimeException e) { + LOG.log(Level.WARNING, "[RPCharacters] Could not change a DenarEconomy balance", e); + return false; + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private Object toDenar(Account account) { + return Enum.valueOf((Class) accounts, account == Account.BANK ? "BANK" : "POUCH"); + } + + /** Looks the API up again whenever DenarEconomy is (re)loaded. */ + @SuppressWarnings("unchecked") + private synchronized boolean bind() { + Plugin plugin = Bukkit.getPluginManager().getPlugin("DenarEconomy"); + if (plugin == null || !plugin.isEnabled()) { + return false; + } + if (plugin == boundTo) { + return true; + } + try { + ClassLoader loader = plugin.getClass().getClassLoader(); + Class modifier = Class.forName("net.tfminecraft.denareconomy.accounts.OfflineModifier", true, loader); + accounts = (Class) Class.forName("net.tfminecraft.denareconomy.enums.Accounts", true, loader); + balance = modifier.getMethod("balance", UUID.class, accounts); + apply = modifier.getMethod("apply", UUID.class, accounts, double.class); + boundTo = plugin; + return true; + } catch (ReflectiveOperationException | RuntimeException e) { + LOG.log(Level.WARNING, "[RPCharacters] DenarEconomy has no OfflineModifier API; paid changes are off", e); + return false; + } + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarWallet.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarWallet.java new file mode 100644 index 0000000..28a836a --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/DenarWallet.java @@ -0,0 +1,25 @@ +package net.tfminecraft.rpcharacters.paidchange; + +import java.math.BigDecimal; +import java.util.UUID; + +/** The denar accounts a paid change is charged to. Amounts are exact, in cents (scale 2). */ +public interface DenarWallet { + enum Account { + POUCH, + BANK; + + public String displayName() { + return name().toLowerCase(java.util.Locale.ROOT); + } + } + + boolean available(); + + BigDecimal balance(UUID playerId, Account account); + + /** Takes the whole amount, or nothing when the account cannot cover it. */ + boolean withdraw(UUID playerId, Account account, BigDecimal amount); + + boolean deposit(UUID playerId, Account account, BigDecimal amount); +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeConfig.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeConfig.java new file mode 100644 index 0000000..d836928 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeConfig.java @@ -0,0 +1,69 @@ +package net.tfminecraft.rpcharacters.paidchange; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.logging.Logger; + +import org.bukkit.configuration.ConfigurationSection; + +import net.tfminecraft.rpcharacters.paidchange.DenarWallet.Account; + +/** Reads the {@code paid-changes} section of config.yml into {@link PaidChangeService}. */ +public final class PaidChangeConfig { + private static final Logger LOG = Logger.getLogger("RPCharacters"); + + private PaidChangeConfig() {} + + public static void load(ConfigurationSection section) { + List rules = new ArrayList<>(); + List accounts = new ArrayList<>(); + if (section != null) { + for (String name : section.getStringList("accounts")) { + try { + accounts.add(Account.valueOf(name.trim().toUpperCase(Locale.ROOT))); + } catch (IllegalArgumentException e) { + LOG.warning("[RPCharacters] paid-changes.accounts: unknown account '" + name + + "'. Use pouch or bank."); + } + } + ConfigurationSection ruleSection = section.getConfigurationSection("rules"); + if (ruleSection != null) { + for (String id : ruleSection.getKeys(false)) { + PaidChangeRule rule = readRule(id, ruleSection.getConfigurationSection(id)); + if (rule != null) { + rules.add(rule); + } + } + } + } + PaidChangeService.configure(rules, accounts); + } + + private static PaidChangeRule readRule(String id, ConfigurationSection rule) { + if (rule == null) { + return null; + } + String stageId = rule.getString("stage"); + if (stageId == null || stageId.isBlank()) { + LOG.warning("[RPCharacters] paid-changes.rules." + id + " has no stage; skipped."); + return null; + } + List costs = new ArrayList<>(); + for (Object value : rule.getList("costs", List.of())) { + BigDecimal cost = value instanceof Number number ? new BigDecimal(number.toString()) : null; + if (cost != null && cost.signum() >= 0) { + costs.add(cost); + } else { + LOG.warning("[RPCharacters] paid-changes.rules." + id + ".costs: ignored '" + value + + "'. Costs must be numbers of 0 or more."); + } + } + if (costs.isEmpty()) { + LOG.warning("[RPCharacters] paid-changes.rules." + id + " has no costs; skipped."); + return null; + } + return new PaidChangeRule(id, stageId.trim(), rule.getString("label", id), costs); + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeListener.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeListener.java new file mode 100644 index 0000000..7535bf2 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeListener.java @@ -0,0 +1,30 @@ +package net.tfminecraft.rpcharacters.paidchange; + +import java.util.ArrayList; + +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.server.PluginDisableEvent; + +import net.tfminecraft.rpcharacters.creation.CharacterCreation; +import net.tfminecraft.rpcharacters.managers.CreationManager; + +/** + * DenarEconomy disables before RPCharacters on shutdown, so held payments are settled here, + * while its accounts can still take a refund. + */ +public final class PaidChangeListener implements Listener { + + @EventHandler + public void onPluginDisable(PluginDisableEvent event) { + if ("DenarEconomy".equals(event.getPlugin().getName())) { + settleAll(); + } + } + + public static void settleAll() { + for (CharacterCreation cc : new ArrayList<>(CreationManager.activeCreators.values())) { + PaidChangeService.settle(cc); + } + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeRule.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeRule.java new file mode 100644 index 0000000..4dbeea9 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeRule.java @@ -0,0 +1,49 @@ +package net.tfminecraft.rpcharacters.paidchange; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.List; + +/** + * Lets players pay to reopen a creation stage after its lock-time runs out. Each paid change + * costs the next entry in {@code costs}; the last entry repeats once the list runs out. + */ +public final class PaidChangeRule { + private final String id; + private final String stageId; + private final String label; + private final List costs; + + public PaidChangeRule(String id, String stageId, String label, List costs) { + this.id = id; + this.stageId = stageId.toLowerCase(java.util.Locale.ROOT); + this.label = label == null || label.isBlank() ? id : label; + this.costs = costs.stream().map(cost -> cost.setScale(2, RoundingMode.HALF_UP)).toList(); + } + + public String getId() { + return id; + } + + /** Also the key for the per-character paid change count. */ + public String getStageId() { + return stageId; + } + + /** What players change, in lower case, e.g. "class". */ + public String getLabel() { + return label; + } + + public List getCosts() { + return costs; + } + + /** Price of a change after {@code paidSoFar} earlier paid changes. */ + public BigDecimal costAfter(int paidSoFar) { + if (costs.isEmpty()) { + return BigDecimal.ZERO.setScale(2); + } + return costs.get(Math.min(Math.max(0, paidSoFar), costs.size() - 1)); + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java new file mode 100644 index 0000000..1ce3b50 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeService.java @@ -0,0 +1,284 @@ +package net.tfminecraft.rpcharacters.paidchange; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.bukkit.entity.Player; + +import net.tfminecraft.rpcharacters.creation.CharacterCreation; +import net.tfminecraft.rpcharacters.creation.Stage; +import net.tfminecraft.rpcharacters.creation.StageEditLock; +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import net.tfminecraft.rpcharacters.objects.trait.Trait; +import net.tfminecraft.rpcharacters.paidchange.DenarWallet.Account; +import net.tfminecraft.rpcharacters.utils.AgeFormatter; +import net.tfminecraft.rpcharacters.utils.RPTexts; + +/** + * Pay to reopen a locked creation stage. The player pays when the stage opens, and the denars + * stay held on the edit session until they leave it. A changed character keeps the payment and + * raises the next price; an unchanged one gets the denars back. + */ +public final class PaidChangeService { + + public enum ChargeStatus { + PAID, + UNAVAILABLE, + INSUFFICIENT_FUNDS + } + + public record ChargeResult(ChargeStatus status, PendingPaidChange pending, BigDecimal cost) {} + + public enum Outcome { + /** The character changed: the payment stays and the change is counted. */ + KEPT, + /** Nothing changed and the denars went back. */ + REFUNDED, + /** Nothing changed but the refund failed; the payment stays held for a later retry. */ + REFUND_FAILED + } + + private static final Logger LOG = Logger.getLogger("RPCharacters"); + + private static volatile Map rulesByStage = Map.of(); + private static volatile List accountOrder = List.of(Account.POUCH, Account.BANK); + private static DenarWallet wallet = new DenarEconomyWallet(); + + private PaidChangeService() {} + + public static void configure(Collection rules, List accounts) { + Map byStage = new LinkedHashMap<>(); + for (PaidChangeRule rule : rules) { + byStage.put(rule.getStageId().toLowerCase(Locale.ROOT), rule); + } + rulesByStage = Map.copyOf(byStage); + accountOrder = accounts == null || accounts.isEmpty() + ? List.of(Account.POUCH, Account.BANK) + : List.copyOf(accounts); + } + + static void setWallet(DenarWallet replacement) { + wallet = replacement; + } + + public static PaidChangeRule ruleFor(Stage stage) { + if (stage == null || stage.getId() == null) { + return null; + } + return rulesByStage.get(stage.getId().toLowerCase(Locale.ROOT)); + } + + /** True when a locked stage can be reopened by paying. */ + public static boolean canPayToOpen(Stage stage) { + return ruleFor(stage) != null && wallet.available(); + } + + /** Takes the next price from the first account that covers it. */ + public static ChargeResult charge(UUID payerId, PaidChangeRule rule, RPCharacter character) { + BigDecimal cost = rule.costAfter(character.getPaidChangeCount(rule.getStageId())); + String before = snapshot(character); + if (cost.signum() <= 0) { + return new ChargeResult(ChargeStatus.PAID, pending(rule, payerId, null, cost, before), cost); + } + if (!wallet.available()) { + return new ChargeResult(ChargeStatus.UNAVAILABLE, null, cost); + } + for (Account account : accountOrder) { + if (wallet.balance(payerId, account).compareTo(cost) >= 0 && wallet.withdraw(payerId, account, cost)) { + return new ChargeResult(ChargeStatus.PAID, pending(rule, payerId, account, cost, before), cost); + } + } + return new ChargeResult(ChargeStatus.INSUFFICIENT_FUNDS, null, cost); + } + + private static PendingPaidChange pending(PaidChangeRule rule, UUID payerId, Account account, BigDecimal amount, + String before) { + return new PendingPaidChange(rule.getStageId(), rule.getLabel(), payerId, account, amount, before); + } + + /** + * Settles the payment held on the character. A changed character keeps it and counts the + * change; an unchanged one is refunded. The hold is cleared only once one of those happened. + */ + public static Outcome resolve(RPCharacter character) { + PendingPaidChange pending = character.getPendingPaidChange(); + if (pending == null) { + return null; + } + if (!snapshot(character).equals(pending.before())) { + character.setPaidChangeCount(pending.stageId(), character.getPaidChangeCount(pending.stageId()) + 1); + character.setPendingPaidChange(null); + return Outcome.KEPT; + } + if (pending.account() != null && pending.amount().signum() > 0 + && !wallet.deposit(pending.payerId(), pending.account(), pending.amount())) { + LOG.log(Level.WARNING, "[RPCharacters] Could not refund " + pending.amount() + " denars to " + + pending.payerId() + " (" + pending.account() + ") for a " + pending.label() + + " change; keeping it held to retry."); + return Outcome.REFUND_FAILED; + } + character.setPendingPaidChange(null); + return Outcome.REFUNDED; + } + + /** Charges for a locked stage and holds the payment on the character. False leaves the stage shut. */ + public static boolean payToOpen(Player player, CharacterCreation cc, Stage stage) { + settle(cc); + RPCharacter character = cc.getCharacter(); + if (character.getPendingPaidChange() != null) { + RPTexts.send(player, RPTexts.ERROR + "Your last refund hasn't gone through yet. Try again later."); + return false; + } + PaidChangeRule rule = ruleFor(stage); + if (rule == null) { + RPTexts.send(player, RPTexts.ERROR + "That choice is locked and can no longer be edited."); + return false; + } + ChargeResult result = charge(player.getUniqueId(), rule, character); + switch (result.status()) { + case UNAVAILABLE -> { + RPTexts.send(player, RPTexts.ERROR + "You can't pay to change your " + rule.getLabel() + + " right now. Try again later."); + return false; + } + case INSUFFICIENT_FUNDS -> { + UUID id = player.getUniqueId(); + RPTexts.send(player, RPTexts.ERROR + "Changing your " + rule.getLabel() + " costs " + + formatDenars(result.cost()) + ". You have " + formatDenars(wallet.balance(id, Account.POUCH)) + + " in your pouch and " + formatDenars(wallet.balance(id, Account.BANK)) + " in the bank."); + return false; + } + default -> { + } + } + PendingPaidChange pending = result.pending(); + // Saved with the character on the normal schedule, not forced here. DenarEconomy keeps online + // balances in memory until its own save, so after a crash it rolls the withdrawal back; a hold + // forced to disk now would then be refunded a second time on the next join. + character.setPendingPaidChange(pending); + if (pending.amount().signum() > 0) { + RPTexts.send(player, RPTexts.SUCCESS + "Paid " + formatDenars(pending.amount()) + " from your " + + pending.account().displayName() + " to change your " + rule.getLabel() + "."); + RPTexts.send(player, RPTexts.MUTED + "Leave without changing it and you get the denars back."); + } + return true; + } + + /** Keeps or refunds the payment held on this session's character, if any. Safe to call more than once. */ + public static void settle(CharacterCreation cc) { + if (cc == null || cc.getCharacter() == null) { + return; + } + RPCharacter character = cc.getCharacter(); + PendingPaidChange pending = character.getPendingPaidChange(); + Outcome outcome = resolve(character); + if (outcome == null) { + return; + } + tell(cc.getPlayer(), outcomeMessage(pending, outcome, character)); + } + + /** + * Settles a payment left held by a crash or a failed refund. Run when the owner joins, before + * any edit session exists. Returns the player message, or null when there was nothing to settle. + */ + public static String recover(RPCharacter character) { + PendingPaidChange pending = character == null ? null : character.getPendingPaidChange(); + Outcome outcome = pending == null ? null : resolve(character); + return outcome == null ? null : outcomeMessage(pending, outcome, character); + } + + private static String outcomeMessage(PendingPaidChange pending, Outcome outcome, RPCharacter character) { + switch (outcome) { + case KEPT -> { + PaidChangeRule rule = rulesByStage.get(pending.stageId()); + String next = rule == null ? "" + : " The next one costs " + formatDenars(rule.costAfter(character.getPaidChangeCount(pending.stageId()))) + "."; + return RPTexts.SUCCESS + "Your " + pending.label() + " change is paid for." + next; + } + case REFUNDED -> { + if (pending.amount().signum() <= 0) { + return null; + } + return RPTexts.SUCCESS + "Your " + pending.label() + " is unchanged, so " + + formatDenars(pending.amount()) + " went back to your " + pending.account().displayName() + "."; + } + default -> { + return RPTexts.ERROR + "Your " + formatDenars(pending.amount()) + " refund couldn't go through yet. " + + "It will be retried when you next join."; + } + } + } + + private static void tell(Player player, String message) { + if (player != null && message != null) { + RPTexts.send(player, message); + } + } + + /** + * Summary lore for a stage with a paid-change rule, or null to keep the default lore. + * Free window: how long it stays free and the next two prices. Locked: the price to change now + * and the one after. + */ + public static List summaryLore(Stage stage, RPCharacter character, boolean locked) { + PaidChangeRule rule = ruleFor(stage); + if (rule == null || character == null) { + return null; + } + int paid = character.getPaidChangeCount(rule.getStageId()); + String next = formatDenars(rule.costAfter(paid)); + String after = formatDenars(rule.costAfter(paid + 1)); + List lines = new ArrayList<>(); + if (!locked) { + lines.add(RPTexts.MUTED + "Click to change"); + long remaining = StageEditLock.lockRemainingMs(stage, character); + if (remaining > 0L) { + lines.add(RPTexts.MUTED + "Free to change for: " + RPTexts.WARN + AgeFormatter.formatCountdown(remaining)); + lines.add(RPTexts.MUTED + "Then: " + RPTexts.WARN + next + RPTexts.MUTED + ", next: " + RPTexts.WARN + after); + } + return lines; + } + if (!wallet.available()) { + return null; + } + lines.add(RPTexts.WARN + "Click to change for " + next); + lines.add(RPTexts.MUTED + "The change after costs " + RPTexts.WARN + after); + lines.add(RPTexts.MUTED + "Refunded if you keep your " + rule.getLabel()); + return lines; + } + + public static String formatDenars(BigDecimal amount) { + BigDecimal whole = amount.stripTrailingZeros(); + String number = whole.scale() <= 0 + ? String.format(Locale.ROOT, "%,d", whole.toBigInteger()) + : String.format(Locale.ROOT, "%,.2f", amount); + return number + (amount.compareTo(BigDecimal.ONE) == 0 ? " denar" : " denars"); + } + + /** Everything a creation stage can change, so any edit made after paying shows up. */ + static String snapshot(RPCharacter character) { + List traitIds = new ArrayList<>(); + for (Trait trait : character.getTraits()) { + if (trait != null) { + traitIds.add(trait.getId()); + } + } + traitIds.sort(null); + return String.join("\u0000", + String.valueOf(character.getMMOClass()), + character.getRace() == null ? "null" : String.valueOf(character.getRace().getId()), + String.join(",", traitIds), + String.valueOf(character.getName()), + String.valueOf(character.getPersonaDescription()), + String.valueOf(character.getBirthday())); + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/paidchange/PendingPaidChange.java b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PendingPaidChange.java new file mode 100644 index 0000000..54d0bbd --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/paidchange/PendingPaidChange.java @@ -0,0 +1,14 @@ +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. + */ +public record PendingPaidChange(String stageId, String label, UUID payerId, Account account, BigDecimal amount, + String before) {} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index cba3f94..238c827 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -79,3 +79,15 @@ conversation: rp-injure: range: 10 timeout-seconds: 30 + +# Pay to change a choice after its stage's lock-time runs out (class is locked after 5d). +# Players pay when they open the choice, and get the denars back if they leave without changing it. +# costs: the first paid change, the second, and so on. The last cost repeats after that. +# accounts: where to take the denars from, tried in order. The whole cost comes from one account. +paid-changes: + accounts: [pouch, bank] + rules: + class: + stage: class_selection_stage + label: class + costs: [100, 1000, 3000] diff --git a/src/main/resources/stages.yml b/src/main/resources/stages.yml index 68ee3b2..7ff7fb7 100644 --- a/src/main/resources/stages.yml +++ b/src/main/resources/stages.yml @@ -97,6 +97,9 @@ class_info_stage: class_selection_stage: type: selection lock-time: 5d + # Raise by hand after a large change to this stage. Every character then gets a fresh + # lock-time window from when it next loads, and paid-change prices start over. + revision: 0 repeat: true target: class min-select: 1 diff --git a/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeServiceTest.java b/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeServiceTest.java new file mode 100644 index 0000000..2abbee4 --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/paidchange/PaidChangeServiceTest.java @@ -0,0 +1,367 @@ +package net.tfminecraft.rpcharacters.paidchange; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.rpcharacters.creation.Stage; +import net.tfminecraft.rpcharacters.creation.StageEditLock; +import net.tfminecraft.rpcharacters.creation.StageRevisions; +import net.tfminecraft.rpcharacters.database.CharacterStageChangeFields; +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import net.tfminecraft.rpcharacters.paidchange.DenarWallet.Account; +import net.tfminecraft.rpcharacters.paidchange.PaidChangeService.ChargeResult; +import net.tfminecraft.rpcharacters.paidchange.PaidChangeService.ChargeStatus; + +class PaidChangeServiceTest { + + private static final long DAY_MS = 86_400_000L; + private static final UUID PLAYER = UUID.randomUUID(); + private static final String CLASS_STAGE = "class_selection_stage"; + 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(); + + @BeforeEach + void setUp() { + PaidChangeService.setWallet(wallet); + PaidChangeService.configure(List.of(CLASS_RULE), List.of(Account.POUCH, Account.BANK)); + } + + @AfterEach + void tearDown() { + PaidChangeService.configure(List.of(), List.of()); + } + + @Test + void pricesRiseThenRepeatTheLastCost() { + assertEquals(d("100"), CLASS_RULE.costAfter(0)); + assertEquals(d("1000"), CLASS_RULE.costAfter(1)); + assertEquals(d("3000"), CLASS_RULE.costAfter(2)); + assertEquals(d("3000"), CLASS_RULE.costAfter(7)); + } + + @Test + void chargesThePouchFirstThenTheBank() { + wallet.set(Account.POUCH, d("150")); + wallet.set(Account.BANK, d("5000")); + RPCharacter character = characterWithClass("WARRIOR"); + + ChargeResult first = PaidChangeService.charge(PLAYER, CLASS_RULE, character); + assertEquals(ChargeStatus.PAID, first.status()); + assertEquals(Account.POUCH, first.pending().account()); + assertEquals(d("50"), wallet.get(Account.POUCH)); + + character.setPaidChangeCount(CLASS_STAGE, 1); + ChargeResult second = PaidChangeService.charge(PLAYER, CLASS_RULE, character); + assertEquals(Account.BANK, second.pending().account()); + assertEquals(d("1000"), second.pending().amount()); + assertEquals(d("4000"), wallet.get(Account.BANK)); + } + + @Test + void refusesWhenNoAccountCoversTheCost() { + wallet.set(Account.POUCH, d("60")); + wallet.set(Account.BANK, d("60")); + + ChargeResult result = PaidChangeService.charge(PLAYER, CLASS_RULE, characterWithClass("WARRIOR")); + assertEquals(ChargeStatus.INSUFFICIENT_FUNDS, result.status()); + assertEquals(d("100"), result.cost()); + assertEquals(d("60"), wallet.get(Account.POUCH)); + assertEquals(d("60"), wallet.get(Account.BANK)); + } + + @Test + void unavailableWithoutDenarEconomy() { + wallet.available = false; + wallet.set(Account.POUCH, d("500")); + assertFalse(PaidChangeService.canPayToOpen(stage("class_selection_stage", 5 * DAY_MS))); + assertEquals(ChargeStatus.UNAVAILABLE, + PaidChangeService.charge(PLAYER, CLASS_RULE, characterWithClass("WARRIOR")).status()); + } + + @Test + void backingOutWithoutAChangeRefunds() { + wallet.set(Account.POUCH, d("100")); + RPCharacter character = characterWithClass("WARRIOR"); + ChargeResult result = PaidChangeService.charge(PLAYER, CLASS_RULE, character); + assertEquals(d("0"), wallet.get(Account.POUCH)); + character.setPendingPaidChange(result.pending()); + + assertEquals(PaidChangeService.Outcome.REFUNDED, PaidChangeService.resolve(character)); + assertEquals(d("100"), wallet.get(Account.POUCH)); + assertEquals(0, character.getPaidChangeCount(CLASS_STAGE)); + assertNull(character.getPendingPaidChange()); + } + + @Test + void aFailedRefundStaysHeldAndIsRetried() { + wallet.set(Account.POUCH, d("100")); + RPCharacter character = characterWithClass("WARRIOR"); + character.setPendingPaidChange(PaidChangeService.charge(PLAYER, CLASS_RULE, character).pending()); + + wallet.depositsFail = true; + assertEquals(PaidChangeService.Outcome.REFUND_FAILED, PaidChangeService.resolve(character)); + assertEquals(d("0"), wallet.get(Account.POUCH)); + assertTrue(character.getPendingPaidChange() != null); + + wallet.depositsFail = false; + String message = PaidChangeService.recover(character); + assertTrue(message.contains("100 denars went back to your pouch"), message); + assertEquals(d("100"), wallet.get(Account.POUCH)); + assertNull(character.getPendingPaidChange()); + assertNull(PaidChangeService.recover(character)); + } + + @Test + void aSavedHoldSurvivesACrashAndSettlesOnRecovery() throws Exception { + wallet.set(Account.BANK, d("1000")); + RPCharacter character = characterWithClass("WARRIOR"); + character.setPaidChangeCount(CLASS_STAGE, 1); + character.setPendingPaidChange(PaidChangeService.charge(PLAYER, CLASS_RULE, character).pending()); + character.setMMOClass("mage"); + + HashMap saved = new HashMap<>(); + CharacterStageChangeFields.save(saved, character); + JSONObject reparsed = (JSONObject) new JSONParser().parse(new JSONObject(saved).toJSONString()); + RPCharacter loaded = characterWithClass("MAGE"); + CharacterStageChangeFields.load(loaded, reparsed); + PendingPaidChange held = loaded.getPendingPaidChange(); + assertEquals(Account.BANK, held.account()); + assertEquals(d("1000"), held.amount()); + assertEquals(PLAYER, held.payerId()); + + String message = PaidChangeService.recover(loaded); + assertTrue(message.contains("class change is paid for. The next one costs 3,000 denars"), message); + assertEquals(2, loaded.getPaidChangeCount(CLASS_STAGE)); + assertEquals(d("0"), wallet.get(Account.BANK)); + assertNull(loaded.getPendingPaidChange()); + } + + @Test + void keepingTheChangeKeepsThePaymentAndRaisesTheNextPrice() { + wallet.set(Account.BANK, d("100")); + RPCharacter character = characterWithClass("WARRIOR"); + character.setPendingPaidChange(PaidChangeService.charge(PLAYER, CLASS_RULE, character).pending()); + + character.setMMOClass("mage"); + assertEquals(PaidChangeService.Outcome.KEPT, PaidChangeService.resolve(character)); + assertEquals(d("0"), wallet.get(Account.BANK)); + assertEquals(1, character.getPaidChangeCount(CLASS_STAGE)); + assertEquals(d("1000"), CLASS_RULE.costAfter(character.getPaidChangeCount(CLASS_STAGE))); + } + + @Test + void loreShowsTheFreeWindowThenTheNextTwoPrices() { + RPCharacter character = characterWithClass("WARRIOR"); + character.setCreatedAtEpochSeconds((int) Instant.now().getEpochSecond() - 3600); + + List lore = PaidChangeService.summaryLore(stage("class_selection_stage", 5 * DAY_MS), character, false); + String joined = String.join("\n", lore); + assertTrue(joined.contains("Free to change for: "), joined); + assertTrue(joined.contains("4d 23h"), joined); + assertTrue(joined.contains("Then: §e100 denars"), joined); + assertTrue(joined.contains("next: §e1,000 denars"), joined); + } + + @Test + void lockedLoreShowsThePriceNowAndTheOneAfter() { + RPCharacter character = characterWithClass("WARRIOR"); + character.setCreatedAtEpochSeconds((int) (Instant.now().getEpochSecond() - 6 * 86_400L)); + Stage stage = stage("class_selection_stage", 5 * DAY_MS); + + String first = String.join("\n", PaidChangeService.summaryLore(stage, character, true)); + assertTrue(first.contains("Click to change for 100 denars"), first); + assertTrue(first.contains("change after costs §e1,000 denars"), first); + + character.setPaidChangeCount(CLASS_STAGE, 1); + String second = String.join("\n", PaidChangeService.summaryLore(stage, character, true)); + assertTrue(second.contains("Click to change for 1,000 denars"), second); + assertTrue(second.contains("change after costs §e3,000 denars"), second); + + character.setPaidChangeCount(CLASS_STAGE, 2); + String third = String.join("\n", PaidChangeService.summaryLore(stage, character, true)); + assertTrue(third.contains("Click to change for 3,000 denars"), third); + assertTrue(third.contains("change after costs §e3,000 denars"), third); + } + + @Test + void stagesWithoutARuleKeepTheirDefaultLore() { + assertNull(PaidChangeService.summaryLore(stage("race_selection_stage", DAY_MS), characterWithClass("WARRIOR"), true)); + assertFalse(PaidChangeService.canPayToOpen(stage("race_selection_stage", DAY_MS))); + assertTrue(PaidChangeService.canPayToOpen(stage("class_selection_stage", 5 * DAY_MS))); + } + + @Test + void configReadsRulesAndAccounts() { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("paid-changes.accounts", List.of("bank")); + yaml.set("paid-changes.rules.race.stage", "race_selection_stage"); + yaml.set("paid-changes.rules.race.costs", List.of(50, 75)); + PaidChangeConfig.load(yaml.getConfigurationSection("paid-changes")); + + assertFalse(PaidChangeService.canPayToOpen(stage("class_selection_stage", 5 * DAY_MS))); + PaidChangeRule race = PaidChangeService.ruleFor(stage("race_selection_stage", DAY_MS)); + assertEquals("race", race.getLabel()); + assertEquals(d("75"), race.costAfter(3)); + + wallet.set(Account.POUCH, d("1000")); + wallet.set(Account.BANK, d("50")); + ChargeResult result = PaidChangeService.charge(PLAYER, race, characterWithClass("WARRIOR")); + assertEquals(Account.BANK, result.pending().account()); + } + + @Test + void raisingTheRevisionReopensTheWindowAndResetsPrices() { + long now = Instant.now().getEpochSecond(); + RPCharacter character = characterWithClass("WARRIOR"); + character.setCreatedAtEpochSeconds((int) (now - 30 * 86_400L)); + character.setPaidChangeCount(CLASS_STAGE, 2); + Stage stage = stage(CLASS_STAGE, 5 * DAY_MS); + + assertFalse(StageRevisions.refresh(character, List.of(stage), now)); + assertFalse(StageEditLock.canEdit(stage, character)); + + stage.setRevision(1); + assertTrue(StageRevisions.refresh(character, List.of(stage), now - 3600)); + assertTrue(StageEditLock.canEdit(stage, character)); + assertEquals(0, character.getPaidChangeCount(CLASS_STAGE)); + String lore = String.join("\n", PaidChangeService.summaryLore(stage, character, false)); + assertTrue(lore.contains("4d 23h") && lore.contains("Then: §e100 denars"), lore); + + // Same revision on the next load keeps the window where it started. + assertFalse(StageRevisions.refresh(character, List.of(stage), now + 10 * 86_400L)); + assertEquals(now - 3600, character.getStageRevisionSince(CLASS_STAGE)); + } + + @Test + void newCharactersStartOnTheCurrentRevision() { + long now = Instant.now().getEpochSecond(); + Stage stage = stage(CLASS_STAGE, 5 * DAY_MS); + stage.setRevision(3); + RPCharacter character = characterWithClass("WARRIOR"); + character.setCreatedAtEpochSeconds((int) (now - 6 * 86_400L)); + + StageRevisions.stampCurrent(character, List.of(stage)); + assertFalse(StageRevisions.refresh(character, List.of(stage), now)); + assertFalse(StageEditLock.canEdit(stage, character)); + } + + @Test + void stageChangeStateSurvivesASaveAndLoad() throws Exception { + RPCharacter character = new RPCharacter(null); + character.setPaidChangeCount(CLASS_STAGE, 2); + character.setStageRevision(CLASS_STAGE, 4, 1_790_000_000L); + character.setStageRevision("race_selection_stage", 1, 0L); + HashMap saved = new HashMap<>(); + CharacterStageChangeFields.save(saved, character); + JSONObject reparsed = (JSONObject) new JSONParser().parse(new JSONObject(saved).toJSONString()); + + RPCharacter loaded = new RPCharacter(null); + CharacterStageChangeFields.load(loaded, reparsed); + assertEquals(2, loaded.getPaidChangeCount(CLASS_STAGE)); + assertEquals(4, loaded.getStageRevision(CLASS_STAGE)); + assertEquals(1_790_000_000L, loaded.getStageRevisionSince(CLASS_STAGE)); + assertEquals(1, loaded.getStageRevision("race_selection_stage")); + assertEquals(0L, loaded.getStageRevisionSince("race_selection_stage")); + + HashMap clean = new HashMap<>(); + CharacterStageChangeFields.save(clean, new RPCharacter(null)); + assertTrue(clean.isEmpty()); + } + + @Test + void denarsFormatWithSeparators() { + assertEquals("100 denars", PaidChangeService.formatDenars(d("100"))); + assertEquals("3,000 denars", PaidChangeService.formatDenars(d("3000"))); + assertEquals("1 denar", PaidChangeService.formatDenars(d("1"))); + assertEquals("2.50 denars", PaidChangeService.formatDenars(d("2.5"))); + } + + private static BigDecimal d(String value) { + return new BigDecimal(value).setScale(2); + } + + @Test + void fractionalPricesStayExact() { + PaidChangeRule cheap = new PaidChangeRule("tip", CLASS_STAGE, "class", List.of(d("0.1"))); + wallet.set(Account.POUCH, d("0.3")); + RPCharacter character = characterWithClass("WARRIOR"); + for (int i = 0; i < 3; i++) { + assertEquals(ChargeStatus.PAID, PaidChangeService.charge(PLAYER, cheap, character).status()); + } + assertEquals(d("0"), wallet.get(Account.POUCH)); + } + + private static RPCharacter characterWithClass(String classId) { + RPCharacter character = new RPCharacter(null); + character.setMMOClass(classId); + return character; + } + + private static Stage stage(String id, long lockMs) { + Stage stage = new Stage(); + stage.setId(id); + stage.setLockTimeMs(lockMs); + return stage; + } + + private static final class FakeWallet implements DenarWallet { + private final Map balances = new EnumMap<>(Account.class); + boolean available = true; + boolean depositsFail; + + void set(Account account, BigDecimal amount) { + balances.put(account, amount.setScale(2)); + } + + BigDecimal get(Account account) { + return balances.getOrDefault(account, d("0")); + } + + @Override + public boolean available() { + return available; + } + + @Override + public BigDecimal balance(UUID playerId, Account account) { + return get(account); + } + + @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; + } + + @Override + public boolean deposit(UUID playerId, Account account, BigDecimal amount) { + if (depositsFail) { + return false; + } + balances.put(account, get(account).add(amount)); + return true; + } + } +}