namespace Aeshnidae.Bank;
///
/// What each character has earned recently, for /earned.
///
/// Two questions are answered from the same ledger, and they want different shapes:
///
/// "How is this spot paying?" - the rolling windows, bucketed by the minute.
/// "How did tonight go?" - the session total, and the hourly rate it implies.
///
/// The windows are bucketed rather than logged because a player on a fast clearing loop
/// kills several things a second, and a list of individual awards would grow for as long
/// as they farmed. Sixty one-minute buckets per currency is a fixed size no matter how
/// hard anyone grinds, and asking for the last 30 minutes is a sum of 30 of them. The
/// cost is granularity: the oldest bucket in a window counts whole, so "the last 5
/// minutes" can span up to six minutes of wall clock. Invisible for reading an earn
/// rate, and it is what makes this cheap enough to update on every kill.
///
/// The session total is not bucketed at all - it is a running sum since login, so it
/// stays exact however long someone plays.
///
/// EARNED only. Radiance from kills and resonance from quests reach this; currency
/// received from another player does not, and Transfer deliberately does not call in
/// here. The question /earned answers is "what is this character producing", so money
/// somebody handed you would be a lie in it - two players could bounce the same balance
/// back and forth and both report a magnificent hourly rate.
///
/// In memory, and per session: logging out clears it. Restarting the server loses the
/// history, not the currency - balances are in the database and are unaffected.
///
public static class History
{
/// How many minute buckets are kept. The largest rolling window /earned reports.
public const int Minutes = 60;
///
/// Below this much play time an hourly projection is arithmetic rather than
/// information - thirty seconds of a good pull extrapolates to a number nobody is
/// going to make. /earned withholds the rate until a session is at least this old.
///
public static readonly TimeSpan MinimumForProjection = TimeSpan.FromMinutes(2);
///
/// The currencies /earned reports, in the order it prints them.
///
/// Luminance is ACE's own rather than one of ours, and it is here because it is
/// earned from exactly the same two activities and is the third number anyone
/// farming actually wants. It is tracked by watching what a player's balance really
/// changes by, so unlike the other two it can report zero while awards are being
/// made - which is precisely what you want to see when you are at the luminance cap
/// and every kill is paying you nothing.
///
public static readonly CurrencyKind[] Tracked =
{
CurrencyKind.Radiance,
CurrencyKind.Resonance,
CurrencyKind.Luminance,
};
private static int IndexOf(CurrencyKind kind) => Array.IndexOf(Tracked, kind);
private class Ledger
{
/// [currency, minute] -> earned in that minute.
private readonly long[,] _buckets = new long[Tracked.Length, Minutes];
/// Which absolute minute each slot currently holds, so stale slots read as zero.
private readonly long[] _stamp = new long[Minutes];
private readonly long[] _session = new long[Tracked.Length];
private readonly object _lock = new();
public DateTime SessionStart { get; private set; } = DateTime.UtcNow;
public long LastMinute { get; private set; }
public void Record(int currency, long amount, long minute)
{
lock (_lock)
{
var slot = (int)(((minute % Minutes) + Minutes) % Minutes);
// A slot still stamped with an older minute is a full lap behind, so its
// contents belong to an hour ago. EVERY currency in the slot is cleared,
// not just the one being written - resetting one would leave the others
// holding stale value in a slot that now claims to be current.
if (_stamp[slot] != minute)
{
_stamp[slot] = minute;
for (var c = 0; c < Tracked.Length; c++)
_buckets[c, slot] = 0;
}
_buckets[currency, slot] += amount;
_session[currency] += amount;
LastMinute = minute;
}
}
public long Sum(int currency, int windowMinutes, long now)
{
lock (_lock)
{
var oldest = now - windowMinutes + 1;
var total = 0L;
for (var i = 0; i < Minutes; i++)
{
if (_stamp[i] >= oldest && _stamp[i] <= now)
total += _buckets[currency, i];
}
return total;
}
}
public long SessionTotal(int currency)
{
lock (_lock)
return _session[currency];
}
public void Reset()
{
lock (_lock)
{
Array.Clear(_buckets);
Array.Clear(_stamp);
Array.Clear(_session);
// The clock restarts too. A reset that zeroed the totals but kept the
// original login time would report an hourly rate against hours that no
// longer have any earnings behind them, which reads as a collapse.
SessionStart = DateTime.UtcNow;
LastMinute = DateTime.UtcNow.Ticks / TimeSpan.TicksPerMinute;
}
}
}
private static readonly ConcurrentDictionary _ledgers = new();
private static long NowMinute => DateTime.UtcNow.Ticks / TimeSpan.TicksPerMinute;
///
/// Notes an award against the character who earned it.
///
/// Per CHARACTER, not per account, and that is the useful answer rather than the
/// consistent one: the balance is shared by every character on the account, but
/// "what am I earning out here" is a question about the one holding the sword.
///
public static void Record(Player player, CurrencyKind kind, long amount)
{
if (amount <= 0 || player is null)
return;
var currency = IndexOf(kind);
if (currency < 0)
return; // not a currency /earned reports
_ledgers.GetOrAdd(player.Guid.Full, _ => new Ledger()).Record(currency, amount, NowMinute);
}
/// Earned by this character over the last .
public static long Earned(Player player, CurrencyKind kind, int windowMinutes) =>
_ledgers.TryGetValue(player.Guid.Full, out var ledger) && IndexOf(kind) >= 0
? ledger.Sum(IndexOf(kind), windowMinutes, NowMinute)
: 0;
/// Earned by this character since login, or since the last /earned reset.
public static long Session(Player player, CurrencyKind kind) =>
_ledgers.TryGetValue(player.Guid.Full, out var ledger) && IndexOf(kind) >= 0
? ledger.SessionTotal(IndexOf(kind))
: 0;
/// How long the current session has been running.
public static TimeSpan SessionLength(Player player) =>
_ledgers.TryGetValue(player.Guid.Full, out var ledger)
? DateTime.UtcNow - ledger.SessionStart
: TimeSpan.Zero;
///
/// Session earnings projected out to an hour.
///
/// Deliberately measured against the WHOLE session rather than a recent window, so
/// it includes the walking, the repairs and the deaths. A rate taken from five good
/// minutes is a promise the next hour will not keep; a rate taken from two hours of
/// actual play is what you would really make doing that again.
///
/// Null until the session is old enough to mean anything.
///
public static double? PerHour(Player player, CurrencyKind kind)
{
var elapsed = SessionLength(player);
if (elapsed < MinimumForProjection)
return null;
return Session(player, kind) / elapsed.TotalHours;
}
/// Starts a fresh session. Login, and /earned reset.
public static void StartSession(Player player)
{
if (player is not null)
_ledgers.AddOrUpdate(player.Guid.Full, _ => new Ledger(), (_, existing) =>
{
existing.Reset();
return existing;
});
}
/// Forgets a character entirely. Logout.
public static void EndSession(Player player)
{
if (player is not null)
_ledgers.TryRemove(player.Guid.Full, out _);
}
///
/// Drops ledgers nothing has touched for a full hour.
///
/// A safety net rather than the main path - logout removes a ledger outright. This
/// catches the sessions that never got a clean logout, after a crash or a dropped
/// connection, and it is why it keys off activity rather than trusting the hook.
///
public static void Prune()
{
var cutoff = NowMinute - Minutes;
foreach (var (guid, ledger) in _ledgers)
{
// Never prune someone who is standing right there. An idle player with an
// hour-old ledger is still mid-session, and dropping it would restart their
// session clock under them.
if (ledger.LastMinute < cutoff && PlayerManager.GetOnlinePlayer(guid) is null)
_ledgers.TryRemove(guid, out _);
}
}
}