namespace Aeshnidae.Bank;
public record BankResult(bool Ok, string Message);
///
/// Deposit / withdraw logic. Balances are debited before items are created and
/// re-credited if creation fails, so a full pack can never swallow a balance.
///
public static class BankService
{
public const long All = -1;
// ---------------------------------------------------------------- deposit
public static BankResult Deposit(Player player, CurrencyKind kind, long amount)
{
if (amount == 0 || (amount < 0 && amount != All))
return new(false, "Amount must be a positive number, or 'all'.");
// Earned currencies have no carried form, so there is nothing on the character
// to deposit. Caught here rather than falling through to DepositKeys, which
// would go looking for a Radiance item in the pack and report something
// baffling about not finding one.
if (Currencies.IsEarned(kind))
return new(false, $"{Currencies.DisplayName(kind)} is banked the moment you earn it - " +
"there is nothing to deposit.");
return kind switch
{
CurrencyKind.Pyreal => DepositPyreals(player, amount),
CurrencyKind.Luminance => DepositLuminance(player, amount),
_ => DepositKeys(player, kind, amount),
};
}
///
/// /b d with nothing after it: everything in the pack that the bank takes, in one go.
///
/// Each currency is tried on its own and the results are gathered, so a pack with
/// pyreals and no keys deposits the pyreals and says nothing about keys. Only a
/// currency that was actually there and then FAILED gets a line - the point of a
/// sweep is that you do not have to know what you are carrying.
///
public static BankResult DepositAll(Player player)
{
var lines = new List();
var deposited = 0;
foreach (var kind in new[] { CurrencyKind.Pyreal, CurrencyKind.Luminance, CurrencyKind.LegendaryKey, CurrencyKind.SturdyIronKey })
{
// Skip what is not there, quietly. The individual deposits report "you have
// no X" as a failure, which is right when you asked for X and wrong here.
var carrying = kind switch
{
CurrencyKind.Pyreal => CarriedPyreals(player) > 0,
CurrencyKind.Luminance => (player.MaximumLuminance ?? 0) > 0 && (player.AvailableLuminance ?? 0) > 0,
_ => Currencies.AcceptedFor(kind).Keys.Any(w => player.GetInventoryItemsOfWCID(w).Any(i => (i.Structure ?? 0) > 0)),
};
if (!carrying)
continue;
var result = Deposit(player, kind, All);
lines.Add(result.Message);
if (result.Ok)
deposited++;
}
if (lines.Count == 0)
return new(false, "Nothing in your pack that the bank takes - no pyreals, luminance or keys.");
return new(deposited > 0, string.Join("\n", lines));
}
private static BankResult DepositPyreals(Player player, long amount)
{
var carried = CarriedPyreals(player);
if (amount == All)
amount = carried;
if (amount <= 0)
return new(false, "You have no pyreals to deposit.");
if (carried < amount)
return new(false, $"You are only carrying {carried:N0} pyreals.");
// Smallest denominations first, so we break as few large notes as possible.
long collected = 0;
foreach (var denom in Currencies.PyrealDenominations.Reverse())
{
if (collected >= amount)
break;
foreach (var stack in player.GetInventoryItemsOfWCID(denom.Wcid).ToList())
{
if (collected >= amount)
break;
var size = stack.StackSize ?? 1;
var stackValue = denom.Unit * size;
var needed = amount - collected;
if (stackValue <= needed)
{
if (player.TryConsumeFromInventoryWithNetworking(stack, size))
collected += stackValue;
continue;
}
// Take as many whole notes from this stack as fit under the target.
var take = (int)Math.Min(size, needed / denom.Unit);
if (take > 0 && player.TryConsumeFromInventoryWithNetworking(stack, take))
collected += denom.Unit * take;
// One more to cover any remainder; the excess is returned as change.
if (collected < amount && take < size &&
player.TryConsumeFromInventoryWithNetworking(stack, 1))
collected += denom.Unit;
}
}
if (collected < amount)
{
if (collected > 0)
GiveOut(player, CurrencyKind.Pyreal, collected);
return new(false, "Could not gather that many pyreals from your pack.");
}
var change = collected - amount;
if (!BankDb.TryAdjust(player.Account.AccountId, CurrencyKind.Pyreal, amount, out var balance))
{
GiveOut(player, CurrencyKind.Pyreal, collected);
return new(false, "The bank could not record that deposit. Nothing was taken.");
}
if (change > 0)
GiveOut(player, CurrencyKind.Pyreal, change);
var changeNote = change > 0 ? $" ({change:N0} returned as change)" : "";
return new(true, $"Deposited {amount:N0} pyreals{changeNote}. Balance: {balance:N0}.");
}
private static BankResult DepositLuminance(Player player, long amount)
{
if (player.MaximumLuminance is null or 0)
return new(false, "You are not attuned to luminance yet, so you cannot bank it.");
var available = player.AvailableLuminance ?? 0;
if (amount == All)
amount = available;
if (amount <= 0)
return new(false, "You have no luminance to deposit.");
if (available < amount)
return new(false, $"You only have {available:N0} luminance.");
if (!player.SpendLuminance(amount))
return new(false, "Could not take that luminance.");
if (!BankDb.TryAdjust(player.Account.AccountId, CurrencyKind.Luminance, amount, out var balance))
{
player.GrantLuminance(amount, XpType.Admin, ShareType.None);
return new(false, "The bank could not record that deposit. Nothing was taken.");
}
return new(true, $"Deposited {amount:N0} luminance. Balance: {balance:N0}.");
}
///
/// Keys bank as charges. A key cannot be split, so whole keys are taken
/// smallest-first and we stop before overshooting the requested amount.
///
private static BankResult DepositKeys(Player player, CurrencyKind kind, long amount)
{
var label = Currencies.DisplayName(kind).ToLowerInvariant();
var held = new List<(WorldObject Item, int Charges)>();
foreach (var wcid in Currencies.AcceptedFor(kind).Keys)
{
foreach (var item in player.GetInventoryItemsOfWCID(wcid))
{
var charges = item.Structure ?? 0;
if (charges > 0)
held.Add((item, charges));
}
}
if (held.Count == 0)
return new(false, $"You are not carrying any {label}.");
if (amount == All)
amount = held.Sum(h => (long)h.Charges);
long collected = 0;
foreach (var (item, charges) in held.OrderBy(h => h.Charges))
{
if (collected + charges > amount)
continue;
if (player.TryConsumeFromInventoryWithNetworking(item, 1))
collected += charges;
}
if (collected == 0)
{
var smallest = held.Min(h => h.Charges);
return new(false,
$"Your smallest key carries {smallest} uses, more than the {amount} you asked to bank. " +
"Keys cannot be split.");
}
if (!BankDb.TryAdjust(player.Account.AccountId, kind, collected, out var balance))
{
GiveOut(player, kind, collected);
return new(false, "The bank could not record that deposit. Your keys were returned.");
}
var note = collected < amount ? $" (whole keys only, so {collected} of {amount})" : "";
return new(true, $"Deposited {collected:N0} {label} uses{note}. Balance: {balance:N0}.");
}
// --------------------------------------------------------------- withdraw
public static BankResult Withdraw(Player player, CurrencyKind kind, long amount)
{
if (amount == 0 || (amount < 0 && amount != All))
return new(false, "Amount must be a positive number, or 'all'.");
// Nothing to withdraw INTO - there is no radiance item, by design. Spending it
// is what a shop is for; carrying it is not a thing.
if (Currencies.IsEarned(kind))
return new(false, $"{Currencies.DisplayName(kind)} cannot be carried, only spent. " +
"It stays in your account balance.");
var label = Currencies.DisplayName(kind).ToLowerInvariant();
var accountId = player.Account.AccountId;
var balance = BankDb.GetBalance(accountId, kind);
if (amount == All)
amount = balance;
if (amount <= 0)
return new(false, $"You have no {label} banked.");
if (balance < amount)
return new(false, $"You only have {balance:N0} {label} banked.");
// Luminance is hard-capped at MaximumLuminance and AddLuminance silently
// bins the overflow, so clamp here rather than losing the difference.
if (kind == CurrencyKind.Luminance)
{
if (player.MaximumLuminance is null or 0)
return new(false, "You are not attuned to luminance yet, so you cannot withdraw it.");
var headroom = (player.MaximumLuminance ?? 0) - (player.AvailableLuminance ?? 0);
if (headroom <= 0)
return new(false, $"You are already at your luminance maximum of {player.MaximumLuminance:N0}.");
if (amount > headroom)
return new(false, $"You can only hold {headroom:N0} more luminance right now.");
}
if (!BankDb.TryAdjust(accountId, kind, -amount, out var remaining))
return new(false, "The bank could not complete that withdrawal.");
var delivered = GiveOut(player, kind, amount);
if (delivered < amount)
{
BankDb.TryAdjust(accountId, kind, amount - delivered, out remaining);
if (delivered == 0)
return new(false, "You have no room for that. Nothing was withdrawn.");
return new(true,
$"Withdrew {delivered:N0}{Currencies.UnitWord(kind)} of {label} - your pack filled up. " +
$"Balance: {remaining:N0}.");
}
return new(true, $"Withdrew {amount:N0}{Currencies.UnitWord(kind)} of {label}. Balance: {remaining:N0}.");
}
///
/// Hands over an amount, largest denomination first, and reports how much
/// actually fit in the pack.
///
private static long GiveOut(Player player, CurrencyKind kind, long amount)
{
if (kind == CurrencyKind.Luminance)
{
player.GrantLuminance(amount, XpType.Admin, ShareType.None);
return amount;
}
long delivered = 0;
foreach (var denom in Currencies.PayoutFor(kind))
{
while (amount - delivered >= denom.Unit)
{
var wanted = (amount - delivered) / denom.Unit;
var count = denom.MaxStack > 1 ? (int)Math.Min(wanted, denom.MaxStack) : 1;
var item = WorldObjectFactory.CreateNewWorldObject(denom.Wcid);
if (item is null)
break;
if (denom.MaxStack > 1)
item.SetStackSize(count);
if (!player.TryCreateInInventoryWithNetworking(item))
{
item.Destroy();
return delivered;
}
delivered += denom.Unit * count;
}
}
return delivered;
}
// ----------------------------------------------------------------- report
public static long CarriedPyreals(Player player)
{
long total = 0;
foreach (var denom in Currencies.PyrealDenominations)
{
foreach (var stack in player.GetInventoryItemsOfWCID(denom.Wcid))
total += denom.Unit * (stack.StackSize ?? 1);
}
return total;
}
public static string Statement(Player player)
{
var accountId = player.Account.AccountId;
var balances = BankDb.GetAllBalances(accountId);
// Earnings sit in memory for up to Settings.FlushSeconds before they are
// written. Folding them in here is what stops a player killing something,
// typing /bank, and seeing none of it.
foreach (var (kind, pending) in Earning.PendingFor(accountId))
balances[kind] = balances.GetValueOrDefault(kind) + pending;
var sb = new StringBuilder("Bank balance (shared across your account):\n");
foreach (var kind in Enum.GetValues())
sb.AppendLine($" {Currencies.DisplayName(kind),-18} {balances[kind],18:N0}{Currencies.UnitWord(kind)}");
return sb.ToString();
}
///
/// A single balance, including anything earned but not yet flushed.
///
/// Every read that a player can act on goes through here rather than BankDb
/// directly, so a transfer cannot refuse to send currency the statement just
/// said they had.
///
public static long BalanceOf(uint accountId, CurrencyKind kind) =>
BankDb.GetBalance(accountId, kind) + Earning.PendingFor(accountId, kind);
}