Files
2026-07-30 23:15:58 +08:00

434 lines
14 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using System.Threading;
using UnityEngine;
public static class GameplaySkillLogger
{
private static readonly object s_fileLock = new object();
private static readonly UTF8Encoding s_utf8NoBom = new UTF8Encoding(false);
private static readonly Dictionary<string, float> s_lastSkillReleaseByKey = new Dictionary<string, float>(256);
private static string s_logFilePath;
private static bool s_sessionActive;
private static float s_sessionStartRealtime;
private static int s_lastSecondBucket = -1;
// Master gate. This is a diagnostic disk logger: when disabled, every Record* method
// returns before doing any Sanitize/string-concat/StringBuilder work, so it costs
// nothing on the per-damage/per-note/per-score hot paths in production. Latched from
// GameConfig at BeginSession so a whole session is consistently on or off. Hot call
// sites can also check GameplaySkillLogger.Enabled to skip building argument strings.
public static bool Enabled { get; private set; }
private const float RapidDuplicateSkillThresholdSeconds = 0.05f;
// Off-thread writer: judge/skill events push formatted lines into this queue and a
// single background thread writes them to disk in FIFO order. This keeps the
// synchronous file I/O off the gameplay hot path (first-note hitch) while producing
// byte-identical output: the queue preserves enqueue order (enqueues happen under
// s_fileLock), and the writer thread is the sole owner of the file so a session
// truncate (header) can never interleave with an append.
private struct LogMessage
{
public bool Truncate; // true => WriteAllText (new session header); false => AppendAllText
public string Text;
}
private static readonly ConcurrentQueue<LogMessage> s_pendingLines = new ConcurrentQueue<LogMessage>();
private static readonly AutoResetEvent s_writeSignal = new AutoResetEvent(false);
private static Thread s_writerThread;
private static volatile bool s_writerRunning;
private static readonly object s_writerStartLock = new object();
public static string LogFilePath
{
get { return EnsureLogFilePath(); }
}
public static void BeginSession(string sessionLabel)
{
// Latch the gate for the whole session from the debug flags. Disabled in production
// (both flags default false) so the logger adds zero hot-path cost.
Enabled = GameConfig.verboseLogs || GameConfig.skillDebugMode;
if (!Enabled)
{
return;
}
lock (s_fileLock)
{
BeginSessionInternal(sessionLabel);
}
}
public static void RecordSkillRelease(
string roleName,
string skillName,
string effectName,
string attributeSummary,
string targetSummary)
{
if (!Enabled) return;
lock (s_fileLock)
{
EnsureSessionLocked();
string role = Sanitize(roleName);
string skill = Sanitize(skillName);
string effect = Sanitize(effectName);
string attr = Sanitize(attributeSummary);
string target = Sanitize(targetSummary);
string payload = "role=" + role
+ " | skill=" + skill
+ " | effect=" + effect
+ " | attr=" + attr
+ " | target=" + target;
float elapsed = AppendEventLineLocked("SKILL", payload);
string dedupeKey = role + "|" + skill + "|" + effect + "|" + target;
if (s_lastSkillReleaseByKey.TryGetValue(dedupeKey, out float lastElapsed))
{
float interval = elapsed - lastElapsed;
if (interval >= 0f && interval <= RapidDuplicateSkillThresholdSeconds)
{
AppendEventLineLocked(
"WARN",
"potential_duplicate_skill_trigger"
+ " | role=" + role
+ " | skill=" + skill
+ " | intervalSec=" + FormatFloat(interval));
}
}
s_lastSkillReleaseByKey[dedupeKey] = elapsed;
}
}
public static void RecordJudgeResult(
string noteType,
string noteSegment,
int trackIndex,
string noteId,
string judgeResult,
float rawOffsetMs,
bool rewrittenToPerfect,
bool autoplay,
float actionTime,
float hitTime,
float scheduledEndTime)
{
if (!Enabled) return;
lock (s_fileLock)
{
EnsureSessionLocked();
string payload = "noteType=" + Sanitize(noteType)
+ " | segment=" + Sanitize(noteSegment)
+ " | track=" + trackIndex
+ " | noteId=" + Sanitize(noteId)
+ " | judge=" + Sanitize(judgeResult)
+ " | rawOffsetMs=" + FormatFloat(rawOffsetMs)
+ " | rewrittenToPerfect=" + (rewrittenToPerfect ? "1" : "0")
+ " | autoplay=" + (autoplay ? "1" : "0")
+ " | actionTime=" + FormatFloat(actionTime)
+ " | hitTime=" + FormatFloat(hitTime)
+ " | scheduledEndTime=" + FormatFloat(scheduledEndTime);
AppendEventLineLocked("JUDGE", payload);
}
}
public static void RecordScoreDelta(
string source,
int trackIndex,
int pmRequestedDelta,
int pmActualDelta,
int idolRequestedDelta,
int idolActualDelta,
float scoreEfficiency,
bool isSkillRelated,
int trackPmSum,
int trackIdolSum,
int allPm,
int allIdol,
int totalScore)
{
if (!Enabled) return;
lock (s_fileLock)
{
EnsureSessionLocked();
string payload = "source=" + Sanitize(source)
+ " | track=" + trackIndex
+ " | pmReq=" + pmRequestedDelta
+ " | pmAct=" + pmActualDelta
+ " | idolReq=" + idolRequestedDelta
+ " | idolAct=" + idolActualDelta
+ " | efficiency=" + FormatFloat(scoreEfficiency)
+ " | skillRelated=" + (isSkillRelated ? "1" : "0")
+ " | trackPm=" + trackPmSum
+ " | trackIdol=" + trackIdolSum
+ " | allPm=" + allPm
+ " | allIdol=" + allIdol
+ " | total=" + totalScore;
AppendEventLineLocked("SCORE", payload);
}
}
public static void RecordEnemyHpEvent(
string enemyName,
string eventType,
float rawAmount,
float effectiveAmount,
int hpBefore,
int hpAfter,
float damageResistance,
string sourceName)
{
if (!Enabled) return;
lock (s_fileLock)
{
EnsureSessionLocked();
string payload = "enemy=" + Sanitize(enemyName)
+ " | event=" + Sanitize(eventType)
+ " | raw=" + FormatFloat(rawAmount)
+ " | effective=" + FormatFloat(effectiveAmount)
+ " | hpBefore=" + hpBefore
+ " | hpAfter=" + hpAfter
+ " | hpDelta=" + (hpAfter - hpBefore)
+ " | resist=" + FormatFloat(damageResistance)
+ " | source=" + Sanitize(sourceName);
AppendEventLineLocked("ENEMY", payload);
}
}
public static void RecordEnemyLifeState(
string enemyName,
string state,
int hp,
int maxHp,
int mana,
int maxMana,
string source)
{
if (!Enabled) return;
lock (s_fileLock)
{
EnsureSessionLocked();
string payload = "enemy=" + Sanitize(enemyName)
+ " | state=" + Sanitize(state)
+ " | hp=" + hp + "/" + maxHp
+ " | mana=" + mana + "/" + maxMana
+ " | source=" + Sanitize(source);
AppendEventLineLocked("ENEMY", payload);
}
}
public static void RecordAllyResourceEvent(
string allyName,
int slotIndex,
string resource,
string eventType,
float requestedAmount,
float effectiveAmount,
int before,
int after,
int maxValue,
string sourceName)
{
if (!Enabled) return;
lock (s_fileLock)
{
EnsureSessionLocked();
string payload = "ally=" + Sanitize(allyName)
+ " | slot=" + slotIndex
+ " | resource=" + Sanitize(resource)
+ " | event=" + Sanitize(eventType)
+ " | requested=" + FormatFloat(requestedAmount)
+ " | effective=" + FormatFloat(effectiveAmount)
+ " | before=" + before
+ " | after=" + after
+ " | delta=" + (after - before)
+ " | max=" + maxValue
+ " | source=" + Sanitize(sourceName);
AppendEventLineLocked("ALLY", payload);
}
}
public static void RecordConflictHint(string subsystem, string issueType, string details)
{
if (!Enabled) return;
lock (s_fileLock)
{
EnsureSessionLocked();
string payload = "subsystem=" + Sanitize(subsystem)
+ " | issue=" + Sanitize(issueType)
+ " | details=" + Sanitize(details);
AppendEventLineLocked("WARN", payload);
}
}
private static void BeginSessionInternal(string sessionLabel)
{
s_sessionStartRealtime = Time.realtimeSinceStartup;
s_lastSecondBucket = -1;
s_sessionActive = true;
s_lastSkillReleaseByKey.Clear();
string normalizedLabel = string.IsNullOrWhiteSpace(sessionLabel) ? "UnknownSession" : sessionLabel.Trim();
string path = EnsureLogFilePath();
string header =
"# Gameplay Skill Timeline Log" + Environment.NewLine +
"# Session: " + normalizedLabel + Environment.NewLine +
"# StartedAt: " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + Environment.NewLine +
"# Format: [elapsedSec] [CATEGORY] key=value | key=value" + Environment.NewLine +
Environment.NewLine;
EnsureWriterThread();
s_pendingLines.Enqueue(new LogMessage { Truncate = true, Text = header });
s_writeSignal.Set();
}
private static float AppendEventLineLocked(string category, string payload)
{
float elapsed = Mathf.Max(0f, Time.realtimeSinceStartup - s_sessionStartRealtime);
int secondBucket = Mathf.FloorToInt(elapsed);
var builder = new StringBuilder(320);
if (secondBucket != s_lastSecondBucket)
{
if (s_lastSecondBucket >= 0) builder.AppendLine();
builder.AppendLine("=== T+" + secondBucket.ToString("D4", CultureInfo.InvariantCulture) + "s ===");
s_lastSecondBucket = secondBucket;
}
builder.Append("[")
.Append(elapsed.ToString("F3", CultureInfo.InvariantCulture))
.Append("s] [")
.Append(Sanitize(category))
.Append("] ")
.AppendLine(payload);
EnsureWriterThread();
s_pendingLines.Enqueue(new LogMessage { Truncate = false, Text = builder.ToString() });
s_writeSignal.Set();
return elapsed;
}
private static void EnsureWriterThread()
{
if (s_writerRunning)
return;
lock (s_writerStartLock)
{
if (s_writerRunning)
return;
s_writerRunning = true;
s_writerThread = new Thread(WriterLoop)
{
Name = "GameplaySkillLoggerWriter",
IsBackground = true
};
s_writerThread.Start();
}
}
private static void WriterLoop()
{
while (s_writerRunning)
{
s_writeSignal.WaitOne(200);
DrainPendingLines();
}
// Final drain so nothing queued right before shutdown is lost.
DrainPendingLines();
}
private static void DrainPendingLines()
{
while (s_pendingLines.TryDequeue(out LogMessage message))
{
// Read the cached path directly: it is always resolved on the main thread
// (BeginSessionInternal -> EnsureLogFilePath) before the writer thread starts,
// so we must never call Application.dataPath from this background thread.
string path = s_logFilePath;
if (string.IsNullOrEmpty(path))
continue;
try
{
if (message.Truncate)
File.WriteAllText(path, message.Text, s_utf8NoBom);
else
File.AppendAllText(path, message.Text, s_utf8NoBom);
}
catch (Exception ex)
{
Debug.LogWarning("[GameplaySkillLogger] Failed to write log: " + ex.Message);
}
}
}
private static void EnsureSessionLocked()
{
if (!s_sessionActive)
{
BeginSessionInternal("AutoSession");
}
}
private static string EnsureLogFilePath()
{
if (!string.IsNullOrEmpty(s_logFilePath))
{
return s_logFilePath;
}
try
{
string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
string logsDir = Path.Combine(projectRoot, "Logs");
Directory.CreateDirectory(logsDir);
s_logFilePath = Path.Combine(logsDir, "GameplaySkillTimeline.log");
}
catch
{
s_logFilePath = Path.Combine(Application.persistentDataPath, "GameplaySkillTimeline.log");
}
return s_logFilePath;
}
private static string FormatFloat(float value)
{
if (float.IsNaN(value)) return "NaN";
if (float.IsPositiveInfinity(value)) return "Infinity";
if (float.IsNegativeInfinity(value)) return "-Infinity";
return value.ToString("0.###", CultureInfo.InvariantCulture);
}
private static string Sanitize(string value)
{
if (string.IsNullOrEmpty(value)) return "-";
return value.Replace("\r", " ").Replace("\n", " ").Trim();
}
}