Files
exercises-300-digital-dojo-…/DDApp/DDApplication/MainWindow.axaml.cs

1245 lines
40 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 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 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 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;
// 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;
});
}
var specialThreatThreshold = SpecialThreatThreshold(profile);
var specialHpFloor = SpecialHpFloor(profile);
if (Ready(BotAction.Special) && 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;
});
}
var bestSight = _lastPeek
.Where(x => x.Value.Executed && x.Value.PlayersInSight > 0)
.OrderBy(x => x.Value.SightedPlayerDistance ?? int.MaxValue)
.FirstOrDefault();
// More aggressive shooting
if (bestSight.Value is not null && Ready(BotAction.Shoot))
{
var dir = bestSight.Key;
var dist = bestSight.Value.SightedPlayerDistance ?? 99;
return new BotDecision(
"shoot",
$"Enemy at {dir} ({dist} dist); ranged kill.",
async ct =>
{
var response = await _client.ShootAsync(_playerToken, (int)dir, 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;
// Always chase aggressively - ignore radar safety
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 targetDir = DirectionFromVector(_nearestEnemy.Value.X, _nearestEnemy.Value.Y);
// Always move toward nearest enemy
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 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);
}
}
}
}