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 s_lastSkillReleaseByKey = new Dictionary(256); private static string s_logFilePath; private static bool s_sessionActive; private static float s_sessionStartRealtime; private static int s_lastSecondBucket = -1; 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 s_pendingLines = new ConcurrentQueue(); 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) { lock (s_fileLock) { BeginSessionInternal(sessionLabel); } } public static void RecordSkillRelease( string roleName, string skillName, string effectName, string attributeSummary, string targetSummary) { 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) { 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) { 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) { 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) { 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) { 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) { 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(); } }