Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<argLine></argLine>
<simplefactions.version>3.0.1</simplefactions.version>
<tlibs.version>2.4.0</tlibs.version>
<simplefactions.version>4.0.0</simplefactions.version>
<tlibs.version>2.4.1</tlibs.version>
<vehicleframework.version>2.7.0</vehicleframework.version>
<worldguard.version>7.0.9</worldguard.version>
</properties>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
import net.tfminecraft.rpcharacters.playerlist.SkinPortrait.SkinTexture;
import net.tfminecraft.rpcharacters.utils.AgeFormatter;
import net.tfminecraft.rpcharacters.utils.ClueFormatter;
import net.tfminecraft.tlibs.character.CharacterRef;

/**
* Join-time character picker, shown while the player has no active body. It
Expand Down Expand Up @@ -109,7 +110,7 @@ interface PortraitLoader {

static DenarWallet wallet = new DenarEconomyWallet();
/** Guild name, "" for none, or null when guilds can't be read. */
static Function<Player, String> guilds = CharacterSelectView::simpleFactionsGuild;
static Function<UUID, String> guilds = CharacterSelectView::simpleFactionsGuild;
static PortraitLoader portraits = SelectPortraits::load;
static Function<Player, SkinTexture> accountTextures = CharacterSelectView::accountSkin;
static BiFunction<ClickCallback<Audience>, ClickCallback.Options, ClickEvent> clickEvents = ClickEvent::callback;
Expand Down Expand Up @@ -247,7 +248,7 @@ private static void renderRoster(Session session) {
} else {
reason = session.actions.blockReason(selected);
body.add(DialogBody.plainMessage(portrait(session, selected), SkinPortrait.ROW_WIDTH));
body.add(text(characterCard(selected, reason)));
body.add(text(characterCard(player.getUniqueId(), selected, reason)));
}
body.add(text(footer(player, data, alive.size())));

