diff --git a/DDApp/DDApplication/BotSessionLogger.cs b/DDApp/DDApplication/BotSessionLogger.cs new file mode 100644 index 0000000..79538c1 --- /dev/null +++ b/DDApp/DDApplication/BotSessionLogger.cs @@ -0,0 +1,221 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; + +namespace DDApplication; + +internal sealed class BotSessionLogger : IDisposable +{ + private readonly object _gate = new(); + private readonly DateTimeOffset _startedAt = DateTimeOffset.UtcNow; + private readonly Dictionary _actionAttempts = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _actionSuccess = new(StringComparer.OrdinalIgnoreCase); + + private StreamWriter? _eventsWriter; + private bool _disposed; + + public string SessionId { get; } = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + public string DirectoryPath { get; } + public string EventsPath { get; } + public string SummaryPath { get; } + public string LatestSymlinkPath { get; } + + public BotSessionLogger() + { + var baseDir = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + DirectoryPath = Path.Combine(baseDir, "DDApplication", "logs"); + Directory.CreateDirectory(DirectoryPath); + + var stamp = DateTimeOffset.UtcNow.ToString("yyyyMMdd_HHmmss", CultureInfo.InvariantCulture); + EventsPath = Path.Combine(DirectoryPath, $"bot_{stamp}_{SessionId}.events.csv"); + SummaryPath = Path.Combine(DirectoryPath, $"bot_{stamp}_{SessionId}.summary.txt"); + LatestSymlinkPath = Path.Combine(DirectoryPath, "latest.log"); + + _eventsWriter = new StreamWriter(EventsPath, append: false, Encoding.UTF8) { AutoFlush = true }; + _eventsWriter.WriteLine("timestamp_utc,event,label,executed,hp,max_hp,kills,deaths,pos_x,pos_y,pressure,details"); + UpdateLatestSymlink(); + } + + public void LogDecision( + string label, + bool executed, + double hp, + double maxHp, + int kills, + int deaths, + int x, + int y, + bool pressure, + string details) + { + lock (_gate) + { + if (_disposed || _eventsWriter is null) + { + return; + } + + Increment(_actionAttempts, label); + if (executed) + { + Increment(_actionSuccess, label); + } + + _eventsWriter.WriteLine(string.Join(",", + Csv(DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)), + Csv("decision"), + Csv(label), + Csv(executed ? "1" : "0"), + Csv(hp.ToString("0.00", CultureInfo.InvariantCulture)), + Csv(maxHp.ToString("0.00", CultureInfo.InvariantCulture)), + Csv(kills.ToString(CultureInfo.InvariantCulture)), + Csv(deaths.ToString(CultureInfo.InvariantCulture)), + Csv(x.ToString(CultureInfo.InvariantCulture)), + Csv(y.ToString(CultureInfo.InvariantCulture)), + Csv(pressure ? "1" : "0"), + Csv(details))); + } + } + + public void LogTelemetry( + double hp, + double maxHp, + int kills, + int deaths, + double progress, + double remaining, + string level, + int x, + int y, + string details) + { + lock (_gate) + { + if (_disposed || _eventsWriter is null) + { + return; + } + + _eventsWriter.WriteLine(string.Join(",", + Csv(DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)), + Csv("telemetry"), + Csv(level), + Csv(""), + Csv(hp.ToString("0.00", CultureInfo.InvariantCulture)), + Csv(maxHp.ToString("0.00", CultureInfo.InvariantCulture)), + Csv(kills.ToString(CultureInfo.InvariantCulture)), + Csv(deaths.ToString(CultureInfo.InvariantCulture)), + Csv(x.ToString(CultureInfo.InvariantCulture)), + Csv(y.ToString(CultureInfo.InvariantCulture)), + Csv(""), + Csv($"progress={progress:0.00};remaining={remaining:0.00};{details}"))); + } + } + + public void LogEvent(string label, string details) + { + lock (_gate) + { + if (_disposed || _eventsWriter is null) + { + return; + } + + _eventsWriter.WriteLine(string.Join(",", + Csv(DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)), + Csv("event"), + Csv(label), + Csv(""), + Csv(""), + Csv(""), + Csv(""), + Csv(""), + Csv(""), + Csv(""), + Csv(""), + Csv(details))); + } + } + + public void WriteSummaryAndDispose(double finalHp, double finalMaxHp, int finalKills, int finalDeaths) + { + lock (_gate) + { + if (_disposed) + { + return; + } + + var elapsed = DateTimeOffset.UtcNow - _startedAt; + var minutes = Math.Max(elapsed.TotalMinutes, 0.01); + var kd = finalDeaths == 0 ? finalKills : (double)finalKills / finalDeaths; + + var lines = new List + { + $"session={SessionId}", + $"started_utc={_startedAt:O}", + $"ended_utc={DateTimeOffset.UtcNow:O}", + $"duration_minutes={minutes:0.00}", + $"kills={finalKills}", + $"deaths={finalDeaths}", + $"kd={kd:0.000}", + $"kills_per_min={(finalKills / minutes):0.000}", + $"deaths_per_min={(finalDeaths / minutes):0.000}", + $"final_hp={finalHp:0.00}/{finalMaxHp:0.00}", + "actions=" + }; + + foreach (var attempt in _actionAttempts.OrderBy(x => x.Key)) + { + _actionSuccess.TryGetValue(attempt.Key, out var success); + var rate = attempt.Value == 0 ? 0 : (double)success / attempt.Value; + lines.Add($" {attempt.Key}: attempts={attempt.Value}, success={success}, rate={rate:0.000}"); + } + + File.WriteAllLines(SummaryPath, lines, Encoding.UTF8); + + _eventsWriter?.Dispose(); + _eventsWriter = null; + _disposed = true; + } + } + + public void Dispose() + { + WriteSummaryAndDispose(0, 0, 0, 0); + } + + private static void Increment(Dictionary dict, string key) + { + dict.TryGetValue(key, out var current); + dict[key] = current + 1; + } + + private static string Csv(string value) + { + var safe = value.Replace("\"", "\"\""); + return $"\"{safe}\""; + } + + private void UpdateLatestSymlink() + { + try + { + if (File.Exists(LatestSymlinkPath) || Directory.Exists(LatestSymlinkPath)) + { + File.Delete(LatestSymlinkPath); + } + + File.CreateSymbolicLink(LatestSymlinkPath, EventsPath); + } + catch + { + // Best-effort only; logging should continue even if symlink creation fails. + } + } +} + + diff --git a/DDApp/DDApplication/MainWindow.axaml b/DDApp/DDApplication/MainWindow.axaml index 9c87eff..9aa11a1 100644 --- a/DDApp/DDApplication/MainWindow.axaml +++ b/DDApp/DDApplication/MainWindow.axaml @@ -2,10 +2,47 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" + mc:Ignorable="d" d:DesignWidth="1320" d:DesignHeight="860" + Width="1320" Height="860" x:Class="DDApplication.MainWindow" - Title="DDApplication"> - - - + Title="Digital Dojo Bot Controller"> + + +