Diagnostics/NavLinkLog.cs

A static logger for nav link entries used by NZombies. It records per-entry CSV rows with timing, agent/zombie positions and whether the pathfinder wanted or refused the link, buffers rows and writes them to files under FileSystem.Data/navlinks, and provides Start/Stop/Reset controls and counters.

File Access
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;

namespace NZombies;

/// <summary>
/// EVERY NAV LINK ENTRY, WRITTEN TO DISK — who entered, which link, and whether the pathfinder
/// had actually routed them there.
///
/// ⛔ IT EXISTS BECAUSE THE QUESTION IS A RATIO, NOT AN EVENT. "Zombies traverse when they step in
/// the zone" cannot be answered by watching, because the accidental entries are exactly the ones
/// that now do nothing — there is nothing on screen to see. Two counters would give the ratio but
/// not WHICH link, and the fix (a narrower ConnectionRadius) is per-link. A row per entry is the
/// only form that says "link 3 refuses four times for every crossing and the others never do".
///
/// ⛔ A FILE RATHER THAN CONSOLE SPAM. A busy round enters links dozens of times a minute; printed,
/// that buries everything else in the console and is gone when it scrolls. It is also the only form
/// that can be read back and analysed after the fact rather than remembered.
///
/// ⚠️ BUFFERED. Link entries arrive in bursts when a horde funnels through one ledge, and touching
/// the disk per entry inside a nav callback is the wrong place to be slow.
///
/// Written to FileSystem.Data, which on this machine is
/// `D:/SteamLibrary/steamapps/common/sbox/data/local/nzombies_sbox#local/navlinks/`.
///
/// ⚠️ NULLABLE-BACKED / PLAIN STATICS — a static's VALUE survives a hotload but its initialiser does
/// not re-run. Nothing here seeds to a value that would be wrong if carried forward.
/// </summary>
public static class NavLinkLog
{
	const string Dir = "navlinks";

	/// <summary>Rows held before appending. Small — entries are rare next to frames.</summary>
	const int FlushRows = 32;

	static readonly List<string> _rows = new();

	/// <summary>The file being written, or null when not recording.</summary>
	public static string Path { get; private set; }

	/// <summary>Is a log open?</summary>
	public static bool Active => !string.IsNullOrEmpty( Path );

	/// <summary>Rows written so far, including buffered ones.</summary>
	public static int Rows { get; private set; }

	/// <summary>Entries the pathfinder had committed the agent to.</summary>
	public static int Wanted { get; private set; }

	/// <summary>Entries refused — the agent was in the radius but the link was not on its route.</summary>
	public static int Refused { get; private set; }

	/// <summary>
	/// Open a new log and start recording.
	///
	/// ⚠️ NUMBERED, NEVER OVERWRITTEN. Two runs of the same test are two files to compare, and a
	/// logger that clobbers the previous one destroys the only thing worth having.
	/// </summary>
	public static string Start( string label = "" )
	{
		if ( Active )
		{
			Log.Info( $"[nz-navlink] already logging to {Path} — {Rows} row(s)" );
			return Path;
		}

		if ( !FileSystem.Data.DirectoryExists( Dir ) )
			FileSystem.Data.CreateDirectory( Dir );

		var tag = string.IsNullOrWhiteSpace( label ) ? "" : "_" + label.Replace( " ", "_" );

		string path = null;
		for ( int i = 1; i < 10000; i++ )
		{
			var candidate = $"{Dir}/navlink{tag}_{i:000}.csv";
			if ( FileSystem.Data.FileExists( candidate ) ) continue;
			path = candidate;
			break;
		}

		if ( path is null ) { Log.Warning( "[nz-navlink] could not find a free log name" ); return null; }

		_rows.Clear();
		Rows = 0;
		Wanted = 0;
		Refused = 0;

		// ⚠️ THE HEADER IS WRITTEN IMMEDIATELY, so a log that captures nothing still exists and says
		// so, rather than looking like the command silently failed.
		FileSystem.Data.WriteAllText( path,
			"row,time,result,zombie,area,radius,link_len,"
			+ "from_x,from_y,from_z,to_x,to_y,to_z,"
			+ "agent_x,agent_y,agent_z,dist_to_near,"
			// ⚠️ THE TWO TARGET DISTANCES ARE THE WHOLE POINT OF A REFUSED ROW. They say whether the
			// crossing this zombie was about to make would have carried it TOWARD its target or away
			// from it — which is the difference between "the radius is too wide" and "the link is
			// mispriced and the pathfinder genuinely wanted the detour".
			+ "target_dist_from,target_dist_to\n" );

		Path = path;
		Log.Info( $"[nz-navlink] logging link entries to {path}" );
		return path;
	}

