namespace Aeshnidae.Bank;
///
/// Moves radiance and resonance between accounts.
///
/// Restricted to the earned currencies on purpose. Pyreals, luminance and keys all have
/// a carried form and therefore already have a way to change hands - trade windows, and
/// the game's own economy around them. Radiance and resonance have no carried form at
/// all, so without this they could only ever move one way, out of the world and into a
/// balance, and an economy needs them to circulate.
///
public static class Transfer
{
///
/// Serialises the whole check-then-move sequence.
///
/// Both halves are read-modify-writes against the same table, and BankDb's own
/// per-row guard stops a balance going negative but says nothing about two transfers
/// interleaving. This does: no transfer is ever half-applied, because nothing is
/// written until every check has passed.
///
private static readonly object _gate = new();
public static BankResult Send(Player sender, string recipientName, CurrencyKind kind, long amount)
{
if (!Mod.Settings.AllowPlayerTransfers)
return new(false, "Transfers are switched off on this server.");
if (!Currencies.IsEarned(kind))
return new(false, $"{Currencies.DisplayName(kind)} cannot be sent this way. " +
"Only Radiance and Resonance move between accounts - trade the rest in person.");
if (amount < Mod.Settings.MinimumTransfer)
return new(false, $"The smallest transfer is {Mod.Settings.MinimumTransfer:N0}.");
if (string.IsNullOrWhiteSpace(recipientName))
return new(false, "Send it to whom?");
// Finds offline characters too, which is the point - you should not have to
// catch somebody online to pay them.
var recipient = PlayerManager.FindByName(recipientName.Trim());
if (recipient?.Account is null)
return new(false, $"No character called '{recipientName.Trim()}'.");
var senderAccount = sender.Account.AccountId;
var recipientAccount = recipient.Account.AccountId;
if (senderAccount == recipientAccount)
return new(false, $"{recipient.Name} is on your own account - the balance is already shared.");
lock (_gate)
{
// Make the database authoritative before reading a balance to spend from.
// Without this a sender whose recent kills are still buffered would be told
// they have currency by /bank and refused by the debit below, which reads
// the written balance only.
Earning.Flush();
var balance = BankDb.GetBalance(senderAccount, kind);
if (balance < amount)
return new(false, $"You have {balance:N0} {Currencies.DisplayName(kind)}, " +
$"which is not {amount:N0}.");
var tax = (long)Math.Round(amount * Math.Clamp(Mod.Settings.TransferTax, 0, 1));
var delivered = amount - tax;
if (delivered <= 0)
return new(false, "The whole transfer would be lost to the transit fee. Send more.");
if (!BankDb.TryAdjust(senderAccount, kind, -amount, out _))
return new(false, "The transfer was refused - your balance changed while it was being sent.");
if (!BankDb.TryAdjust(recipientAccount, kind, delivered, out _))
{
// Put it back. A failed credit that kept the debit is the one outcome
// that destroys currency and cannot be explained to the player.
if (!BankDb.TryAdjust(senderAccount, kind, amount, out _))
ModManager.Log($"[{Mod.Name}] TRANSFER LOST: {amount:N0} {kind} debited from account " +
$"{senderAccount} for {recipient.Name}, credit failed, refund ALSO failed. " +
"This balance needs fixing by hand.", ModManager.LogLevel.Error);
return new(false, "The transfer failed and your balance has been restored.");
}
ModManager.Log($"[{Mod.Name}] {sender.Name} sent {delivered:N0} {kind} to {recipient.Name} " +
$"(gross {amount:N0}, fee {tax:N0})");
// Told immediately if they are online, because a currency arriving silently
// is indistinguishable from one that never arrived.
var online = PlayerManager.GetOnlinePlayer(recipient.Guid);
online?.SendMessage($"{sender.Name} sent you {delivered:N0} {Currencies.DisplayName(kind)}.");
Hud.Refresh(online);
var note = tax > 0 ? $" ({tax:N0} lost to the transit fee)" : "";
return new(true, $"Sent {delivered:N0} {Currencies.DisplayName(kind)} to {recipient.Name}{note}.");
}
}
}