Files
2026-10-14 06:59:51 +02:00

86 lines
3.8 KiB
C#

namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Anti-farm throttle for the one currency-minting endpoint, POST /api/storefronts/v2/balance.
///
/// It answers Allow or Deny per award attempt, keyed on the player's LIVE room instance
/// (decided server-side from the heartbeat, never the request body):
///
/// * RATE LIMIT -> Deny. At most one award per player per BalanceRateLimitSeconds. A second
/// award that arrives sooner - e.g. the client glitching and firing twice within seconds -
/// simply doesn't go through. No currency, no ban: the player is not punished for a glitch.
///
/// * LONG SESSION -> Deny. A player parked in ONE instance past BalanceMaxInstanceMinutes, or
/// already awarded BalanceMaxAwardsPerInstance times in it, is refused further awards. The
/// counters reset the moment they move to a different room instance (legit players move;
/// farmers park).
///
/// All in-memory and self-pruning. No bans are issued here.
/// </summary>
public static class BalanceGuard
{
public enum Decision { Allow, Deny }
private sealed class State
{
public long InstanceId;
public DateTime FirstSeenUtc;
public DateTime LastAwardUtc;
public int Awards;
}
private static readonly object _lock = new();
private static readonly Dictionary<ulong, State> _state = new();
/// <summary>
/// Decide what to do with an award attempt BEFORE any currency is granted. On Allow the
/// attempt is recorded (advances the rate-limit clock and the per-instance total).
/// </summary>
public static (Decision decision, string reason) Evaluate(ulong playerId, long roomInstanceId)
{
if (!ConfigCampus.BalanceGuardEnabled)
return (Decision.Allow, "");
var now = DateTime.UtcNow;
lock (_lock)
{
// Fresh session whenever the player is in a different instance than last time
// (or we have never seen them) - this is what resets the long-session counters.
if (!_state.TryGetValue(playerId, out var st) || st.InstanceId != roomInstanceId)
{
st = new State { InstanceId = roomInstanceId, FirstSeenUtc = now, LastAwardUtc = DateTime.MinValue };
_state[playerId] = st;
}
// RATE LIMIT -> deny (the glitch-guard). Not an award, so nothing is recorded.
double sinceLast = (now - st.LastAwardUtc).TotalSeconds;
if (sinceLast < Math.Max(1, ConfigCampus.BalanceRateLimitSeconds))
{
return (Decision.Deny,
$"rate-limited: {sinceLast:F0}s since last award (< {ConfigCampus.BalanceRateLimitSeconds}s) in instance {roomInstanceId}");
}
// LONG SESSION -> deny.
double mins = (now - st.FirstSeenUtc).TotalMinutes;
if (mins >= ConfigCampus.BalanceMaxInstanceMinutes ||
st.Awards >= ConfigCampus.BalanceMaxAwardsPerInstance)
{
return (Decision.Deny,
$"parked in instance {roomInstanceId} for {mins:F0} min / {st.Awards} awards - denying");
}
// Allow: record the award.
st.LastAwardUtc = now;
st.Awards++;
return (Decision.Allow, "");
}
}
/// <summary>Forget a player's live session (e.g. on disconnect).</summary>
public static void ClearSession(ulong playerId)
{
lock (_lock) { _state.Remove(playerId); }
}
}
}