251 lines
9.5 KiB
C#
251 lines
9.5 KiB
C#
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<string, int> _actionAttempts = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly Dictionary<string, int> _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<string>
|
|
{
|
|
$"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<string, int> 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.
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|