Audio/EditorUiActivity.cs

Editor UI activity recorder for the editor watchdog. It tracks bounded observations of UI state (stage, level, picker, scroll, map positions) and records time-stamped actions into a circular buffer, coalescing continuous movement events.

File Access
using System;

namespace BlockParty;

/// <summary>Bounded UI observations for the editor watchdog. Recording never formats strings or logs.</summary>
internal sealed class EditorUiActivity
{
	internal struct State
	{
		public bool Known, Loading, PickerOpen, PickerObserved, PickerGrid;
		public string Level, Stage;
		public int Rows, First, Last, MapPreviews, SearchLength;
		public float Scroll, PickerX, PickerY, MapX, MapY;
		public double Observed, PickerObservedAt;

		public string Describe( double at ) => !Known ? "uiContext=none" :
			$"uiContext=[stage={Stage} level='{Level}' loading={Loading} rows={Rows} visible=[{First},{Last}) scroll={Scroll:0.###} " +
			(Stage == "LevelSelectStage" ? $"mapCamera=({MapX:0.###},{MapY:0.###}) " : "") +
			$"observedAgo={Math.Max( 0, at - Observed ):0.000}s pickerOpen={PickerOpen}" +
			(!PickerOpen ? "]" : !PickerObserved ? " pickerLayout=pending]" :
			$" pickerView={(PickerGrid ? "search-grid" : "map")} pickerScroll=({PickerX:0.###},{PickerY:0.###}) " +
			$"mapPreviews={MapPreviews} searchLength={SearchLength} pickerObservedAgo={Math.Max( 0, at - PickerObservedAt ):0.000}s]");
	}

	internal struct Entry
	{
		public double FirstAt, LastAt;
		public string Action;
		public int Count;
		public float Input;
		public State Context;
		public string Describe() => $"+{FirstAt:0.000}..{LastAt:0.000}s action={Action} count={Count} input={Input:0.###} " + Context.Describe( LastAt );
	}

	private readonly Entry[] _entries = new Entry[64];
	private int _next;
	private object _owner;
	private State _state;
	public int Count { get; private set; }
	public State Current => _state;
	public Entry Oldest( int index ) => _entries[(_next - Count + index + _entries.Length) % _entries.Length];
	public void ClearState() { _state = default; _owner = null; }

	public void Board( object owner, double now, string level, bool loading, bool pickerOpen,
		int rows, int first, int last, float scroll, string action = null, float input = 0,
		string stage = "HighscoreStage", float mapX = 0, float mapY = 0 )
	{
		if ( !ReferenceEquals( owner, _owner ) || _state.Stage != stage ) { _state = default; _owner = owner; }
		bool moved = _state.Known && (int)_state.Scroll != (int)scroll;
		bool mapMoved = _state.Known && ((int)_state.MapX != (int)mapX || (int)_state.MapY != (int)mapY);
		if ( pickerOpen != _state.PickerOpen ) _state.PickerObserved = false;
		_state.Known = true;
		_state.Stage = stage;
		_state.MapX = mapX;
		_state.MapY = mapY;
		_state.Observed = now;
		_state.Level = level;
		_state.Loading = loading;
		_state.PickerOpen = pickerOpen;
		_state.Rows = rows;
		_state.First = first;
		_state.Last = last;
		_state.Scroll = scroll;
		if ( action is not null ) Record( now, action, input );
		else
		{
			if ( moved ) Record( now, stage == "LevelSelectStage" ? "footer-scroll" : "leaderboard-scroll" );
			if ( mapMoved ) Record( now, "map-pan" );
		}
	}

	public void Picker( double now, bool grid, float x, float y, int previews, int searchLength, string action = null )
	{
		if ( !_state.Known || !_state.PickerOpen ) return;
		bool first = !_state.PickerObserved;
		bool moved = (int)x != (int)_state.PickerX || (int)y != (int)_state.PickerY;
		bool viewChanged = grid != _state.PickerGrid;
		bool previewsChanged = previews != _state.MapPreviews;
		_state.PickerObserved = true;
		_state.PickerObservedAt = now;
		_state.PickerGrid = grid;
		_state.PickerX = x;
		_state.PickerY = y;
		_state.MapPreviews = previews;
		_state.SearchLength = searchLength;
		if ( action is not null ) Record( now, action );
		else if ( first || viewChanged ) Record( now, "picker-layout" );
		else if ( moved ) Record( now, "picker-scroll" );
		else if ( previewsChanged ) Record( now, "picker-previews" );
	}

	private void Record( double now, string action, float input = 0 )
	{
		int previous = (_next - 1 + _entries.Length) % _entries.Length;
		// Coalesce only continuous movement, never repeated clicks or board requests.
		bool movement = action == "leaderboard-scroll" || action == "leaderboard-wheel" || action == "picker-scroll"
			|| action == "footer-scroll" || action == "footer-wheel" || action == "map-pan";
		if ( movement && Count > 0 && _entries[previous].Action == action
			&& _entries[previous].Context.Stage == _state.Stage
			&& _entries[previous].Context.Level == _state.Level && now - _entries[previous].LastAt < 0.25 )
		{
			_entries[previous].LastAt = now;
			_entries[previous].Context = _state;
			_entries[previous].Input = input;
			_entries[previous].Count++;
			return;
		}
		_entries[_next] = new Entry { FirstAt = now, LastAt = now, Action = action, Count = 1, Input = input, Context = _state };
		_next = (_next + 1) % _entries.Length;
		Count = Math.Min( Count + 1, _entries.Length );
	}
}