Diagnostics/SessionWatch.cs

Runtime diagnostic tool that records door/link state, barricade changes, zombie stalls and related events to a log file (or relays rows from clients to the host). Samples at a configured interval, snapshots initial state, writes change rows and special "GHOST" rows when a barrier is standing while its nav link is open. Includes a driver component to call Tick each frame.

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

namespace NZombies;

/// <summary>
/// A whole-session trace of doors, barricades and zombie pathing, written to disk.
/// `nz_watch start` … play … `nz_watch stop`.
///
/// ⛔ IT EXISTS BECAUSE THE BUG WILL NOT REPRODUCE ON DEMAND. The report is *"the zombies path as
/// if the doors are closed, including collisions"*, and the answer is a three-way split that
/// `nz_debris_why` can only settle IN THE MOMENT — link open with the prop still standing, prop
/// gone with the nav blocker surviving it, or everything removed and the tiles simply not
/// regenerated. Asking someone to type a command at the instant a horde is on them is asking for
/// the one reading nobody can take. User: *"i could not replicate it — also running commands mid
/// game is hard."*
///
/// ⛔ EVENTS AND TRANSITIONS, NEVER A PER-FRAME DUMP. A snapshot every frame of fifty zombies and
/// forty barriers is a megabyte a minute in which nothing stands out. Every row here is something
/// CHANGING or a condition that should never be true — so an empty log after a clean round is the
/// correct output, and any row at all is worth reading.
///
/// ⚠️ THE ROW THAT MATTERS IS `GHOST`. A link recorded open while its barrier is still standing is
/// the reported bug, stated in one line, with the moment it began. It is mirrored to the console as
/// well as the file precisely because it should be rare enough to be worth interrupting for.
///
/// ⚠️ SAMPLED AT 5 Hz, NOT EVERY FRAME. The things being watched change on the scale of a door
/// opening or a zombie standing still for a second; a frame-rate sample would cost more than it
/// could ever reveal and would itself be a reason the bug moved.
///
/// ⚠️ BUFFERED AND APPENDED, matching `NavLinkLog` and `PerfLog` rather than inventing a third
/// shape. Those two already established where diagnostics live and how they are flushed.
/// </summary>
public static class SessionWatch
{
	const string Dir = "watch";

	/// <summary>Seconds between samples. 0.2 = 5 Hz.</summary>
	public static float Interval { get; set; } = 0.2f;

	/// <summary>
	/// How long a chasing zombie may stand still before it is reported, in seconds. 1.5.
	///
	/// ⚠️ DELIBERATELY SHORTER THAN `StuckTimeout` (5s), WHICH IS THE POINT. The anti-stuck already
	/// logs when it fires; by then the zombie has been relocated and the evidence of WHY is gone.
	/// This catches the same zombie three and a half seconds earlier, while it is still standing in
	/// front of whatever is stopping it.
	/// </summary>
	public static float StallSeconds { get; set; } = 1.5f;

	/// <summary>Units a zombie must move to count as moving. 8.</summary>
	public static float StallRadius { get; set; } = 8f;

	public static string Path { get; private set; }

	/// <summary>
	/// This machine is a CLIENT feeding the host's log rather than writing one.
	///
	/// ⛔ THE WHOLE POINT IS ONE FILE, NOT TWO. Two logs on two machines is what every previous
	/// round of this produced: each one internally consistent, neither one able to say the two
	/// disagreed. `NetProbe`'s header makes the same argument. So a client records nothing locally
	/// and sends its rows to the host, which interleaves them into the single file by timestamp.
	/// </summary>
	static bool _relay;

	public static bool Active => !string.IsNullOrEmpty( Path ) || _relay;
	public static int Rows { get; private set; }

	/// <summary>Rows a client may relay per second before it starts dropping them. 20.</summary>
	public static int RelayBudget { get; set; } = 20;

	static int _relayedThisSecond;
	static RealTimeSince _relayWindow;
	static int _relayDropped;

	static readonly List<string> _buffer = new();
	static RealTimeSince _sinceSample;
	static RealTimeSince _sinceStart;

	// ── remembered state, so only CHANGES are written ────────────────────────
	static readonly Dictionary<string, bool> _linkOpen = new();
	static readonly Dictionary<int, bool> _debrisStanding = new();
	static readonly Dictionary<int, string> _barricade = new();
	static readonly Dictionary<ZombieAI, (Vector3 at, float since, bool reported)> _zombies = new();
	static int _lastUnstuck;

