namespace Aeshnidae.Bank;
///
/// /bank and /b.
///
/// The item name can be several words ("legendary key"), so parsing works from
/// both ends: first argument is the verb, last is the amount, and everything
/// between is joined back into the item name. That makes
/// /bank deposit legendary key 50
/// /b d lk 50
/// two spellings of the same thing without needing quotes.
///
public static class Commands
{
[CommandHandler("bank", AccessLevel.Player, CommandHandlerFlag.RequiresWorld, -1,
"Deposit and withdraw pyreals, luminance and keys. Shared across your account.",
"/bank show balances\n" +
"/bank deposit - deposit, or 'all' for everything\n" +
"/bank withdraw
- withdraw\n" +
"/bank help item names and short forms")]
public static void HandleBank(Session session, params string[] parameters)
{
var player = session?.Player;
if (player is null)
return;
if (!BankDb.Ready)
{
Send(player, "The bank is not open - its storage failed to initialise. Check the server log.");
return;
}
try
{
Run(player, parameters ?? Array.Empty());
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] /bank failed for {player.Name}: {ex}", ModManager.LogLevel.Error);
Send(player, "Something went wrong at the bank. Nothing was changed.");
}
// Whatever the verb did, a listening client redraws its bank panel from the
// balances as they now are. Cheap, and simpler than tracking which verbs move money.
Hud.Refresh(player);
}
[CommandHandler("b", AccessLevel.Player, CommandHandlerFlag.RequiresWorld, -1,
"Short form of /bank.", "/b d lk 50")]
public static void HandleBankShort(Session session, params string[] parameters) =>
HandleBank(session, parameters);
private static void Run(Player player, string[] args)
{
if (args.Length == 0)
{
Send(player, BankService.Statement(player));
return;
}
var verb = args[0].ToLowerInvariant();
switch (verb)
{
case "help" or "h" or "?":
Send(player, HelpText());
return;
case "balance" or "bal" or "b" or "list" or "l":
Send(player, BankService.Statement(player));
return;
case "pay" or "send" or "give":
Pay(player, args);
return;
case "earned" or "rate" or "e":
Send(player, EarnedText(player));
return;
case "autolum" or "autoluminance" or "auto":
AutoLuminance(player, args);
return;
case "deposit" or "dep" or "d":
case "withdraw" or "wd" or "w":
break;
default:
Send(player, $"Unknown command '{args[0]}'. Try /bank help.");
return;
}
var depositing = verb is "deposit" or "dep" or "d";
// /b d on its own: everything the bank takes, in one go.
if (depositing && args.Length == 1)
{
Send(player, BankService.DepositAll(player).Message);
return;
}
if (args.Length < 2)
{
Send(player, "Usage: /b w
- [amount] e.g. /b w p 50k or /b w p for all of it");
return;
}
// The amount is the last token IF it parses as one; otherwise every token after
// the verb is the item name and the amount is "all". So `/b d lk`, `/b d lk all`
// and `/b d legendary key` all mean the same thing, and `/b d lk 50` means fifty.
// Item names first, amount last, because the names can be several words.
long amount = BankService.All;
string itemName;
if (args.Length >= 3 && TryParseAmount(args[^1], out var parsed))
{
amount = parsed;
itemName = string.Join(' ', args[1..^1]);
}
else if (args.Length == 2 && TryParseAmount(args[1], out _))
{
Send(player, $"Usage: /b {(depositing ? "d" : "w")}
- [amount] - which item?");
return;
}
else
{
itemName = string.Join(' ', args[1..]);
}
if (!Currencies.TryResolve(itemName, out var kind))
{
Send(player, $"Don't know what '{itemName}' is. Try /bank help for the list.");
return;
}
var result = depositing
? BankService.Deposit(player, kind, amount)
: BankService.Withdraw(player, kind, amount);
Send(player, result.Message);
}
///
/// /bank pay <player> <currency> <amount>
///
/// Parsed from the right: the amount is the last token, the currency the one before
/// it, and the recipient is everything in between - because character names can be
/// two words ("Dargoth Hera") and the only earned currencies, the ones that can be
/// sent, all have one-word names and aliases. Reading the name first would have
/// swallowed the second word of a two-word name into the currency.
///
private static void Pay(Player player, string[] args)
{
if (args.Length < 4)
{
Send(player, "Usage: /b pay e.g. /b pay Dargoth Hera rad 5m");
return;
}
var amountToken = args[^1];
var currencyName = args[^2];
var recipient = string.Join(' ', args[1..^2]);
if (!Currencies.TryResolve(currencyName, out var kind))
{
Send(player, $"Don't know what '{currencyName}' is. Try /bank help for the list.");
return;
}
if (!TryParseAmount(amountToken, out var amount) || amount <= 0)
{
Send(player, $"'{amountToken}' is not an amount to send. Use a whole number.");
return;
}
Send(player, Transfer.Send(player, recipient, kind, amount).Message);
}
///
/// /grant <radiance|resonance> <amount> [player] - Admin, for testing.
///
/// Writes the account balance directly and synchronously: no earning buffer, no
/// /earned history (it is not earned), no transfer fee. A negative amount takes it
/// away, and the guarded UPDATE refuses to go below zero. Works from the console
/// with a player named; in game the player defaults to yourself.
///
/// Only the earned currencies. Pyreals and keys have item forms and the normal
/// admin commands for those already exist.
///
[CommandHandler("grant", AccessLevel.Admin, CommandHandlerFlag.None, 2,
"Grant (or remove) Radiance or Resonance on an account, for testing.",
"/grant radiance 5000000 Aeshna\n" +
"/grant resonance 100 (yourself, in game)\n" +
"/grant radiance -5000000 Aeshna (take it back)")]
public static void HandleGrant(Session session, params string[] parameters)
{
void Say(string text)
{
if (session?.Player is not null) Send(session.Player, text);
else ModManager.Log($"[{Mod.Name}] {text}");
}
if (!BankDb.Ready)
{
Say("The bank is not open - its storage failed to initialise.");
return;
}
if (parameters.Length < 2)
{
Say("Usage: /grant [player]");
return;
}
if (!Currencies.TryResolve(parameters[0], out var kind) || !Currencies.IsEarned(kind))
{
Say($"'{parameters[0]}' is not an earned currency. Radiance or Resonance.");
return;
}
var amountToken = parameters[1];
var negative = amountToken.StartsWith('-');
if (!TryParseAmount(amountToken.TrimStart('-'), out var amount) || amount <= 0)
{
Say($"'{amountToken}' is not an amount. Use a whole number - 50, 10k, 2.5m.");
return;
}
if (negative)
amount = -amount;
uint accountId;
string targetName;
if (parameters.Length >= 3)
{
// Names can be two words ("Dargoth Hera"), so the name is everything after
// the amount, not the next token.
var name = string.Join(' ', parameters[2..]);
var target = PlayerManager.FindByName(name);
if (target?.Account is null)
{
Say($"No character called '{name}'.");
return;
}
accountId = target.Account.AccountId;
targetName = target.Name;
}
else if (session?.Player?.Account is not null)
{
accountId = session.Player.Account.AccountId;
targetName = session.Player.Name;
}
else
{
Say("From the console, name the player: /grant radiance 5000000 Aeshna");
return;
}
if (!BankDb.TryAdjust(accountId, kind, amount, out var balance))
{
Say($"Refused - that would take {targetName}'s {Currencies.DisplayName(kind)} below zero.");
return;
}
var who = session?.Player?.Name ?? "console";
var verb = amount >= 0 ? "granted" : "removed";
ModManager.Log($"[{Mod.Name}] {who} {verb} {Math.Abs(amount):N0} {kind} {(amount >= 0 ? "to" : "from")} {targetName}'s account; balance now {balance:N0}");
Say($"{Currencies.DisplayName(kind)}: {verb} {Math.Abs(amount):N0} {(amount >= 0 ? "to" : "from")} {targetName}'s account. Balance: {balance:N0}.");
// Tell them, if they are on. A balance that changes with no message looks like a bug.
var online = PlayerManager.GetOnlinePlayer(targetName);
if (online is not null && online != session?.Player)
online.SendMessage($"An admin {verb} {Math.Abs(amount):N0} {Currencies.DisplayName(kind)}. Balance: {balance:N0}.");
}
[CommandHandler("earned", AccessLevel.Player, CommandHandlerFlag.RequiresWorld, -1,
"What this character has earned recently.",
"/earned rolling windows, session total and hourly rate\n" +
"/earned reset start the session over from now")]
public static void HandleEarned(Session session, params string[] parameters)
{
if (session.Player is not { } player)
return;
var verb = parameters.Length > 0 ? parameters[0].ToLowerInvariant() : "";
if (verb is "reset" or "clear" or "zero")
{
History.StartSession(player);
Send(player, "Earnings history cleared. The session clock restarts from now.");
return;
}
Send(player, EarnedText(player));
}
///
/// Radiance and resonance earned over the four windows, for the CHARACTER rather
/// than the account - the balance is shared but the earning is not, and "how is this
/// spot paying" is a question about who is standing in it.
///
/// Earned only. Currency another player sent you is not counted, or two people could
/// pass the same balance back and forth and both report a superb hourly rate.
///
private static string EarnedText(Player player)
{
int[] windows = { 5, 10, 30, 60 };
// Every row is built by walking History.Tracked rather than naming currencies,
// so adding a fourth to that list is all it takes to appear here.
var sb = new StringBuilder($"Earned by {player.Name} (kills and quests only):\n");
sb.Append($" {"",-10}");
foreach (var kind in History.Tracked)
sb.Append($"{Currencies.DisplayName(kind),14}");
sb.AppendLine();
foreach (var minutes in windows)
{
sb.Append($" {minutes + " min",-10}");
foreach (var kind in History.Tracked)
sb.Append($"{History.Earned(player, kind, minutes),14:N0}");
sb.AppendLine();
}
sb.Append($" {"Session",-10}");
foreach (var kind in History.Tracked)
sb.Append($"{History.Session(player, kind),14:N0}");
sb.AppendLine($" over {Duration(History.SessionLength(player))}");
sb.Append($" {"Per hour",-10}");
var projected = History.PerHour(player, History.Tracked[0]) is not null;
foreach (var kind in History.Tracked)
{
var rate = History.PerHour(player, kind);
sb.Append(rate is null ? $"{"-",14}" : $"{rate.Value,14:N0}");
}
sb.AppendLine(projected
? " projected"
: $" after {(int)History.MinimumForProjection.TotalMinutes} min of play");
if (History.Tracked.All(kind => History.Session(player, kind) == 0))
sb.AppendLine(" Nothing yet this session. /earned reset starts the clock over.");
return sb.ToString().TrimEnd();
}
/// "2h 14m", "43m", "50s" - the largest useful unit, not a full timestamp.
private static string Duration(TimeSpan span)
{
if (span.TotalMinutes < 1)
return $"{(int)span.TotalSeconds}s";
if (span.TotalHours < 1)
return $"{(int)span.TotalMinutes}m";
return $"{(int)span.TotalHours}h {span.Minutes}m";
}
///
/// /bank autolum [on|off] - whether THIS character banks its luminance.
///
/// Per character rather than per account on purpose: a main that should never lose
/// an award to the cap and a mule that should sit on its luminance are the same
/// account, and the flag has to be able to tell them apart.
///
private static void AutoLuminance(Player player, string[] args)
{
if (!Mod.Settings.AllowLuminanceAutoBank)
{
Send(player, "Automatic luminance banking is switched off on this server.");
return;
}
var current = AutoBank.IsOn(player);
if (args.Length < 2)
{
Send(player, current
? $"{player.Name} banks luminance automatically. It bypasses your luminance cap, " +
"and purchases draw from the bank. /bank autolum off to stop."
: $"{player.Name} keeps luminance the normal way, capped at your maximum. " +
"/bank autolum on to bank it instead.");
return;
}
var wanted = args[1].ToLowerInvariant() switch
{
"on" or "yes" or "1" or "true" => true,
"off" or "no" or "0" or "false" => false,
_ => (bool?)null,
};
if (wanted is null)
{
Send(player, "Usage: /bank autolum on or /bank autolum off");
return;
}
if (wanted == current)
{
Send(player, $"Already {(current ? "on" : "off")}.");
return;
}
if (!AutoBank.Set(player, wanted.Value))
{
Send(player, "That could not be saved, so nothing was changed.");
return;
}
Send(player, wanted.Value
? "Luminance will now go straight to your bank, past your cap. Purchases draw from it."
: "Luminance will now accrue on your character again, up to your cap.");
// Said plainly rather than left to be discovered. Luminance already on the
// character is not swept up by turning this on, and somebody who assumes it was
// will find their old balance still sitting there and conclude the flag failed.
if (wanted.Value && (player.AvailableLuminance ?? 0) > 0)
Send(player, $"You are still carrying {player.AvailableLuminance:N0} luminance. " +
"/bank deposit luminance all moves it in.");
}
/// Accepts 12345, 12,345, 10k, 2.5m and 'all'.
private static bool TryParseAmount(string token, out long amount)
{
amount = 0;
if (string.IsNullOrWhiteSpace(token))
return false;
token = token.Trim().Replace(",", "").Replace("_", "");
if (token.Equals("all", StringComparison.OrdinalIgnoreCase) ||
token.Equals("max", StringComparison.OrdinalIgnoreCase) ||
token == "*")
{
amount = BankService.All;
return true;
}
long multiplier = 1;
var last = char.ToLowerInvariant(token[^1]);
if (last is 'k' or 'm' or 'b')
{
multiplier = last switch { 'k' => 1_000L, 'm' => 1_000_000L, _ => 1_000_000_000L };
token = token[..^1];
}
if (!decimal.TryParse(token, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var value))
return false;
var scaled = value * multiplier;
if (scaled < 0 || scaled > long.MaxValue)
return false;
amount = (long)scaled;
return amount > 0;
}
[CommandHandler("bankreload", AccessLevel.Admin, CommandHandlerFlag.None, 0,
"Re-read Aeshnidae.Bank Settings.json.", "/bankreload")]
public static void HandleReload(Session session, params string[] parameters)
{
var container = Mod.Container;
var msg = container is null ? $"{Mod.Name} is not loaded." : $"{Mod.Name} reloaded.";
container?.Restart();
if (session?.Player is not null)
Send(session.Player, msg);
else
ModManager.Log(msg);
}
///
/// The client renders an embedded newline as a music note, so a multi-line
/// message has to go out as one SendMessage per line.
///
private static void Send(Player player, string message)
{
foreach (var line in (message ?? "").Split('\n'))
{
var text = line.TrimEnd('\r');
if (!string.IsNullOrWhiteSpace(text))
player.SendMessage(text);
}
}
///
/// Short forms only. Every verb has long forms too, but a help screen that lists
/// both is twice as long and no clearer - the person who wants to type
/// /bank deposit will find it works without being told.
///
private static string HelpText() =>
"Bank - one balance shared by every character on your account.\n" +
" /b balances\n" +
" /b d deposit EVERYTHING - all pyreals, luminance and keys\n" +
" /b d
- [n] deposit one thing; no amount means all of it\n" +
" /b w
- [n] withdraw; no amount means all of it\n" +
" /b pay send Radiance or Resonance to another player\n" +
" /b autolum on|off earned luminance goes straight to the bank, past your cap\n" +
" /earned what you have earned lately, and per hour\n" +
"Items: p pyreals l luminance lk legendary keys sik sturdy iron keys\n" +
" rad radiance res resonance (earned, never carried)\n" +
"Amounts: 50, 10k, 2.5m, all. Keys bank as uses: a 25-use key is 25.\n" +
"Radiance mirrors experience from kills and quests; Resonance is 10 per quest.";
}