using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Globalization; using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Avalonia.Controls; using Avalonia.Layout; using Avalonia.Media; using Avalonia.Threading; namespace DDApplication; public partial class MainWindow : Window { private const int MapRadius = 10; private static readonly TimeSpan TickDelay = TimeSpan.FromMilliseconds(70); private static readonly IReadOnlyDictionary Cooldowns = new Dictionary { [BotAction.Move] = TimeSpan.FromMilliseconds(250), [BotAction.Hit] = TimeSpan.FromMilliseconds(250), [BotAction.Radar] = TimeSpan.FromMilliseconds(250), [BotAction.Shoot] = TimeSpan.FromMilliseconds(1000), [BotAction.Peek] = TimeSpan.FromMilliseconds(1000), [BotAction.Scan] = TimeSpan.FromMilliseconds(2000), [BotAction.Dash] = TimeSpan.FromMilliseconds(5000), [BotAction.Special] = TimeSpan.FromMilliseconds(5000), [BotAction.Teleport] = TimeSpan.FromMilliseconds(20000), }; private readonly string _configuredApiKey = Program.Configuration["APIKEY"] ?? string.Empty; private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(8) }; private readonly DigitalDojoApiClient _client; private readonly ObservableCollection _logEntries = []; private readonly Dictionary<(int X, int Y), double> _enemyHeat = []; private readonly Dictionary _lastRadar = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _lastPeek = []; private readonly Dictionary _lastUse = []; private readonly Dictionary _blockedDirectionTicks = []; private readonly Queue _recentDeaths = new(); private readonly TextBlock[,] _mapCells = new TextBlock[MapRadius * 2 + 1, MapRadius * 2 + 1]; private readonly Random _random = new(); private CancellationTokenSource? _runCts; private Task? _runTask; private BotSessionLogger? _sessionLogger; private Guid _playerToken; private Guid? _gameToken; private bool _botRunning; private int _x; private int _y; private int _peekDirectionIndex; private int _kills; private int _deaths; private double _currentHealth; private double _maxHealth; private double _progress; private double _remainingTime; private string _levelName = "-"; private DateTimeOffset _lastStatsAt = DateTimeOffset.MinValue; private DateTimeOffset _lastStatusAt = DateTimeOffset.MinValue; private (int X, int Y)? _nearestEnemy; private Direction _lastMoveDirection = Direction.North; private int _recentDamageTicks; private int _survivalTicks; private bool _lastUnderPressure; public MainWindow() { _client = new DigitalDojoApiClient("https://game-dd.countit.at", _httpClient); InitializeComponent(); BuildKnowledgeGrid(); ApiKeyBox.Text = _configuredApiKey; LogList.ItemsSource = _logEntries; StartButton.Click += async (_, _) => await StartBotAsync(); StopButton.Click += async (_, _) => await StopBotAsync(closeGame: true); RenderTelemetry("Waiting to start."); } protected override async void OnClosed(EventArgs e) { await StopBotAsync(closeGame: false); _httpClient.Dispose(); base.OnClosed(e); } private async Task StartBotAsync() { if (_botRunning) { return; } if (!Guid.TryParse(ApiKeyBox.Text, out var parsedToken)) { SetStatus("Invalid API key GUID."); return; } _playerToken = parsedToken; _runCts = new CancellationTokenSource(); _botRunning = true; _sessionLogger = new BotSessionLogger(); _sessionLogger.LogEvent("session-start", "Bot session created"); StartButton.IsEnabled = false; StopButton.IsEnabled = true; ApiKeyBox.IsEnabled = false; ResetKnowledgeState(); var started = await EnsureGameStartedAsync(_runCts.Token); if (!started) { await StopBotAsync(closeGame: false); return; } AppendLog($"Logging to {_sessionLogger.EventsPath}"); SetStatus("Bot running."); _runTask = Task.Run(() => BotLoopAsync(_runCts.Token)); } private async Task StopBotAsync(bool closeGame) { if (_runCts is not null) { _runCts.Cancel(); } if (_runTask is not null) { try { await _runTask; } catch (OperationCanceledException) { // Normal shutdown path. } } _runTask = null; _runCts?.Dispose(); _runCts = null; if (closeGame && _playerToken != Guid.Empty) { try { await _client.CloseAsync(_playerToken); AppendLog("Game closed."); } catch (Exception ex) { AppendLog($"Close failed: {ex.Message}"); } } if (_sessionLogger is not null) { _sessionLogger.LogEvent("session-stop", "Bot session stopping"); _sessionLogger.WriteSummaryAndDispose(_currentHealth, _maxHealth, _kills, _deaths); AppendLog($"Summary CSV written: {_sessionLogger.SummaryCsvPath}"); _sessionLogger = null; } _botRunning = false; StartButton.IsEnabled = true; StopButton.IsEnabled = false; ApiKeyBox.IsEnabled = true; SetStatus("Stopped."); } private async Task EnsureGameStartedAsync(CancellationToken ct) { try { var status = await _client.StatusAsync(_playerToken, ct); if (status.Running) { _gameToken = Guid.TryParse(status.Gameid, out var existingGameId) ? existingGameId : null; _levelName = status.Level ?? "-"; AppendLog("Reusing running game."); return true; } } catch (ApiException) { // No game running yet. } catch (Exception ex) { AppendLog($"Status check failed: {ex.Message}"); return false; } try { var created = await _client.CreateAsync(_playerToken, ct); _gameToken = Guid.TryParse(created.Gameid, out var gameId) ? gameId : null; _levelName = created.Level ?? "-"; AppendLog($"Game created ({_gameToken?.ToString() ?? "unknown"})."); return true; } catch (Exception ex) { AppendLog($"Create failed: {ex.Message}"); return false; } } private async Task BotLoopAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { try { if (_recentDamageTicks > 0) { _recentDamageTicks--; } if (_survivalTicks > 0) { _survivalTicks--; } DecayBlockedDirections(); await PollTelemetryAsync(ct); DecayHeat(); await GatherSensorsAsync(ct); var decision = BuildDecision(); if (decision is not null) { var executed = await decision.Value.Execute(ct); _sessionLogger?.LogDecision( decision.Value.Label, executed, _currentHealth, _maxHealth, _kills, _deaths, _x, _y, _lastUnderPressure, decision.Value.Reason); if (executed) { AppendLog($"{decision.Value.Label}: {decision.Value.Reason}"); } } RenderTelemetry(decision?.Reason ?? "Scanning..."); RenderKnowledgeGrid(); } catch (OperationCanceledException) { break; } catch (Exception ex) { AppendLog($"Loop error: {ex.Message}"); await Task.Delay(300, ct); } await Task.Delay(TickDelay, ct); } } private async Task PollTelemetryAsync(CancellationToken ct) { var now = DateTimeOffset.UtcNow; if (now - _lastStatsAt >= TimeSpan.FromMilliseconds(650)) { _lastStatsAt = now; try { var stats = await _client.StatsAsync(_playerToken, ct); var previousHealth = _currentHealth; var previousDeaths = _deaths; _kills = stats.Stats?.Kills ?? _kills; _deaths = stats.Stats?.Deaths ?? _deaths; _currentHealth = stats.Health?.Currenthealth ?? _currentHealth; _maxHealth = stats.Health?.Maxhealth ?? _maxHealth; _progress = stats.Level?.Progress ?? _progress; _remainingTime = stats.Level?.Remainingtime ?? _remainingTime; _levelName = stats.Level?.Name ?? _levelName; if (previousHealth > 0.1 && _currentHealth + 0.05 < previousHealth) { _recentDamageTicks = 20; } if (_deaths > previousDeaths) { var nowDeath = DateTimeOffset.UtcNow; _recentDeaths.Enqueue(nowDeath); while (_recentDeaths.Count > 0 && nowDeath - _recentDeaths.Peek() > TimeSpan.FromSeconds(90)) { _recentDeaths.Dequeue(); } _survivalTicks = Math.Max(_survivalTicks, 180 + _recentDeaths.Count * 30); _x = 0; _y = 0; _enemyHeat.Clear(); _nearestEnemy = null; _sessionLogger?.LogEvent("death", $"death_count={_deaths};death_window_90s={_recentDeaths.Count}"); } _sessionLogger?.LogTelemetry( _currentHealth, _maxHealth, _kills, _deaths, _progress, _remainingTime, _levelName, _x, _y, $"survival_ticks={_survivalTicks};damage_ticks={_recentDamageTicks}"); } catch (Exception ex) { AppendLog($"Stats failed: {ex.Message}"); } } if (now - _lastStatusAt >= TimeSpan.FromSeconds(2)) { _lastStatusAt = now; try { var status = await _client.StatusAsync(_playerToken, ct); _levelName = status.Level ?? _levelName; if (_gameToken is null && Guid.TryParse(status.Gameid, out var gameId)) { _gameToken = gameId; } } catch { // Status occasionally fails while game is rotating; ignore. } } } private async Task GatherSensorsAsync(CancellationToken ct) { if (Ready(BotAction.Radar)) { try { var radar = await _client.RadarAsync(_playerToken, ct); MarkUsed(BotAction.Radar); _lastRadar.Clear(); foreach (var kvp in radar.RadarResults ?? new Dictionary()) { _lastRadar[kvp.Key] = kvp.Value; } ApplyRadarHeat(); } catch (Exception ex) { AppendLog($"Radar failed: {ex.Message}"); } } if (Ready(BotAction.Scan)) { try { var scan = await _client.ScanAsync(_playerToken, ct); MarkUsed(BotAction.Scan); var sx = scan.DifferenceToNearestPlayer?.X; var sy = scan.DifferenceToNearestPlayer?.Y; _nearestEnemy = sx.HasValue && sy.HasValue ? (sx.Value, sy.Value) : null; if (_nearestEnemy.HasValue) { AddHeat(_x + _nearestEnemy.Value.X, _y + _nearestEnemy.Value.Y, 5.5); } } catch (Exception ex) { AppendLog($"Scan failed: {ex.Message}"); } } if (Ready(BotAction.Peek)) { var dir = (Direction)(_peekDirectionIndex % 4); _peekDirectionIndex++; try { var peek = await _client.PeekAsync(_playerToken, (int)dir, ct); MarkUsed(BotAction.Peek); _lastPeek[dir] = peek; ApplyPeekHeat(dir, peek); } catch (Exception ex) { AppendLog($"Peek {dir} failed: {ex.Message}"); } } } private BotDecision? BuildDecision() { var hpRatio = _maxHealth > 0.1 ? _currentHealth / _maxHealth : 1.0; var immediateDir = _lastPeek .Where(x => x.Value.Executed && x.Value.PlayersInSight > 0 && x.Value.SightedPlayerDistance == 1) .Select(x => (Direction?)x.Key) .FirstOrDefault(); var closeThreats = _lastPeek.Values.Count(x => x.Executed && x.PlayersInSight > 0 && (x.SightedPlayerDistance ?? 99) <= 2); if (_nearestEnemy.HasValue && Math.Abs(_nearestEnemy.Value.X) + Math.Abs(_nearestEnemy.Value.Y) <= 2) { closeThreats++; } var underPressure = hpRatio < 0.58 || _recentDamageTicks > 0 || closeThreats >= 2 || _survivalTicks > 0; _lastUnderPressure = underPressure; if (Ready(BotAction.Special) && closeThreats >= 2) { return new BotDecision( "specialattack", "Multiple enemies close; AoE yields best kill rate.", async ct => { var response = await _client.SpecialattackAsync(_playerToken, ct); MarkUsed(BotAction.Special); return response.Executed; }); } if (immediateDir.HasValue && !underPressure && Ready(BotAction.Hit)) { var dir = immediateDir.Value; return new BotDecision( "hit", $"Adjacent enemy at {dir}; highest DPS action.", async ct => { var response = await _client.HitAsync(_playerToken, (int)dir, ct); MarkUsed(BotAction.Hit); return response.Executed; }); } if (immediateDir.HasValue && Ready(BotAction.Move)) { var retreatDirection = Opposite(immediateDir.Value); if (Ready(BotAction.Dash) && (hpRatio < 0.40 || _survivalTicks > 0)) { return new BotDecision( "dash-retreat", $"High pressure escape via dash -> {retreatDirection}.", async ct => { var response = await _client.DashAsync(_playerToken, (int)retreatDirection, ct); MarkUsed(BotAction.Dash); if (response.Executed) { ApplyMovement(retreatDirection, Math.Max(response.BlocksDashed, 1)); } return response.Executed; }); } return new BotDecision( "move-retreat", $"Adjacent enemy and pressure high; kite to {retreatDirection}.", async ct => { var response = await _client.MoveAsync(_playerToken, (int)retreatDirection, ct); MarkUsed(BotAction.Move); var moved = response.Executed && response.Move == true; RegisterMoveResult(retreatDirection, moved); if (moved) { ApplyMovement(retreatDirection, 1); } return response.Executed; }); } if (immediateDir.HasValue && Ready(BotAction.Hit)) { var dir = immediateDir.Value; return new BotDecision( "hit-last-stand", $"Adjacent enemy at {dir}; strike while move cooldown is active.", async ct => { var response = await _client.HitAsync(_playerToken, (int)dir, ct); MarkUsed(BotAction.Hit); return response.Executed; }); } var bestSight = _lastPeek .Where(x => x.Value.Executed && x.Value.PlayersInSight > 0) .OrderBy(x => x.Value.SightedPlayerDistance ?? int.MaxValue) .FirstOrDefault(); if (bestSight.Value is not null && Ready(BotAction.Shoot)) { var dir = bestSight.Key; var dist = bestSight.Value.SightedPlayerDistance ?? 99; if (!underPressure || dist >= 2) { return new BotDecision( "shoot", $"Enemy in line of sight to {dir}; ranged hit before moving.", async ct => { var response = await _client.ShootAsync(_playerToken, (int)dir, ct); MarkUsed(BotAction.Shoot); return response.Executed; }); } } var moveDirection = GetBestMoveDirection(chase: !underPressure); if (Ready(BotAction.Move)) { var reason = underPressure ? $"Pressure high; reposition toward safer lane -> {moveDirection}." : $"Controlled hunt path -> {moveDirection}."; return new BotDecision( "move", reason, async ct => { var response = await _client.MoveAsync(_playerToken, (int)moveDirection, ct); MarkUsed(BotAction.Move); var moved = response.Executed && response.Move == true; RegisterMoveResult(moveDirection, moved); if (moved) { ApplyMovement(moveDirection, 1); } return response.Executed; }); } return null; } private Direction GetBestMoveDirection(bool chase) { var scored = new Dictionary { [Direction.North] = RadarScore(Direction.North), [Direction.East] = RadarScore(Direction.East), [Direction.South] = RadarScore(Direction.South), [Direction.West] = RadarScore(Direction.West), }; foreach (var dir in scored.Keys.ToArray()) { var score = 0.0; score += chase ? scored[dir] * 1.35 : -scored[dir] * 1.0; if (_lastPeek.TryGetValue(dir, out var peek) && peek.Executed) { if (peek.PlayersInSight > 0) { var dist = Math.Max(1, peek.SightedPlayerDistance ?? 6); score += chase ? 7.0 / dist + peek.PlayersInSight * 0.8 : -10.0 / dist - peek.PlayersInSight * 0.7; } else if (!chase) { score += 0.9; } } if (_nearestEnemy.HasValue) { var targetDir = DirectionFromVector(_nearestEnemy.Value.X, _nearestEnemy.Value.Y); var awayDir = Opposite(targetDir); if (chase && dir == targetDir) { score += 4.0; } else if (!chase && dir == awayDir) { score += 4.5; } else if (!chase && dir == targetDir) { score -= 3.5; } } if (_survivalTicks > 0 && _nearestEnemy.HasValue) { var safest = Opposite(DirectionFromVector(_nearestEnemy.Value.X, _nearestEnemy.Value.Y)); if (dir == safest) { score += 2.8; } } if (_blockedDirectionTicks.TryGetValue(dir, out var blockedTicks) && blockedTicks > 0) { score -= 5.0 + blockedTicks * 0.4; } if (dir == Opposite(_lastMoveDirection)) { score -= 0.6; } score += _survivalTicks > 0 ? _random.NextDouble() * 0.2 : _random.NextDouble() * 0.7; scored[dir] = score; } return scored.OrderByDescending(x => x.Value).First().Key; } private void ApplyRadarHeat() { var north = RadarScore(Direction.North); var east = RadarScore(Direction.East); var south = RadarScore(Direction.South); var west = RadarScore(Direction.West); ApplyDirectionalHeat(Direction.North, north, 0.35); ApplyDirectionalHeat(Direction.East, east, 0.35); ApplyDirectionalHeat(Direction.South, south, 0.35); ApplyDirectionalHeat(Direction.West, west, 0.35); } private void ApplyPeekHeat(Direction dir, PeekEntitiesAsyncResponse peek) { if (!peek.Executed || peek.PlayersInSight <= 0) { return; } var (dx, dy) = Delta(dir); var sightDistance = peek.SightedPlayerDistance ?? 8; var maxTrace = Math.Clamp(sightDistance, 1, 10); for (var i = 1; i <= maxTrace; i++) { var certainty = i == sightDistance ? 5.0 : 0.6; AddHeat(_x + dx * i, _y + dy * i, certainty + peek.PlayersInSight * 0.3); } } private void ApplyDirectionalHeat(Direction dir, int count, double factor) { if (count <= 0) { return; } var (dx, dy) = Delta(dir); for (var i = 1; i <= 8; i++) { AddHeat(_x + dx * i, _y + dy * i, count * factor * (9 - i)); } } private void DecayHeat() { if (_enemyHeat.Count == 0) { return; } var keys = _enemyHeat.Keys.ToArray(); foreach (var key in keys) { _enemyHeat[key] *= 0.92; if (_enemyHeat[key] < 0.2) { _enemyHeat.Remove(key); } } } private void ApplyMovement(Direction direction, int steps) { var (dx, dy) = Delta(direction); _x += dx * steps; _y += dy * steps; AddHeat(_x, _y, -2.5); } private void AddHeat(int worldX, int worldY, double value) { var key = (worldX, worldY); _enemyHeat.TryGetValue(key, out var current); _enemyHeat[key] = Math.Max(0.0, current + value); } private int RadarScore(Direction direction) { var keys = direction switch { Direction.North => new[] { "north", "n", "up", "0" }, Direction.East => new[] { "east", "e", "right", "1" }, Direction.South => new[] { "south", "s", "down", "2" }, Direction.West => new[] { "west", "w", "left", "3" }, _ => Array.Empty(), }; var score = 0; foreach (var key in keys) { if (_lastRadar.TryGetValue(key, out var value)) { score = Math.Max(score, value); } } return score; } private static Direction DirectionFromVector(int x, int y) { if (Math.Abs(x) >= Math.Abs(y)) { return x >= 0 ? Direction.East : Direction.West; } return y >= 0 ? Direction.South : Direction.North; } private static (int dx, int dy) Delta(Direction direction) { return direction switch { Direction.North => (0, -1), Direction.East => (1, 0), Direction.South => (0, 1), Direction.West => (-1, 0), _ => (0, 0), }; } private bool Ready(BotAction action) { if (!_lastUse.TryGetValue(action, out var time)) { return true; } return DateTimeOffset.UtcNow - time >= Cooldowns[action]; } private void MarkUsed(BotAction action) { _lastUse[action] = DateTimeOffset.UtcNow; } private void ResetKnowledgeState() { _x = 0; _y = 0; _kills = 0; _deaths = 0; _currentHealth = 0; _maxHealth = 10; _progress = 0; _remainingTime = 0; _peekDirectionIndex = 0; _gameToken = null; _nearestEnemy = null; _recentDamageTicks = 0; _survivalTicks = 0; _lastUnderPressure = false; _lastMoveDirection = Direction.North; _enemyHeat.Clear(); _lastRadar.Clear(); _lastPeek.Clear(); _lastUse.Clear(); _blockedDirectionTicks.Clear(); _recentDeaths.Clear(); _logEntries.Clear(); RenderKnowledgeGrid(); } private void RenderTelemetry(string decisionReason) { Dispatcher.UIThread.Post(() => { var hpPercent = _maxHealth <= 0.01 ? 0 : (_currentHealth / _maxHealth) * 100.0; var kdr = (_deaths == 0 ? _kills : (double)_kills / _deaths).ToString("0.00", CultureInfo.InvariantCulture); StatsText.Text = $"Level: {_levelName} | Kills: {_kills} | Deaths: {_deaths} | K/D: {kdr} | " + $"HP: {_currentHealth.ToString("0.0", CultureInfo.InvariantCulture)}/{_maxHealth.ToString("0.0", CultureInfo.InvariantCulture)} ({hpPercent.ToString("0", CultureInfo.InvariantCulture)}%) | " + $"Progress: {_progress.ToString("0.0", CultureInfo.InvariantCulture)}% | Remaining: {_remainingTime.ToString("0.00", CultureInfo.InvariantCulture)} min | " + $"Pos (estimated): ({_x}, {_y}) | SurvivalMode: {(_survivalTicks > 0 ? "ON" : "OFF")}"; var nearest = _nearestEnemy.HasValue ? $"({_nearestEnemy.Value.X}, {_nearestEnemy.Value.Y})" : "none"; var radar = _lastRadar.Count == 0 ? "none" : string.Join(", ", _lastRadar.OrderBy(x => x.Key).Select(x => $"{x.Key}:{x.Value}")); DecisionText.Text = $"Reasoning: {decisionReason}\n" + $"Nearest from scan (relative): {nearest}\n" + $"Radar: {radar}"; CooldownText.Text = string.Join( Environment.NewLine, Cooldowns .OrderBy(x => x.Key.ToString()) .Select(x => $"{x.Key,-8} {RemainingText(x.Key)}")); }); } private string RemainingText(BotAction action) { if (!_lastUse.TryGetValue(action, out var last)) { return "ready"; } var remaining = Cooldowns[action] - (DateTimeOffset.UtcNow - last); if (remaining <= TimeSpan.Zero) { return "ready"; } return $"{remaining.TotalMilliseconds.ToString("0", CultureInfo.InvariantCulture)} ms"; } private void SetStatus(string text) { Dispatcher.UIThread.Post(() => StatusText.Text = text); } private void AppendLog(string message) { Dispatcher.UIThread.Post(() => { var stamp = DateTime.Now.ToString("HH:mm:ss", CultureInfo.InvariantCulture); _logEntries.Insert(0, $"[{stamp}] {message}"); while (_logEntries.Count > 250) { _logEntries.RemoveAt(_logEntries.Count - 1); } }); } private void BuildKnowledgeGrid() { for (var row = 0; row < _mapCells.GetLength(0); row++) { for (var col = 0; col < _mapCells.GetLength(1); col++) { var block = new TextBlock { Text = " ", Width = 18, Height = 18, TextAlignment = TextAlignment.Center, VerticalAlignment = VerticalAlignment.Center, HorizontalAlignment = HorizontalAlignment.Center, FontSize = 11, FontWeight = FontWeight.SemiBold }; _mapCells[row, col] = block; KnowledgeGrid.Children.Add(block); } } } private void RenderKnowledgeGrid() { Dispatcher.UIThread.Post(() => { for (var row = 0; row < _mapCells.GetLength(0); row++) { for (var col = 0; col < _mapCells.GetLength(1); col++) { var worldX = _x + (col - MapRadius); var worldY = _y + (row - MapRadius); var cell = _mapCells[row, col]; var symbol = " "; var foreground = Brushes.Gray; if (worldX == _x && worldY == _y) { symbol = "P"; foreground = Brushes.DodgerBlue; } else if (_nearestEnemy.HasValue && worldX == _x + _nearestEnemy.Value.X && worldY == _y + _nearestEnemy.Value.Y) { symbol = "N"; foreground = Brushes.OrangeRed; } else if (_enemyHeat.TryGetValue((worldX, worldY), out var heat)) { if (heat >= 5) { symbol = "*"; foreground = Brushes.OrangeRed; } else if (heat >= 2) { symbol = "+"; foreground = Brushes.Gold; } else { symbol = "."; foreground = Brushes.LightGray; } } cell.Text = symbol; cell.Foreground = foreground; } } }); } private readonly record struct BotDecision( string Label, string Reason, Func> Execute); private enum Direction { North = 0, East = 1, South = 2, West = 3, } private enum BotAction { Move, Hit, Radar, Shoot, Peek, Scan, Dash, Special, Teleport, } private static Direction Opposite(Direction direction) { return direction switch { Direction.North => Direction.South, Direction.East => Direction.West, Direction.South => Direction.North, Direction.West => Direction.East, _ => Direction.North, }; } private void RegisterMoveResult(Direction direction, bool moved) { _lastMoveDirection = direction; if (moved) { _blockedDirectionTicks.Remove(direction); return; } _blockedDirectionTicks.TryGetValue(direction, out var current); _blockedDirectionTicks[direction] = Math.Min(current + 4, 20); } private void DecayBlockedDirections() { foreach (var key in _blockedDirectionTicks.Keys.ToArray()) { _blockedDirectionTicks[key]--; if (_blockedDirectionTicks[key] <= 0) { _blockedDirectionTicks.Remove(key); } } } }