1017 lines
32 KiB
C#
1017 lines
32 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<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 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 written: {_sessionLogger.SummaryPath}");
|
|
_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--;
|
|
}
|
|
|
|
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<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 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, 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 += 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<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 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<CancellationToken, Task<bool>> 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);
|
|
}
|
|
}
|
|
}
|
|
}
|