	// ══ commands ════════════════════════════════════════════════════════════

	/// <summary>
	/// `nz_watch [start|stop|reset]` — trace doors, barricades and zombie pathing to a file.
	///
	/// ⚠️ ONE COMMAND WITH SUBCOMMANDS, matching `nz_navlink_intent`. Three separate commands is
	/// three things to remember mid-game, which is the complaint this whole file answers.
	/// </summary>
	[ConCmd( "nz_watch" )]
	public static void Cmd( string arg = "" )
	{
		switch ( arg.ToLowerInvariant() )
		{
			case "start": Start(); return;
			case "stop": Stop(); return;
			case "reset": Reset(); return;
		}

		if ( !Active )
		{
			Log.Info( "[nz-watch] not recording. `nz_watch start` to begin, `stop` to finish." );
			Log.Info( "[nz-watch]   records door/link changes, barricade changes, zombies that"
				+ " stall, and any barrier still standing on an OPEN link." );
			return;
		}

		Log.Info( $"[nz-watch] recording to {Path} — {Rows} row(s), {_sinceStart:0}s elapsed" );
	}

	public static void Start()
	{
		if ( Active )
		{
			Log.Info( $"[nz-watch] already recording to {Path} — {Rows} row(s)" );
			return;
		}

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

		for ( int i = 1; i < 10000; i++ )
		{
			var candidate = $"{Dir}/watch_{i:000}.log";
			if ( FileSystem.Data.FileExists( candidate ) ) continue;
			Path = candidate;
			break;
		}

		if ( !Active ) { Log.Warning( "[nz-watch] could not find a free log name" ); return; }

		_buffer.Clear();
		_linkOpen.Clear();
		_debrisStanding.Clear();
		_barricade.Clear();
		_zombies.Clear();
		Rows = 0;
		_sinceStart = 0;
		_sinceSample = 0;
		_lastUnstuck = ZombieAI.UnstuckCount;

		FileSystem.Data.WriteAllText( Path, "" );

		EnsureDriver();

		Snapshot();
		Flush();

		// ⚠️ AND THE CLIENTS START TOO, DRIVEN FROM HERE. The precedent is `NZNet.HandsFixAsk`,
		// whose own note says it exists so a diagnostic does not depend on somebody typing into the
		// client's console — a command added after that instance launched may simply not be there.
		// Requested in the same words: run it on the host, get the client's side as well.
		if ( Networking.IsActive && NZGame.IsHost )
			NZNet.WatchAsk( true );

		Log.Info( $"[nz-watch] recording to {Path} — `nz_watch stop` when the bug happens" );

		if ( Networking.IsActive && NZGame.IsHost )
			Log.Info( "[nz-watch]   clients are recording into this same file, tagged by name" );
	}

	/// <summary>
	/// A client begins feeding the host's log. Nothing is written here.
	///
	/// ⚠️ NO FILE, NO DRIVER SNAPSHOT TO DISK — but the snapshot still RUNS, because the
	/// client's opening state is exactly what the host's own cannot tell you. A door the two
	/// machines disagree about at second zero is the finding.
	/// </summary>
	public static void StartRelay()
	{
		if ( Active ) return;

		_buffer.Clear();
		_linkOpen.Clear();
		_debrisStanding.Clear();
		_barricade.Clear();
		_zombies.Clear();
		_once.Clear();
		Rows = 0;
		_sinceStart = 0;
		_sinceSample = 0;
		_relayedThisSecond = 0;
		_relayDropped = 0;
		_relayWindow = 0;

		_relay = true;

		EnsureDriver();
		Snapshot();

		Log.Info( "[nz-watch] relaying to the host's log" );
	}

	/// <summary>Stop relaying. Called from the host's `nz_watch stop`.</summary>
	public static void StopRelay()
	{
		if ( !_relay ) return;

		if ( _relayDropped > 0 )
			Write( "NOTE", $"{_relayDropped} row(s) dropped — over the {RelayBudget}/s relay budget" );

		_relay = false;
		_driver?.Destroy();
		_driver = null;

		Log.Info( "[nz-watch] stopped relaying" );
	}

