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.
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;
}
}
}