1375 lines
45 KiB
C#
1375 lines
45 KiB
C#
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<BotAction, TimeSpan> Cooldowns = new Dictionary<BotAction, TimeSpan>
|
|
{
|
|
[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<string> _logEntries = [];
|
|
private readonly Dictionary<(int X, int Y), double> _enemyHeat = [];
|
|
private readonly Dictionary<(int X, int Y), double> _visitHeat = [];
|
|
private readonly Dictionary<(int X, int Y), double> _deathHeat = [];
|
|
private readonly Dictionary<string, int> _lastRadar = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly Dictionary<Direction, PeekEntitiesAsyncResponse> _lastPeek = [];
|
|
private readonly Dictionary<BotAction, DateTimeOffset> _lastUse = [];
|
|
private readonly Dictionary<Direction, int> _blockedDirectionTicks = [];
|
|
private readonly Queue<DateTimeOffset> _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 (int X, int Y)? _lastObservedEnemyRelative;
|
|
private (int X, int Y)? _enemyVelocityEstimate;
|
|
private Direction _lastMoveDirection = Direction.North;
|
|
private Direction _escapeDirection = Direction.North;
|
|
private int _recentDamageTicks;
|
|
private int _survivalTicks;
|
|
private int _escapeLockTicks;
|
|
private bool _lastUnderPressure;
|
|
private int _pressureStreak;
|
|
private LevelProfile _currentLevelProfile = LevelProfile.Unknown;
|
|
|
|
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<bool> 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<ErrorResponse>)
|
|
{
|
|
// 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--;
|
|
}
|
|
|
|
if (_escapeLockTicks > 0)
|
|
{
|
|
_escapeLockTicks--;
|
|
}
|
|
|
|
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;
|
|
UpdateLevelProfile(_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();
|
|
}
|
|
|
|
// Minimal survival mode - deaths don't matter, only kills
|
|
_survivalTicks = Math.Max(_survivalTicks, 4);
|
|
_escapeLockTicks = Math.Max(_escapeLockTicks, 2);
|
|
_escapeDirection = Opposite(_lastMoveDirection);
|
|
_pressureStreak = 0;
|
|
_x = 0;
|
|
_y = 0;
|
|
_enemyHeat.Clear();
|
|
_nearestEnemy = null;
|
|
MarkDeathZone(_x, _y);
|
|
_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};escape_lock={_escapeLockTicks};pressure_streak={_pressureStreak}");
|
|
}
|
|
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<string, int>())
|
|
{
|
|
_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 previousEnemy = _nearestEnemy;
|
|
var sx = scan.DifferenceToNearestPlayer?.X;
|
|
var sy = scan.DifferenceToNearestPlayer?.Y;
|
|
_nearestEnemy = sx.HasValue && sy.HasValue ? (sx.Value, sy.Value) : null;
|
|
if (_nearestEnemy.HasValue)
|
|
{
|
|
if (previousEnemy.HasValue)
|
|
{
|
|
_enemyVelocityEstimate = (
|
|
_nearestEnemy.Value.X - previousEnemy.Value.X,
|
|
_nearestEnemy.Value.Y - previousEnemy.Value.Y);
|
|
}
|
|
|
|
_lastObservedEnemyRelative = _nearestEnemy;
|
|
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 profile = _currentLevelProfile;
|
|
|
|
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++;
|
|
}
|
|
|
|
if (hpRatio < 0.75 || _recentDamageTicks > 0 || closeThreats >= 2)
|
|
{
|
|
_pressureStreak = Math.Min(_pressureStreak + 1, 12);
|
|
}
|
|
else
|
|
{
|
|
_pressureStreak = Math.Max(_pressureStreak - 1, 0);
|
|
}
|
|
|
|
var underPressure = hpRatio < PressureHpThreshold(profile) || _recentDamageTicks > 0 || closeThreats >= PressureThreatThreshold(profile) || _survivalTicks > 0 || _pressureStreak >= 2;
|
|
_lastUnderPressure = underPressure;
|
|
var predictedEnemy = PredictEnemyRelative();
|
|
|
|
var bestSight = _lastPeek
|
|
.Where(x => x.Value.Executed && x.Value.PlayersInSight > 0)
|
|
.OrderBy(x => x.Value.SightedPlayerDistance ?? int.MaxValue)
|
|
.FirstOrDefault();
|
|
|
|
if (Ready(BotAction.Hit) && immediateDir.HasValue && closeThreats >= 1)
|
|
{
|
|
var dir = immediateDir.Value;
|
|
return new BotDecision(
|
|
"hit-lock",
|
|
$"Enemy committed in close range at {dir}; preemptive melee pressure.",
|
|
async ct =>
|
|
{
|
|
var response = await _client.HitAsync(_playerToken, (int)dir, ct);
|
|
MarkUsed(BotAction.Hit);
|
|
return response.Executed;
|
|
});
|
|
}
|
|
|
|
// Teleport if we can and enemies are nearby - aggressive repositioning for kills
|
|
if (Ready(BotAction.Teleport) && closeThreats >= 1 && _nearestEnemy.HasValue)
|
|
{
|
|
var targetX = _nearestEnemy.Value.X;
|
|
var targetY = _nearestEnemy.Value.Y;
|
|
// Teleport closer to enemy for aggressive positioning
|
|
var moveTowardX = targetX > 0 ? targetX - 1 : targetX + 1;
|
|
var moveTowardY = targetY > 0 ? targetY - 1 : targetY + 1;
|
|
|
|
return new BotDecision(
|
|
"teleport-aggro",
|
|
"Close enemy; teleport for aggressive positioning.",
|
|
async ct =>
|
|
{
|
|
var response = await _client.TeleportAsync(_playerToken, moveTowardX, moveTowardY, ct);
|
|
MarkUsed(BotAction.Teleport);
|
|
if (response.Executed)
|
|
{
|
|
_x = moveTowardX;
|
|
_y = moveTowardY;
|
|
}
|
|
return response.Executed;
|
|
});
|
|
}
|
|
|
|
if (Ready(BotAction.Dash) && TryGetAggressiveDashDirection(predictedEnemy, bestSight, out var dashDirection, out var dashReason))
|
|
{
|
|
return new BotDecision(
|
|
"dash-aggro",
|
|
dashReason,
|
|
async ct =>
|
|
{
|
|
var response = await _client.DashAsync(_playerToken, (int)dashDirection, ct);
|
|
MarkUsed(BotAction.Dash);
|
|
if (response.Executed)
|
|
{
|
|
ApplyMovement(dashDirection, Math.Max(response.BlocksDashed, 1));
|
|
}
|
|
|
|
return response.Executed;
|
|
});
|
|
}
|
|
|
|
var specialThreatThreshold = SpecialThreatThreshold(profile);
|
|
var specialHpFloor = SpecialHpFloor(profile);
|
|
|
|
if (Ready(BotAction.Special) && closeThreats >= specialThreatThreshold && (hpRatio >= specialHpFloor || closeThreats > specialThreatThreshold))
|
|
{
|
|
return new BotDecision(
|
|
"specialattack",
|
|
$"Multiple enemies ({closeThreats}) close; AoE for maximum kills.",
|
|
async ct =>
|
|
{
|
|
var response = await _client.SpecialattackAsync(_playerToken, ct);
|
|
MarkUsed(BotAction.Special);
|
|
return response.Executed;
|
|
});
|
|
}
|
|
|
|
if (immediateDir.HasValue && Ready(BotAction.Hit) && ShouldUseImmediateHit(profile, hpRatio, closeThreats, underPressure))
|
|
{
|
|
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) && ShouldRetreatOnImmediate(profile, hpRatio, closeThreats))
|
|
{
|
|
var retreatDirection = Opposite(immediateDir.Value);
|
|
|
|
if (Ready(BotAction.Dash) && (hpRatio < DashHpThreshold(profile) || _pressureStreak >= 3 || _survivalTicks > 0))
|
|
{
|
|
return new BotDecision(
|
|
"dash-retreat",
|
|
$"Critical HP 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));
|
|
_escapeDirection = retreatDirection;
|
|
_escapeLockTicks = Math.Max(_escapeLockTicks, EscapeLockTicks(profile));
|
|
}
|
|
|
|
return response.Executed;
|
|
});
|
|
}
|
|
|
|
return new BotDecision(
|
|
"move-retreat",
|
|
$"Retreating from {immediateDir.Value} -> {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);
|
|
_escapeDirection = retreatDirection;
|
|
_escapeLockTicks = Math.Max(_escapeLockTicks, EscapeLockTicks(profile));
|
|
}
|
|
|
|
return response.Executed;
|
|
});
|
|
}
|
|
|
|
if (immediateDir.HasValue && Ready(BotAction.Hit))
|
|
{
|
|
var dir = immediateDir.Value;
|
|
return new BotDecision(
|
|
"hit-last-stand",
|
|
$"Adjacent enemy at {dir}; aggressive strike.",
|
|
async ct =>
|
|
{
|
|
var response = await _client.HitAsync(_playerToken, (int)dir, ct);
|
|
MarkUsed(BotAction.Hit);
|
|
return response.Executed;
|
|
});
|
|
}
|
|
|
|
if (Ready(BotAction.Shoot) && TryGetPredictiveShotDirection(bestSight.Key, bestSight.Value, predictedEnemy, out var shotDirection, out var shotReason))
|
|
{
|
|
return new BotDecision(
|
|
"shoot",
|
|
shotReason,
|
|
async ct =>
|
|
{
|
|
var response = await _client.ShootAsync(_playerToken, (int)shotDirection, ct);
|
|
MarkUsed(BotAction.Shoot);
|
|
return response.Executed;
|
|
});
|
|
}
|
|
|
|
var moveDirection = GetBestMoveDirection(chase: true);
|
|
if (Ready(BotAction.Move))
|
|
{
|
|
return new BotDecision(
|
|
"move",
|
|
$"Aggressive hunt -> {moveDirection}.",
|
|
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)
|
|
{
|
|
if (!chase && _escapeLockTicks > 0)
|
|
{
|
|
if (!_blockedDirectionTicks.TryGetValue(_escapeDirection, out var blocked) || blocked <= 0)
|
|
{
|
|
return _escapeDirection;
|
|
}
|
|
}
|
|
|
|
var scored = new Dictionary<Direction, double>
|
|
{
|
|
[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 += scored[dir] * 0.5;
|
|
|
|
if (_lastPeek.TryGetValue(dir, out var peek) && peek.Executed)
|
|
{
|
|
if (peek.PlayersInSight > 0)
|
|
{
|
|
var dist = Math.Max(1, peek.SightedPlayerDistance ?? 6);
|
|
// Aggressive scoring: high bonus for chasing visible enemies
|
|
score += 15.0 / dist + peek.PlayersInSight * 3.0;
|
|
}
|
|
}
|
|
|
|
if (_nearestEnemy.HasValue)
|
|
{
|
|
var targetVector = PredictEnemyRelative() ?? _nearestEnemy.Value;
|
|
var targetDir = DirectionFromVector(targetVector.X, targetVector.Y);
|
|
if (dir == targetDir)
|
|
{
|
|
score += 12.0;
|
|
}
|
|
else if (dir == Opposite(targetDir))
|
|
{
|
|
score -= 8.0;
|
|
}
|
|
}
|
|
|
|
// Reduce penalties for visiting/death zones - we're aggressive
|
|
var visitPenalty = VisitHeatFor(dir);
|
|
var deathPenalty = DeathHeatFor(dir);
|
|
|
|
score -= visitPenalty * 0.2;
|
|
score -= deathPenalty * 0.3;
|
|
|
|
if (_escapeLockTicks > 0)
|
|
{
|
|
if (dir == _escapeDirection)
|
|
{
|
|
score += 2.0;
|
|
}
|
|
else if (dir == Opposite(_escapeDirection))
|
|
{
|
|
score -= 1.5;
|
|
}
|
|
}
|
|
|
|
if (_blockedDirectionTicks.TryGetValue(dir, out var blockedTicks) && blockedTicks > 0)
|
|
{
|
|
score -= 3.0 + blockedTicks * 0.2;
|
|
}
|
|
|
|
if (dir == Opposite(_lastMoveDirection))
|
|
{
|
|
score -= 0.2;
|
|
}
|
|
|
|
score += _random.NextDouble() * 0.5;
|
|
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)
|
|
{
|
|
DecaySpatialHeat(_visitHeat, 0.94, 0.15);
|
|
DecaySpatialHeat(_deathHeat, 0.97, 0.10);
|
|
return;
|
|
}
|
|
|
|
var keys = _enemyHeat.Keys.ToArray();
|
|
foreach (var key in keys)
|
|
{
|
|
_enemyHeat[key] *= 0.92;
|
|
if (_enemyHeat[key] < 0.2)
|
|
{
|
|
_enemyHeat.Remove(key);
|
|
}
|
|
}
|
|
|
|
DecaySpatialHeat(_visitHeat, 0.94, 0.15);
|
|
DecaySpatialHeat(_deathHeat, 0.97, 0.10);
|
|
}
|
|
|
|
private void ApplyMovement(Direction direction, int steps)
|
|
{
|
|
var (dx, dy) = Delta(direction);
|
|
_x += dx * steps;
|
|
_y += dy * steps;
|
|
AddHeat(_x, _y, -2.5);
|
|
MarkVisit(_x, _y, 1.0);
|
|
}
|
|
|
|
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<string>(),
|
|
};
|
|
|
|
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 void UpdateLevelProfile(string? levelName)
|
|
{
|
|
var newProfile = ResolveLevelProfile(levelName);
|
|
if (newProfile == _currentLevelProfile)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_currentLevelProfile = newProfile;
|
|
_sessionLogger?.LogEvent("level-profile", $"level={levelName ?? "unknown"};profile={newProfile}");
|
|
}
|
|
|
|
private static LevelProfile ResolveLevelProfile(string? levelName)
|
|
{
|
|
var value = levelName?.Trim().ToLowerInvariant() ?? string.Empty;
|
|
if (value.Contains("bigger")) return LevelProfile.BiggerMap;
|
|
if (value.Contains("more bots")) return LevelProfile.MoreBots;
|
|
if (value.Contains("new bots")) return LevelProfile.NewBots;
|
|
if (value.Contains("default")) return LevelProfile.Default;
|
|
return LevelProfile.Unknown;
|
|
}
|
|
|
|
private double PressureHpThreshold(LevelProfile profile) => profile switch
|
|
{
|
|
LevelProfile.BiggerMap => 0.15,
|
|
LevelProfile.MoreBots => 0.12,
|
|
LevelProfile.NewBots => 0.18,
|
|
LevelProfile.Default => 0.10,
|
|
_ => 0.15,
|
|
};
|
|
|
|
private int PressureThreatThreshold(LevelProfile profile) => profile switch
|
|
{
|
|
LevelProfile.BiggerMap => 5,
|
|
LevelProfile.MoreBots => 6,
|
|
LevelProfile.NewBots => 5,
|
|
LevelProfile.Default => 6,
|
|
_ => 5,
|
|
};
|
|
|
|
private int SpecialThreatThreshold(LevelProfile profile) => profile switch
|
|
{
|
|
LevelProfile.MoreBots => 2,
|
|
LevelProfile.NewBots => 1,
|
|
LevelProfile.BiggerMap => 1,
|
|
LevelProfile.Default => 1,
|
|
_ => 1,
|
|
};
|
|
|
|
private double SpecialHpFloor(LevelProfile profile) => profile switch
|
|
{
|
|
LevelProfile.MoreBots => 0.20,
|
|
LevelProfile.NewBots => 0.15,
|
|
LevelProfile.BiggerMap => 0.10,
|
|
LevelProfile.Default => 0.05,
|
|
_ => 0.10,
|
|
};
|
|
|
|
private double DashHpThreshold(LevelProfile profile) => profile switch
|
|
{
|
|
LevelProfile.BiggerMap => 0.08,
|
|
LevelProfile.NewBots => 0.12,
|
|
LevelProfile.MoreBots => 0.10,
|
|
LevelProfile.Default => 0.05,
|
|
_ => 0.10,
|
|
};
|
|
|
|
private int EscapeLockTicks(LevelProfile profile) => profile switch
|
|
{
|
|
LevelProfile.BiggerMap => 2,
|
|
LevelProfile.NewBots => 2,
|
|
LevelProfile.MoreBots => 2,
|
|
LevelProfile.Default => 2,
|
|
_ => 2,
|
|
};
|
|
|
|
private bool ShouldUseImmediateHit(LevelProfile profile, double hpRatio, int closeThreats, bool underPressure) => profile switch
|
|
{
|
|
LevelProfile.Default => hpRatio >= 0.05,
|
|
LevelProfile.BiggerMap => hpRatio >= 0.05,
|
|
LevelProfile.MoreBots => hpRatio >= 0.05,
|
|
LevelProfile.NewBots => hpRatio >= 0.05,
|
|
_ => hpRatio >= 0.05,
|
|
};
|
|
|
|
private bool ShouldRetreatOnImmediate(LevelProfile profile, double hpRatio, int closeThreats) => profile switch
|
|
{
|
|
LevelProfile.Default => hpRatio < 0.02,
|
|
LevelProfile.BiggerMap => hpRatio < 0.02,
|
|
LevelProfile.MoreBots => hpRatio < 0.02,
|
|
LevelProfile.NewBots => hpRatio < 0.02,
|
|
_ => hpRatio < 0.02,
|
|
};
|
|
|
|
private (int X, int Y)? PredictEnemyRelative()
|
|
{
|
|
if (!_nearestEnemy.HasValue)
|
|
{
|
|
return _lastObservedEnemyRelative;
|
|
}
|
|
|
|
if (_enemyVelocityEstimate is null)
|
|
{
|
|
return _nearestEnemy;
|
|
}
|
|
|
|
return (
|
|
_nearestEnemy.Value.X + _enemyVelocityEstimate.Value.X,
|
|
_nearestEnemy.Value.Y + _enemyVelocityEstimate.Value.Y);
|
|
}
|
|
|
|
private bool TryGetPredictiveShotDirection(
|
|
Direction? sightDirection,
|
|
PeekEntitiesAsyncResponse? sightResponse,
|
|
(int X, int Y)? predictedEnemy,
|
|
out Direction direction,
|
|
out string reason)
|
|
{
|
|
if (sightDirection is null || sightResponse is null)
|
|
{
|
|
if (predictedEnemy.HasValue)
|
|
{
|
|
direction = DirectionFromVector(predictedEnemy.Value.X, predictedEnemy.Value.Y);
|
|
reason = $"Predicted enemy lane -> {direction}; taking lead shot.";
|
|
return true;
|
|
}
|
|
|
|
direction = Direction.North;
|
|
reason = string.Empty;
|
|
return false;
|
|
}
|
|
|
|
var currentDir = sightDirection.Value;
|
|
var dist = sightResponse.SightedPlayerDistance ?? 99;
|
|
if (predictedEnemy.HasValue && dist >= 2)
|
|
{
|
|
var predictedDir = DirectionFromVector(predictedEnemy.Value.X, predictedEnemy.Value.Y);
|
|
if (predictedDir != currentDir)
|
|
{
|
|
direction = predictedDir;
|
|
reason = $"Enemy moving toward {predictedDir}; lead shot instead of static line {currentDir}.";
|
|
return true;
|
|
}
|
|
}
|
|
|
|
direction = currentDir;
|
|
reason = $"Enemy at {currentDir} ({dist} dist); ranged kill.";
|
|
return true;
|
|
}
|
|
|
|
private bool TryGetAggressiveDashDirection(
|
|
(int X, int Y)? predictedEnemy,
|
|
KeyValuePair<Direction, PeekEntitiesAsyncResponse>? bestSight,
|
|
out Direction direction,
|
|
out string reason)
|
|
{
|
|
if (_nearestEnemy.HasValue)
|
|
{
|
|
var target = predictedEnemy ?? _nearestEnemy.Value;
|
|
var dist = Math.Abs(target.X) + Math.Abs(target.Y);
|
|
if (dist >= 2 && dist <= 6)
|
|
{
|
|
direction = DirectionFromVector(target.X, target.Y);
|
|
reason = $"Aggressive gap-close toward enemy at {direction} (predicted distance {dist}).";
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (bestSight.HasValue && bestSight.Value.Value.Executed && bestSight.Value.Value.PlayersInSight > 0)
|
|
{
|
|
var dist = bestSight.Value.Value.SightedPlayerDistance ?? 99;
|
|
if (dist >= 2 && dist <= 5)
|
|
{
|
|
direction = bestSight.Value.Key;
|
|
reason = $"Enemy visible at {direction} ({dist} dist); dash to force close-range combat.";
|
|
return true;
|
|
}
|
|
}
|
|
|
|
direction = Direction.North;
|
|
reason = string.Empty;
|
|
return false;
|
|
}
|
|
|
|
private void MarkVisit(int x, int y, double amount)
|
|
{
|
|
var key = (x, y);
|
|
_visitHeat.TryGetValue(key, out var current);
|
|
_visitHeat[key] = Math.Min(current + amount, 20);
|
|
}
|
|
|
|
private void MarkDeathZone(int x, int y)
|
|
{
|
|
for (var dx = -1; dx <= 1; dx++)
|
|
{
|
|
for (var dy = -1; dy <= 1; dy++)
|
|
{
|
|
var key = (x + dx, y + dy);
|
|
_deathHeat.TryGetValue(key, out var current);
|
|
_deathHeat[key] = Math.Min(current + (dx == 0 && dy == 0 ? 4.0 : 1.5), 12);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void DecaySpatialHeat(Dictionary<(int X, int Y), double> heatMap, double decay, double minimum)
|
|
{
|
|
if (heatMap.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var key in heatMap.Keys.ToArray())
|
|
{
|
|
heatMap[key] *= decay;
|
|
if (heatMap[key] < minimum)
|
|
{
|
|
heatMap.Remove(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
private double VisitHeatFor(Direction direction)
|
|
{
|
|
var (dx, dy) = Delta(direction);
|
|
var key = (_x + dx, _y + dy);
|
|
return _visitHeat.TryGetValue(key, out var heat) ? heat : 0;
|
|
}
|
|
|
|
private double DeathHeatFor(Direction direction)
|
|
{
|
|
var (dx, dy) = Delta(direction);
|
|
var key = (_x + dx, _y + dy);
|
|
return _deathHeat.TryGetValue(key, out var heat) ? heat : 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;
|
|
_escapeLockTicks = 0;
|
|
_lastUnderPressure = false;
|
|
_lastMoveDirection = Direction.North;
|
|
_escapeDirection = Direction.North;
|
|
_pressureStreak = 0;
|
|
|
|
_enemyHeat.Clear();
|
|
_visitHeat.Clear();
|
|
_deathHeat.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}) | Profile: {_currentLevelProfile} | 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<CancellationToken, Task<bool>> Execute);
|
|
|
|
private enum LevelProfile
|
|
{
|
|
Unknown,
|
|
Default,
|
|
BiggerMap,
|
|
MoreBots,
|
|
NewBots,
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|