Expand Down Expand Up @@ -438,7 +439,8 @@ private static void fill(BufferedImage image, int x, int y, int width, int heigh

private static Component footer(Player player, PlayerData data, int alive) {
int slots = CharacterSlotService.getMaxAliveCharacters(player);
return Component.text(alive + " of " + slots + " character slots used • ", DIM).append(accountSummary(player));
Component used = Component.text(alive + " of " + slots + " character slots used", DIM);
return used;
}

/** Living characters, most recently played first, then memorials. */
Expand Down Expand Up @@ -482,22 +484,30 @@ static String createBlockReason(Player player, PlayerData data) {
return null;
}

static Component accountSummary(Player player) {
List<String> parts = new ArrayList<>();
String guild = guilds.apply(player);
if (guild != null) parts.add("Guild: " + (guild.isBlank() ? "none" : guild));
if (wallet.available()) {
parts.add("Pouch: " + PaidChangeService.formatDenars(wallet.balance(player.getUniqueId(), Account.POUCH)));
parts.add("Bank: " + PaidChangeService.formatDenars(wallet.balance(player.getUniqueId(), Account.BANK)));
} else {
parts.add("Pouch and bank: unavailable");
/** The guild line, or null when guilds can't be read. */
static Component guildSummary(RPCharacter character) {
UUID id;
try { id = UUID.fromString(character.getId()); }
catch (IllegalArgumentException | NullPointerException invalid) { return null; }
String guild = guilds.apply(id);
if (guild == null) return null;
return Component.text("Guild: " + (guild.isBlank() ? "none" : guild), MUTED);
}

/** This character's own pouch and bank; empty for a record without a character ID. */
static String balances(UUID owner, RPCharacter character) {
CharacterRef ref;
try {
ref = new CharacterRef(owner, UUID.fromString(character.getId()));
} catch (IllegalArgumentException | NullPointerException notACharacterId) {
return "";
}
return Component.text("Shared by all your characters", DIM)
.append(Component.newline())
.append(Component.text(String.join(" • ", parts), MUTED));
if (!wallet.available()) return "Pouch and bank: unavailable";
return "Pouch: " + PaidChangeService.formatCents(wallet.balance(ref, Account.POUCH))
+ " • Bank: " + PaidChangeService.formatCents(wallet.balance(ref, Account.BANK));
}

static Component characterCard(RPCharacter character, String blockReason) {
static Component characterCard(UUID owner, RPCharacter character, String blockReason) {
TextComponent.Builder card = Component.text()
.append(Component.text(name(character), PALE, TextDecoration.BOLD));
line(card, joined(race(character), className(character), age(character)), TEAL);
Expand All @@ -508,6 +518,9 @@ static Component characterCard(RPCharacter character, String blockReason) {
history.add("Last played " + AgeFormatter.formatAge(seconds) + " ago");
}
line(card, String.join(" • ", history), MUTED);
line(card, balances(owner, character), MUTED);
Component guild = guildSummary(character);
if (guild != null) card.append(Component.newline()).append(guild);
if (character.hasLastLocation()) {
line(card, String.format(Locale.ROOT, "Last location: %s %d, %d, %d", character.getLastLocationWorld(),
(long) Math.floor(character.getLastLocationX()), (long) Math.floor(character.getLastLocationY()),
Expand Down Expand Up @@ -621,11 +634,11 @@ private static ActionButton button(Component label, String tooltip, int width, S
return builder.build();
}

/** Account-wide until characters own their guild membership. */
static String simpleFactionsGuild(Player player) {
/** The guild belonging to the character shown on this card. */
static String simpleFactionsGuild(UUID characterId) {
if (!Bukkit.getPluginManager().isPluginEnabled("SimpleFactions")) return null;
try {
return SimpleFactionsGuild.name(player.getName());
return SimpleFactionsGuild.name(characterId);
} catch (NoClassDefFoundError | RuntimeException failure) {
return null;
}
Expand All @@ -635,9 +648,10 @@ static String simpleFactionsGuild(Player player) {
static final class SimpleFactionsGuild {
private SimpleFactionsGuild() {}

static String name(String playerName) {
static String name(UUID characterId) {
if (!net.tfminecraft.simplefactions.api.SimpleFactionsApi.ready()) return null;
net.tfminecraft.simplefactions.guild.Guild guild =
net.tfminecraft.simplefactions.managers.FactionManager.getGuildByMember(playerName);
net.tfminecraft.simplefactions.managers.FactionManager.getGuildByMember(characterId);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return guild == null || guild.getName() == null ? "" : ClueFormatter.stripColor(guild.getName());
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,11 @@
import net.tfminecraft.rpcharacters.paidchange.DenarEconomyWallet;
import net.tfminecraft.rpcharacters.paidchange.DenarWallet;
import net.tfminecraft.rpcharacters.paidchange.DenarWallet.Account;
import net.tfminecraft.rpcharacters.paidchange.DenarWallet.Posting;
import net.tfminecraft.rpcharacters.paidchange.PaidChangeRule;
import net.tfminecraft.rpcharacters.paidchange.PaidChangeService;
import net.tfminecraft.rpcharacters.paidchange.Payment;
import net.tfminecraft.rpcharacters.paidchange.Payments;
import net.tfminecraft.rpcharacters.utils.AgeFormatter;
import net.tfminecraft.rpcharacters.utils.RPTexts;

Expand Down Expand Up @@ -62,7 +65,7 @@ public enum Status {
NOT_APPLIED
}

/** {@code paidFrom} is null when the pick was free. */
/** {@code paidFrom} is null when the pick was free, or when a pick that was not applied was refunded. */
public record Result(Status status, PlayerClass target, BigDecimal cost, Account paidFrom) {}

/** A displayed base class and the level its subclasses unlock at. */
Expand Down Expand Up @@ -229,62 +232,133 @@ public static Result choose(Player player, String classId) {
if (net.Indyuce.mmocore.api.player.PlayerData.get(player).getLevel() < option.requiredLevel()) {
return new Result(Status.LEVEL_TOO_LOW, target, BigDecimal.ZERO, null);
}
UUID owner = player.getUniqueId();
tell(player, recover(owner, character));
if (character.getPendingPaidChange() != null) {
tell(player, PaidChangeService.recover(character));
}
// A held payment is settled by comparing the class, so no other change may run meanwhile.
if (character.getPendingClassPick() != null || character.getPendingPaidChange() != null) {
return new Result(Status.UNAVAILABLE, target, BigDecimal.ZERO, null);
}
BigDecimal cost = price(character, option);
Account paidFrom = null;
Payment payment = null;
if (cost.signum() > 0) {
if (!wallet.available()) {
return new Result(Status.UNAVAILABLE, target, cost, null);
}
paidFrom = withdraw(player.getUniqueId(), cost);
if (paidFrom == null) {
return new Result(Status.INSUFFICIENT_FUNDS, target, cost, null);
Payments.Charge taken = Payments.take(wallet, DenarWallet.CLASS_PICK, "class-pick",
Payments.payer(owner, character), settings.accounts(), Payments.cents(cost),
held -> hold(owner, character, target.getId(), held));
if (taken.status() != Payments.Taken.PAID) {
Status status = taken.status() == Payments.Taken.UNAVAILABLE ? Status.UNAVAILABLE : Status.INSUFFICIENT_FUNDS;
return new Result(status, target, cost, null);
}
payment = taken.payment();
}
String oldClassId = character.getMMOClass();
character.setMMOClass(target.getId());
if (!applied(player, target.getId())) {
character.setMMOClass(oldClassId);
refund(player.getUniqueId(), paidFrom, cost);
if (payment != null) {
// Saved first, so a later move to the same class can't pass for this payment.
character.setPendingClassPick(new PendingClassPick(target.getId(), payment, true));
Payments.save(owner, character);
}
if (payment != null && !refund(owner, character)) {
tell(player, refundLater(payment));
return new Result(Status.NOT_APPLIED, target, cost, payment.account());
}
return new Result(Status.NOT_APPLIED, target, cost, null);
}
if (option.isSubclass()) {
character.setSubclassPicked(true);
}
if (paidFrom != null) {
character.setPaidClassPicks(character.getPaidClassPicks() + 1);
PaidChangeRule rule = PaidChangeService.ruleFor(classStage());
if (rule != null) {
// Shared with paid class-stage edits, so the next change costs the next price.
character.setPaidChangeCount(rule.getStageId(), character.getPaidChangeCount(rule.getStageId()) + 1);
}
if (payment != null) {
countPaid(character);
}
CharacterLifecycle.notifyClassChange(player, pd.getUniqueId(), character, oldClassId, target.getId());
RPCharacters.getPlayerManager().savePlayer(player);
return new Result(Status.CHOSEN, target, cost, paidFrom);
return new Result(Status.CHOSEN, target, cost, payment == null ? null : payment.account());
}

private static boolean applied(Player player, String classId) {
try {
return ClassService.applyClass(player, classId);
} catch (RuntimeException e) {
LOG.log(Level.WARNING, "[RPCharacters] Class " + classId + " could not be applied for " + player.getName(), e);
return false;
/** Holds the payment on the character and saves it; null drops a payment that was never taken. */
private static boolean hold(UUID owner, RPCharacter character, String targetClass, Payment payment) {
character.setPendingClassPick(payment == null ? null : new PendingClassPick(targetClass, payment, false));
if (Payments.save(owner, character) || payment == null) {
return true;
}
character.setPendingClassPick(null);
return false;
}

private static Account withdraw(UUID playerId, BigDecimal cost) {
for (Account account : settings.accounts()) {
if (wallet.balance(playerId, account).compareTo(cost) >= 0 && wallet.withdraw(playerId, account, cost)) {
return account;
}
/** Counts a kept paid pick and clears its hold. */
private static void countPaid(RPCharacter character) {
character.setPendingClassPick(null);
character.setPaidClassPicks(character.getPaidClassPicks() + 1);
PaidChangeRule rule = PaidChangeService.ruleFor(classStage());
if (rule != null) {
// Shared with paid class-stage edits, so the next change costs the next price.
character.setPaidChangeCount(rule.getStageId(), character.getPaidChangeCount(rule.getStageId()) + 1);
}
}

/**
* Settles a paid pick left held by a crash or a failed refund. A debit that never applied is
* dropped; a pick whose class stuck (and was not marked for refund) is kept and counted;
* anything else is refunded with the stored refund ID. Returns the player message, or null when
* there is nothing to say.
*/
public static String recover(UUID owner, RPCharacter character) {
PendingClassPick pending = character == null ? null : character.getPendingClassPick();
if (pending == null) {
return null;
}
Payments.Debit debit = Payments.debited(wallet, pending.payment());
if (debit == Payments.Debit.NOT_APPLIED) {
character.setPendingClassPick(null);
} else if (debit == Payments.Debit.APPLIED && !pending.refundRequired()
&& pending.targetClass().equalsIgnoreCase(character.getMMOClass())) {
countPaid(character);
} else if (debit == Payments.Debit.APPLIED) {
Payment payment = pending.payment();
return refund(owner, character) ? RPTexts.SUCCESS + "Your class didn't change, so "
+ PaidChangeService.formatCents(payment.cents()) + " went back to your "
+ payment.account().displayName() + "." : refundLater(payment);
}
return null;
}

private static void refund(UUID playerId, Account account, BigDecimal cost) {
if (account != null && !wallet.deposit(playerId, account, cost)) {
LOG.log(Level.WARNING, "[RPCharacters] Could not refund " + cost + " denars to " + playerId + " ("
+ account.displayName() + ") for a class pick that was not applied. Refund it by hand.");
/** Refunds the held pick with its stored ID. False keeps the hold for the next join. */
private static boolean refund(UUID owner, RPCharacter character) {
Payment payment = character.getPendingClassPick().payment();
if (Payments.refund(wallet, payment) != Posting.APPLIED) {
LOG.warning("[RPCharacters] Could not post " + payment.refundOpId() + " for a class pick that was not applied;"
+ " it is retried when the owner next joins.");
return false;
}
character.setPendingClassPick(null);
Payments.save(owner, character);
return true;
}

private static String refundLater(Payment payment) {
return RPTexts.ERROR + "Your " + PaidChangeService.formatCents(payment.cents())
+ " class change refund couldn't go through yet. It will be retried when you next join.";
}

private static void tell(Player player, String message) {
if (message != null) {
RPTexts.send(player, message);
}
}

private static boolean applied(Player player, String classId) {
try {
return ClassService.applyClass(player, classId);
} catch (RuntimeException e) {
LOG.log(Level.WARNING, "[RPCharacters] Class " + classId + " could not be applied for " + player.getName(), e);
return false;
}
}

Expand All @@ -308,7 +382,7 @@ public static String message(Result result) {
case INSUFFICIENT_FUNDS -> RPTexts.ERROR + "Changing your class costs " + PaidChangeService.formatDenars(result.cost())
+ ". You don't have that much in your pouch or the bank.";
case NOT_APPLIED -> RPTexts.ERROR + "Your class couldn't be changed right now. Try again in a moment."
+ (result.cost().signum() > 0 ? " You were not charged." : "");
+ (result.cost().signum() > 0 && result.paidFrom() == null ? " You were not charged." : "");
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package net.tfminecraft.rpcharacters.classpick;

import net.tfminecraft.rpcharacters.paidchange.Payment;

/**
* A paid class pick between its debit and the class being applied, saved with the character.
* {@code refundRequired} is set once applying the class failed, so the payment is refunded even if
* the character later reaches the same class some other way.
*/
public record PendingClassPick(String targetClass, Payment payment, boolean refundRequired) {}
Original file line number Diff line number Diff line change
Expand Up @@ -505,9 +505,17 @@ public void jumpToStageForEdit(String stageId) {

PaidChangeService.settle(this);

if (character.getPendingPaidChange() != null) {
if (p != null) {

// A refund that failed stays held. Any edit now would look like the paid change was used.
String classRefund = net.tfminecraft.rpcharacters.classpick.ClassPickService.recover(p.getUniqueId(), character);

if (classRefund != null) RPTexts.send(p, classRefund);

}

if (character.getPendingPaidChange() != null || character.getPendingClassPick() != null) {

// A held payment is settled by comparing the character. Any edit now could pass for it.

RPTexts.send(p, RPTexts.ERROR + "Your last refund hasn't gone through yet. Try again after you rejoin.");

Expand Down
Loading
Loading