	public static void Stop()
	{
		if ( !Active ) { Log.Info( "[nz-watch] was not recording" ); return; }

		if ( Networking.IsActive && NZGame.IsHost )
			NZNet.WatchAsk( false );

		Write( "STOP", $"{_sinceStart:0}s elapsed" );
		Flush();

		_driver?.Destroy();
		_driver = null;

		Log.Info( $"[nz-watch] stopped — {Rows} row(s) in {Path}" );
		Log.Info( "[nz-watch]   the file is under the s&box data folder for this project,"
			+ $" in '{Dir}'." );
		Path = null;
	}

	/// <summary>Stop and delete every log, for a clean slate.</summary>
	public static void Reset()
	{
		Path = null;
		_buffer.Clear();

		if ( !FileSystem.Data.DirectoryExists( Dir ) ) { Log.Info( "[nz-watch] nothing to clear" ); return; }

		var gone = 0;
		foreach ( var f in FileSystem.Data.FindFile( Dir, "*.log" ).ToList() )
		{
			FileSystem.Data.DeleteFile( $"{Dir}/{f}" );
			gone++;
		}

		Log.Info( $"[nz-watch] cleared {gone} log(s)" );
	}

	// ══ the tick ════════════════════════════════════════════════════════════

	/// <summary>
	/// Called every frame from the game loop; samples on its own interval.
	///
	/// ⚠️ IT COSTS ONE FLOAT COMPARE WHEN OFF, which is why it can be called unconditionally
	/// rather than gated at the call site. A diagnostic the caller has to remember to guard is a
	/// diagnostic that eventually gets guarded wrong.
	/// </summary>
	public static void Tick()
	{
		if ( !Active ) return;
		if ( _sinceSample < Interval ) return;
		_sinceSample = 0;

		SampleDoors();
		SampleBarricades();

		// ⛔ A CLIENT'S ZOMBIES ARE NOT AUTHORITATIVE AND WOULD BE NOISE, NOT EVIDENCE. Zombie
		// objects replicate, so `ZombieAI.All` is populated here — but `State`, `Target` and the
		// crossing latches are all decided on the host and are whatever the local copy last
		// defaulted to. Every client zombie would look permanently stalled with `state=Idle
		// target=none`, which is a page of rows a second saying nothing. The client's value is
		// its view of DOORS and BARRICADES, which is the half the host cannot see.
		if ( !_relay ) SampleZombies();

		if ( _buffer.Count >= 32 ) Flush();
	}

	// ══ what is watched ═════════════════════════════════════════════════════

	/// <summary>
	/// Doors: a link changing state, and the bug itself — a barrier standing on an open link.
	///
	/// ⛔ `GHOST` IS THE WHOLE REASON THIS FILE EXISTS. `DoorLinks.IsOpen` says the door is open and
	/// `PropAt` says the barrier is still in the world: pathing blocked, collisions blocked, flags
	/// correct. That is the report, in one row, with a timestamp.
	///
	/// ⚠️ REPORTED ONCE PER DEBRIS, NOT ONCE PER SAMPLE. A ghost persists, and five rows a second
	/// saying the same thing would bury the door event that caused it.
	/// </summary>
	static void SampleDoors()
	{
		var list = ActiveConfig.Current?.Debris;
		var mgr = DebrisManager.Instance;
		if ( list is null || mgr is null ) return;

		for ( int i = 0; i < list.Count; i++ )
		{
			var d = list[i];
			var open = DoorLinks.IsOpen( d.Link );

			if ( !_linkOpen.TryGetValue( d.Link, out var was ) || was != open )
			{
				_linkOpen[d.Link] = open;
				if ( was != open || !_linkOpen.ContainsKey( d.Link ) )
					Write( "DOOR", $"link '{d.Link}' -> {(open ? "OPEN" : "closed")}" );
			}

			var go = mgr.PropAt( i );
			var standing = go.IsValid() && go.Enabled;

			if ( !_debrisStanding.TryGetValue( i, out var wasStanding ) || wasStanding != standing )
			{
				_debrisStanding[i] = standing;
				Write( "PROP", $"#{i} link '{d.Link}' -> {(standing ? "standing" : "gone")}" );
			}

			// the bug
			if ( open && standing )
			{
				var blocker = go.Components
					.GetAll<NavMeshArea>( FindMode.EverythingInSelfAndDescendants )
					.Any( a => a.IsValid() && a.Enabled && a.IsBlocker );

				WriteOnce( $"ghost{i}", "GHOST",
					$"#{i} link '{d.Link}' is OPEN but the barrier is STILL STANDING"
					+ $" at {go.WorldPosition:0} — navBlocker={(blocker ? "yes" : "no")}"
					+ "  <<< this is the door bug", mirror: true );
			}
		}
	}