	/// <summary>Flush and close, keeping the file.</summary>
	public static void Stop()
	{
		if ( !Active )
		{
			Log.Info( "[nz-navlink] not logging" );
			return;
		}

		Flush();
		Log.Info( $"[nz-navlink] closed {Path} — {Rows} row(s),"
			+ $" {Wanted} on a route, {Refused} refused" );
		Path = null;
	}

	/// <summary>
	/// Stop recording AND delete the logs.
	///
	/// ⚠️ It clears the whole folder, not just the open file. The point of a reset is a clean slate
	/// before a fresh measurement, and leaving four earlier runs behind means the next read has to
	/// work out which file was the one that mattered.
	/// </summary>
	public static void Reset()
	{
		var was = Path;
		_rows.Clear();
		Path = null;
		Rows = 0;
		Wanted = 0;
		Refused = 0;

		var killed = 0;
		try
		{
			if ( FileSystem.Data.DirectoryExists( Dir ) )
			{
				foreach ( var f in FileSystem.Data.FindFile( Dir, "*.csv" ).ToList() )
				{
					FileSystem.Data.DeleteFile( $"{Dir}/{f}" );
					killed++;
				}
			}
		}
		catch ( Exception e )
		{
			Log.Warning( $"[nz-navlink] could not delete every log: {e.Message}" );
		}

		Log.Info( $"[nz-navlink] recording stopped and {killed} log(s) deleted"
			+ (was is null ? "" : $" (was writing {was})") );
	}

	/// <summary>
	/// Record one link entry.
	///
	/// ⚠️ COUNTS EVEN WHEN NOT LOGGING, so `nz_navlink_intent` with no argument still has something
	/// to report without anyone having remembered to start a file first.
	/// </summary>
	public static void Record( bool wanted, ZombieAI z, Vector3 agentAt,
		Vector3 from, Vector3 to, string area, float radius )
	{
		if ( wanted ) Wanted++; else Refused++;

		if ( !Active ) return;

		var t = z.IsValid() && z.Target.IsValid() ? z.Target.WorldPosition : (Vector3?)null;

		var row = string.Join( ",",
			Rows,
			$"{Time.Now:0.00}",
			wanted ? "route" : "refused",
			z.IsValid() ? z.GameObject.Id.ToString()[..8] : "-",
			area ?? "-",
			$"{radius:0.0}",
			$"{from.Distance( to ):0.0}",
			$"{from.x:0}", $"{from.y:0}", $"{from.z:0}",
			$"{to.x:0}", $"{to.y:0}", $"{to.z:0}",
			$"{agentAt.x:0}", $"{agentAt.y:0}", $"{agentAt.z:0}",
			$"{agentAt.Distance( from ):0.0}",
			t.HasValue ? $"{from.Distance( t.Value ):0}" : "-",
			t.HasValue ? $"{to.Distance( t.Value ):0}" : "-" );

		_rows.Add( row );
		Rows++;

		if ( _rows.Count >= FlushRows ) Flush();
	}

	static void Flush()
	{
		if ( _rows.Count == 0 || !Active ) return;

		var block = string.Concat( _rows.Select( r => r + "\n" ) );
		_rows.Clear();

		try
		{
			using var stream = FileSystem.Data.OpenWrite( Path, FileMode.Append );
			var bytes = Encoding.UTF8.GetBytes( block );
			stream.Write( bytes, 0, bytes.Length );
		}
		catch ( Exception e )
		{
			// ⛔ SAID OUT LOUD AND THE LOG IS CLOSED, the same rule PerfLog states: a logger that
			// silently stops writing hands back a truncated file that looks complete.
			Log.Warning( $"[nz-navlink] log write failed, stopping: {e.Message}" );
			Path = null;
		}
	}
}