412 lines
13 KiB
C#
412 lines
13 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;
|
|
|
|
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)
|
|
{
|
|
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();
|
|
}
|
|
}
|