	/// <summary>Barricades: boards torn or repaired, and the nav link appearing or vanishing.</summary>
	static void SampleBarricades()
	{
		for ( int i = 0; i < Barricade.All.Count; i++ )
		{
			var b = Barricade.All[i];
			if ( !b.IsValid() ) continue;

			var now = $"{b.Planks}|{b.NavLinked}|{b.CrossValid}";
			if ( _barricade.TryGetValue( i, out var was ) && was == now ) continue;

			_barricade[i] = now;

			// ⚠️ THE FIRST SAMPLE IS NOT A CHANGE. Without this every barricade writes a row at
			// startup and the snapshot above is printed twice in two formats.
			if ( was is null ) continue;

			Write( "BARR", $"#{i} planks {b.Planks}  navLinked={b.NavLinked}"
				+ $"  crossValid={b.CrossValid}" );
		}
	}

	/// <summary>
	/// Zombies: anyone chasing who has stopped moving, and the anti-stuck firing.
	///
	/// ⚠️ IT REPORTS EARLIER THAN THE ANTI-STUCK AND SAYS MORE. `Unstick` fires at 5s and then
	/// teleports, which destroys the evidence; this fires at 1.5s and records what the zombie
	/// thought it was doing — state, target, whether it believed it was crossing or on a link, and
	/// the nearest barrier. That last one is what ties a stall to a specific door.
	/// </summary>
	static void SampleZombies()
	{
		if ( ZombieAI.UnstuckCount != _lastUnstuck )
		{
			Write( "UNSTUCK", $"anti-stuck has now fired {ZombieAI.UnstuckCount} time(s)"
				+ $" this session (+{ZombieAI.UnstuckCount - _lastUnstuck})" );
			_lastUnstuck = ZombieAI.UnstuckCount;
		}

		// drop the dead so the dictionary cannot grow for the life of the round
		foreach ( var gone in _zombies.Keys.Where( z => !z.IsValid() ).ToList() )
			_zombies.Remove( gone );

		foreach ( var z in ZombieAI.All )
		{
			if ( !z.IsValid() ) continue;

			var at = z.WorldPosition;

			if ( !_zombies.TryGetValue( z, out var prev ) )
			{
				_zombies[z] = (at, 0f, false);
				continue;
			}

			if ( at.Distance( prev.at ) > StallRadius )
			{
				_zombies[z] = (at, 0f, false);
				continue;
			}

			var stalled = prev.since + Interval;

			// ⚠️ ONE ROW PER STALL, not one per sample — `reported` latches until it moves again.
			// ⛔ ONLY STILLNESS THE GAME ITSELF CALLS WRONG. The first log was 196 STALL rows of
			// which 162 were `state=Dead`, `Attacking` or `Idle` — a corpse, a zombie tearing
			// boards and a zombie with nobody to chase are all SUPPOSED to be still, and
			// `ZombieAI.StillnessIsExpected` says so before the anti-stuck will touch them. Logging
			// them buried the 34 rows that meant something under five times their number in noise.
			//
			// ⚠️ THE SAME CONDITIONS THAT GATE THE ANTI-STUCK, deliberately mirrored rather than
			// invented: Chasing, a live target, and not already mid-crossing. If this list and that
			// one drift, the log stops describing the system it is watching — §2.
			var worthReporting = z.State == ZombieState.Chasing
				&& z.Target.IsValid()
				&& !z.CrossingLink
				&& !z.OnLink;

			if ( stalled >= StallSeconds && !prev.reported && worthReporting )
			{
				_zombies[z] = (prev.at, stalled, true);
				Write( "STALL", $"'{z.GameObject.Name}' still for {stalled:0.0}s at {at:0}"
					+ $"  state={z.State}  target={(z.Target.IsValid() ? z.Target.Name : "none")}"
					+ $"  crossing={z.CrossingLink}  onLink={z.OnLink}"
					+ $"  {z.AgentDebug}"
					+ $"  nearest: {NearestBarrier( at )}" );
				continue;
			}

			_zombies[z] = (prev.at, stalled, prev.reported);
		}
	}

