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 SummaryCsvPath { get; } public string LatestSymlinkPath { get; } public string LatestSummarySymlinkPath { 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"); SummaryCsvPath = Path.Combine(DirectoryPath, $"bot_{stamp}_{SessionId}.summary.csv"); LatestSymlinkPath = Path.Combine(DirectoryPath, "latest.log.csv"); LatestSummarySymlinkPath = Path.Combine(DirectoryPath, "latest-summary.log.csv"); _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(LatestSymlinkPath, EventsPath); } 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}"); } using (var csvWriter = new StreamWriter(SummaryCsvPath, append: false, Encoding.UTF8)) { csvWriter.WriteLine("type,key,value"); csvWriter.WriteLine($"{Csv("metric")},{Csv("session")},{Csv(SessionId)}"); csvWriter.WriteLine($"{Csv("metric")},{Csv("started_utc")},{Csv(_startedAt.ToString("O", CultureInfo.InvariantCulture))}"); csvWriter.WriteLine($"{Csv("metric")},{Csv("ended_utc")},{Csv(DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture))}"); csvWriter.WriteLine($"{Csv("metric")},{Csv("duration_minutes")},{Csv(minutes.ToString("0.00", CultureInfo.InvariantCulture))}"); csvWriter.WriteLine($"{Csv("metric")},{Csv("kills")},{Csv(finalKills.ToString(CultureInfo.InvariantCulture))}"); csvWriter.WriteLine($"{Csv("metric")},{Csv("deaths")},{Csv(finalDeaths.ToString(CultureInfo.InvariantCulture))}"); csvWriter.WriteLine($"{Csv("metric")},{Csv("kd")},{Csv(kd.ToString("0.000", CultureInfo.InvariantCulture))}"); csvWriter.WriteLine($"{Csv("metric")},{Csv("kills_per_min")},{Csv((finalKills / minutes).ToString("0.000", CultureInfo.InvariantCulture))}"); csvWriter.WriteLine($"{Csv("metric")},{Csv("deaths_per_min")},{Csv((finalDeaths / minutes).ToString("0.000", CultureInfo.InvariantCulture))}"); csvWriter.WriteLine($"{Csv("metric")},{Csv("final_hp")},{Csv($"{finalHp:0.00}/{finalMaxHp:0.00}")}"); 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; csvWriter.WriteLine($"{Csv("action")},{Csv(attempt.Key + ".attempts")},{Csv(attempt.Value.ToString(CultureInfo.InvariantCulture))}"); csvWriter.WriteLine($"{Csv("action")},{Csv(attempt.Key + ".success")},{Csv(success.ToString(CultureInfo.InvariantCulture))}"); csvWriter.WriteLine($"{Csv("action")},{Csv(attempt.Key + ".rate")},{Csv(rate.ToString("0.000", CultureInfo.InvariantCulture))}"); } } UpdateLatestSymlink(LatestSummarySymlinkPath, SummaryCsvPath); _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 static void UpdateLatestSymlink(string symlinkPath, string targetPath) { try { if (File.Exists(symlinkPath) || Directory.Exists(symlinkPath)) { File.Delete(symlinkPath); } File.CreateSymbolicLink(symlinkPath, targetPath); } catch { // Best-effort only; logging should continue even if symlink creation fails. } } }