	/// <summary>
	/// The closest debris or barricade to a point, and whether it is open.
	///
	/// ⚠️ THIS IS WHAT TURNS A STALL INTO A LEAD. "A zombie stopped at -1731,1066" is a
	/// coordinate; "a zombie stopped 40 units from debris #12, whose link is open" is the bug.
	/// </summary>
	static string NearestBarrier( Vector3 at )
	{
		var best = "nothing within 300u";
		var bestDist = 300f;

		var list = ActiveConfig.Current?.Debris;
		var mgr = DebrisManager.Instance;

		if ( list is not null && mgr is not null )
		{
			for ( int i = 0; i < list.Count; i++ )
			{
				var go = mgr.PropAt( i );
				if ( !go.IsValid() ) continue;

				var d = go.WorldPosition.Distance( at );
				if ( d >= bestDist ) continue;

				bestDist = d;
				best = $"debris #{i} at {d:0}u, link '{list[i].Link}'"
					+ $" {(DoorLinks.IsOpen( list[i].Link ) ? "OPEN" : "closed")}";
			}
		}

		for ( int i = 0; i < Barricade.All.Count; i++ )
		{
			var b = Barricade.All[i];
			if ( !b.IsValid() ) continue;

			var d = b.WorldPosition.Distance( at );
			if ( d >= bestDist ) continue;

			bestDist = d;

			// ⛔ THE TWO NUMBERS `BlockingBarricade` ACTUALLY TESTS, because the first log reported
			// neither and could not settle the question. That gate is
			// `InReach( pos, BarricadeReach )`, which is `DistanceToRun <= 38` AND
			// `|z - runZ| <= VerticalReach (64)`. `DistanceToRun` is measured to the window's RUN
			// and IGNORES Z, while the straight-line distance logged before is to the barricade's
			// ORIGIN in 3D — so the two are not comparable, and reading 42u told us nothing about
			// whether a 38u gate had passed. Naming both halves is what makes the next log
			// decisive: too far along the wall, or standing on the wrong deck.
			var runZ = (b.RunA.z + b.RunB.z) * 0.5f;
			var toRun = b.DistanceToRun( at );
			var dz = MathF.Abs( at.z - runZ );

			best = $"barricade #{i} at {d:0}u, planks {b.Planks}, navLinked={b.NavLinked}"
				+ $", toRun {toRun:0.0}u, dz {dz:0.0}u, inReach={b.InReach( at, 38f )}";
		}

		return best;
	}

	// ══ writing ═════════════════════════════════════════════════════════════

	/// <summary>Everything worth knowing before the first event, so the log reads standalone.</summary>
	static void Snapshot()
	{
		Write( "START", $"map '{NZMap.Current}'  mode {NZGame.Mode}"
			+ $"  {(Networking.IsActive ? (NZGame.IsHost ? "HOST" : "CLIENT") : "solo")}"
			+ $"  round {(RoundManager.Instance.IsValid() ? RoundManager.Instance.Round : 0)}" );

		var list = ActiveConfig.Current?.Debris;
		var mgr = DebrisManager.Instance;

		if ( list is not null && mgr is not null )
		{
			for ( int i = 0; i < list.Count; i++ )
			{
				var open = DoorLinks.IsOpen( list[i].Link );
				var go = mgr.PropAt( i );
				var standing = go.IsValid() && go.Enabled;

				_linkOpen[list[i].Link] = open;
				_debrisStanding[i] = standing;

				Write( "SNAP", $"debris #{i} link '{list[i].Link}'"
					+ $"  {(open ? "OPEN" : "closed")}  {(standing ? "standing" : "gone")}" );
			}
		}

		for ( int i = 0; i < Barricade.All.Count; i++ )
		{
			var b = Barricade.All[i];
			if ( !b.IsValid() ) continue;

			_barricade[i] = $"{b.Planks}|{b.NavLinked}|{b.CrossValid}";
			Write( "SNAP", $"barricade #{i} planks {b.Planks}"
				+ $"  navLinked={b.NavLinked}  crossValid={b.CrossValid}" );
		}
	}

	static readonly HashSet<string> _once = new();

	/// <summary>A row that must not repeat while the condition persists.</summary>
	static void WriteOnce( string key, string kind, string text, bool mirror = false )
	{
		if ( !_once.Add( key ) ) return;
		Write( kind, text, mirror );
	}

	static void Write( string kind, string text, bool mirror = false )
	{
		if ( !Active ) return;

		// ⚠️ A CLIENT SENDS AND KEEPS NOTHING. The host stamps its own arrival time, so the two
		// machines' rows interleave on ONE clock rather than on two that started at different
		// moments — which is the comparison this is for.
		if ( _relay )
		{
			if ( _relayWindow >= 1f ) { _relayWindow = 0; _relayedThisSecond = 0; }

			// ⚠️ A BUDGET, BECAUSE A RELAY IS A NETWORK CALL PER ROW. Rows are transitions and
			// should be rare; if something starts producing them by the hundred, dropping the
			// excess and SAYING SO at the end beats a diagnostic that degrades the session it is
			// supposed to be measuring.
			if ( _relayedThisSecond++ >= RelayBudget ) { _relayDropped++; return; }

			NZNet.WatchRow( Connection.Local?.DisplayName ?? "client", kind, text );
			Rows++;
			return;
		}

		// ⚠️ CAST, OR THE FORMAT IS IGNORED. `RealTimeSince` is a struct with an implicit
		// conversion, and interpolating it directly printed `0.0023994148` where `0.00` was
		// asked for — the numeric format only applies once it is actually a float.
		_buffer.Add( $"{(float)_sinceStart,8:0.00}  {kind,-8} {text}" );
		Rows++;

		// ⚠️ MIRRORED ONLY FOR THE ROW THAT MATTERS. Everything else is read from the file after
		// the fact; putting it all on screen would recreate the console spam this avoids.
		if ( mirror ) Log.Warning( $"[nz-watch] {text}" );
	}

	static GameObject _driver;

	/// <summary>
	/// Something has to call <see cref="Tick"/>, and it cannot be the scene file.
	///
	/// ⛔ CREATED FROM CODE, `NotSaved`, exactly as `PerfLog.EnsureDriver` does it. A component
	/// placed in `nzombies.scene` would be the obvious alternative and is not available: this
	/// project does not rewrite scene files from scripts, and a diagnostic that requires someone to
	/// add an object by hand before it works is a diagnostic that is not there when it is needed.
	///
	/// ⚠️ REBUILT ON EVERY `Start` RATHER THAN ONCE, because a GameObject made from code does
	/// not survive a hotload — the same reason `RecoilTuner.EnsureHost` re-checks.
	/// </summary>
	static void EnsureDriver()
	{
		if ( _driver.IsValid() ) return;

		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) { Log.Warning( "[nz-watch] no scene — nothing to watch" ); return; }

		_driver = scene.CreateObject();
		_driver.Name = "Session Watch Driver";
		_driver.Flags |= GameObjectFlags.NotSaved;
		_driver.Components.Create<SessionWatchDriver>();
	}

	/// <summary>
	/// A row arriving from a client. Host only — see <see cref="NZNet.WatchRow"/>.
	///
	/// ⚠️ TAGGED WITH WHO SENT IT AND STAMPED WITH THE HOST'S CLOCK. The tag is what makes a
	/// disagreement visible at a glance; the host's clock is what makes the two machines' rows
	/// comparable at all, since a client's own elapsed time starts whenever its relay did.
	/// </summary>
	public static void Receive( string who, string kind, string text )
	{
		if ( string.IsNullOrEmpty( Path ) ) return;

		_buffer.Add( $"{(float)_sinceStart,8:0.00}  {kind,-8} [{who}] {text}" );
		Rows++;

		if ( kind == "GHOST" ) Log.Warning( $"[nz-watch] ({who}) {text}" );
		if ( _buffer.Count >= 32 ) Flush();
	}

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

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

		try
		{
			// BYTES, NOT A StreamWriter. `new StreamWriter( Stream )` is not on the s&box
			// whitelist -- it compiles under `dotnet build` and is REFUSED by the editor, which is
			// the one compiler whose opinion ships. NavLinkLog and PerfLog both already write
			// through Encoding.UTF8.GetBytes for this reason; this is the third file to need it and
			// the second time this session that `dotnet build` passing has meant nothing.
			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 rule NavLinkLog and PerfLog both state:
			// a logger that silently stops writing hands back a truncated file that looks complete.
			Log.Warning( $"[nz-watch] log write failed, stopping: {e.Message}" );
			Path = null;
		}
	}
}


/// <summary>
/// Calls <see cref="SessionWatch.Tick"/> every frame.
///
/// ⚠️ `OnUpdate`, NOT `OnFixedUpdate`. The thing being watched is a door disappearing and a
/// zombie standing still, both of which are frame-rate events rather than physics ones — and
/// `Tick` rate-limits itself to 5 Hz anyway, so the tighter loop costs one compare.
/// </summary>
public sealed class SessionWatchDriver : Component
{
	protected override void OnUpdate() => SessionWatch.Tick();
}