Editor/RoundUiHandlers.cs

Editor-side handlers that generate or patch UI and round-flow code for the editor. It provides three bridge handlers: CreateRoundTimerHudHandler generates a Razor HUD and SCSS that discovers and displays round/phase timers by TypeLibrary reflection; ScaffoldMapVoteFlowHandler scaffolds a host-authoritative map-vote controller (C#) plus a Razor vote panel and SCSS that replicate tallies and load the winning scene; AddPanelBuildhashHandler patches existing .razor PanelComponent files to insert a BuildHash() override by heuristically parsing the @code block.

File AccessNetworkingExternal Download
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

// =============================================================================
//  Round-flow & UI pack (v2.0.x) -- three tools:
//
//    create_round_timer_hud   Razor screen-panel HUD showing the active round /
//                             phase time remaining; binds AT RUNTIME (TypeLibrary
//                             property reflection) to whichever shipped round
//                             machine exists in the scene -- no code coupling.
//    scaffold_map_vote_flow   end-of-round map vote: host-collected [Rpc.Host]
//                             votes, [Sync(FromHost)] NetList tallies, countdown,
//                             deterministic tie-break, winner -> Scene.LoadFromFile,
//                             plus a Razor vote panel.
//    add_panel_buildhash      NOT a codegen scaffold: a file-EDIT tool that patches
//                             an existing .razor to add a BuildHash() override
//                             folding the state declared in its @code block.
//                             razor_lint flags missing BuildHash; this fixes it.
//
//  Compiles into the SAME editor assembly as MyEditorMenu.cs / ScaffoldHandlers.cs /
//  UiFeedbackHandlers.cs, so it reuses the shared statics on ClaudeBridge
//  (TryResolveProjectPath, SanitizeIdentifier), ScaffoldHelpers (WriteCode /
//  Utf8NoBom) and UiFeedbackHelpers (Resolve). Handler code here is UNSANDBOXED
//  editor code (System.* fine).
//
//  The strings these handlers WRITE TO DISK are SANDBOXED game code (.cs) and
//  Razor (.razor / .razor.scss) and obey the sandbox + razor_lint rules:
//    - sealed Components; [Sync(SyncFlags.FromHost)] host-auth; TimeUntil syncable;
//      System.Math/MathF/MathX fine; Array.Clone() blocked (not used);
//      fully-qualified System.Collections.Generic.List/Dictionary;
//      Connection null-check only (Connection has NO IsValid on this SDK).
//    - Razor: PanelComponent overrides BuildHash, NO switch-expressions in @code,
//      ASCII only in @code, SCSS roots are class selectors.
//    - InvariantCulture float formatting with an 'f' suffix.
//
//  Live-verified API surface before codegen (describe_type / search_types):
//    - Sandbox.Scene: LoadFromFile(String) -> Boolean (instance) and
//      Load(SceneLoadOptions) -> Boolean. SceneLoadOptions: ShowLoadingScreen,
//      IsAdditive, DeleteEverything, SetScene(String).
//    - Sandbox.NetList`1 / Sandbox.NetDictionary`2 exist ([Sync]-able collections).
//    - Sandbox.TimeUntil: Relative/Passed/Fraction (Single, read-only).
//    - Sandbox.PropertyDescription: GetValue(Object) -> Object, PropertyType, Name
//      (the reflection surface the generated HUD uses through TypeLibrary).
//    - [Rpc.Host] / Rpc.Caller / Networking.IsActive / Connection.Local /
//      (ulong)Connection.SteamId -- same shape the v1.20.0 NetPrimitives pack
//      compile-verified live.
//
//  Shipped-generator member names the HUD binds against (read from the generators,
//  not guessed): create_round_phase_machine emits [Sync] TimeUntil PhaseTimer +
//  Phase CurrentPhase; create_round_state_machine emits a manager with int
//  StateIndex + Current whose active state carries [Sync] TimeUntil TimeLeft +
//  string Identifier.
//
//  Register(...) lines + the _sceneMutatingCommands additions live in
//  MyEditorMenu.cs (orchestrator integrates) to keep the files decoupled.
// =============================================================================

// -----------------------------------------------------------------------------
// create_round_timer_hud -- a Razor screen-panel HUD (.razor + .razor.scss) that
// shows the active round/phase time remaining in mm:ss. It binds at RUNTIME via
// TypeLibrary property reflection to whichever shipped round machine exists
// (same-GameObject components first, then the whole scene), so it works with
// create_round_phase_machine AND create_round_state_machine output without
// referencing either class. BuildHash folds the WHOLE second remaining ->
// re-renders at 1 Hz, not every frame.
// -----------------------------------------------------------------------------
public class CreateRoundTimerHudHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			var ci = System.Globalization.CultureInfo.InvariantCulture;

			var name      = p.TryGetProperty( "name",      out var n ) && !string.IsNullOrWhiteSpace( n.GetString() ) ? n.GetString() : "RoundTimerHud";
			var directory = p.TryGetProperty( "directory", out var d ) && !string.IsNullOrWhiteSpace( d.GetString() ) ? d.GetString() : "Code/UI";

			float lowTime = p.TryGetProperty( "lowTimeSeconds", out var lv ) && lv.TryGetSingle( out var lf ) ? lf : 10f;
			if ( lowTime < 0f ) lowTime = 0f;

			var className = ClaudeBridge.SanitizeIdentifier( name.EndsWith( ".razor" ) ? Path.GetFileNameWithoutExtension( name ) : name );
			var razorFile = className + ".razor";
			var scssFile  = className + ".razor.scss";

			if ( !UiFeedbackHelpers.Resolve( directory, razorFile, out var razorPath, out var relRazor, out var rerr ) )
				return Task.FromResult<object>( rerr );
			if ( !UiFeedbackHelpers.Resolve( directory, scssFile, out var scssPath, out var relScss, out var serr ) )
				return Task.FromResult<object>( serr );

			ScaffoldHelpers.WriteCode( razorPath, BuildRazor( className, lowTime.ToString( ci ) + "f" ) );
			ScaffoldHelpers.WriteCode( scssPath, BuildScss() );

			return Task.FromResult<object>( new
			{
				created = true,
				razorPath = relRazor,
				scssPath = relScss,
				className,
				lowTimeSeconds = lowTime,
				note = "Binds by TypeLibrary reflection to the FIRST component it finds shaped like a shipped round machine "
				     + "(PhaseTimer:TimeUntil from create_round_phase_machine, or StateIndex+Current from create_round_state_machine). "
				     + "Shows --:-- until one exists. Renders nothing without a ScreenPanel host.",
				nextSteps = new[]
				{
					"trigger_hotload to compile the panel into the game assembly.",
					"If you have no round machine yet: create_round_phase_machine or create_round_state_machine, then hotload again.",
					"add_screen_panel to a HUD GameObject, then add this panel to the SAME object: add_component_with_properties (component=\"" + className + "\").",
					"Tune LowTimeSeconds with set_property -- at/below it the time gets the 'low' class (red by default; style it in the scss).",
					"Verify in play mode with capture_view (renderUI=true): the label shows the phase/state name, the clock counts down once per second."
				}
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"create_round_timer_hud failed: {ex.Message}" } );
		}
	}

	static string BuildRazor( string className, string lowTime )
	{
		// NOTE: all {{ }} inside the $@"" are C# string-escape doubled braces.
		// The generated .razor uses single { } for Razor/C# expressions.
		return $@"@using Sandbox;
@using Sandbox.UI;
@using System.Linq;
@inherits PanelComponent;

@* {className} -- screen-space round/phase countdown (mm:ss).
   Binds AT RUNTIME to whichever shipped round machine exists -- no code coupling:
     - create_round_phase_machine output: a component with a [Sync] TimeUntil
       PhaseTimer (+ CurrentPhase enum for the label), or
     - create_round_state_machine output: a manager with StateIndex + Current,
       whose active state carries a [Sync] TimeUntil TimeLeft (+ Identifier).
   Discovery uses TypeLibrary property reflection: same-GameObject components
   first, then the whole scene; re-scans every 2 seconds while unbound, so a
   machine added later is picked up automatically.
   BuildHash folds the WHOLE second remaining, so the panel re-renders at 1 Hz,
   not every frame. Host under a ScreenPanel (add_screen_panel). *@

<div class=""round-timer @BoundClass"">
	<div class=""plate"">
		<div class=""label"">@_label</div>
		<div class=""time @LowClass"">@_timeText</div>
	</div>
</div>

@code {{
	/// Remaining-seconds threshold at/below which the ""low"" warning class is
	/// applied to the clock (0 disables the warning entirely).
	[Property] public float LowTimeSeconds {{ get; set; }} = {lowTime};

	private Component _machine;
	private PropertyDescription _timerProp;     // phase machine: PhaseTimer (TimeUntil)
	private PropertyDescription _labelSrcProp;  // phase machine: CurrentPhase
	private PropertyDescription _currentProp;   // state machine: Current (active state component)
	private System.Type _stateType;
	private PropertyDescription _stateTimerProp;
	private PropertyDescription _stateIdentProp;

	private bool _bound;
	private int _wholeSeconds = -1;
	private string _label = """";
	private string _timeText = ""--:--"";
	private bool _low;
	private RealTimeSince _sinceScan = 999f;

	private string BoundClass => _bound ? """" : ""unbound"";
	private string LowClass => _low ? ""low"" : """";

	protected override void OnUpdate()
	{{
		if ( _machine == null || !_machine.IsValid() )
		{{
			_bound = false;
			if ( _sinceScan > 2f )
			{{
				_sinceScan = 0f;
				TryBind();
			}}
		}}
		Sample();
	}}

	private void TryBind()
	{{
		_machine = null; _timerProp = null; _labelSrcProp = null; _currentProp = null;
		_stateType = null; _stateTimerProp = null; _stateIdentProp = null;
		if ( Scene == null ) return;

		// Same-GameObject components first, then the whole scene. First match wins.
		var candidates = Components.GetAll<Component>().Concat( Scene.GetAllComponents<Component>() );
		foreach ( var c in candidates )
		{{
			if ( c == null || c == this ) continue;
			var td = TypeLibrary.GetType( c.GetType() );
			if ( td == null ) continue;

			// Shape 1: create_round_phase_machine -- [Sync] TimeUntil PhaseTimer (+ CurrentPhase).
			var timer = td.Properties.FirstOrDefault( x => x.Name == ""PhaseTimer"" && x.PropertyType == typeof( TimeUntil ) );
			if ( timer != null )
			{{
				_machine = c;
				_timerProp = timer;
				_labelSrcProp = td.Properties.FirstOrDefault( x => x.Name == ""CurrentPhase"" );
				_bound = true;
				return;
			}}

			// Shape 2: create_round_state_machine -- a manager with StateIndex + Current.
			var current = td.Properties.FirstOrDefault( x => x.Name == ""Current"" );
			var stateIndex = td.Properties.FirstOrDefault( x => x.Name == ""StateIndex"" );
			if ( current != null && stateIndex != null )
			{{
				_machine = c;
				_currentProp = current;
				_bound = true;
				return;
			}}
		}}
	}}

	private void Sample()
	{{
		float seconds = -1f;
		string label = """";

		if ( _bound && _machine != null && _machine.IsValid() )
		{{
			if ( _timerProp != null )
			{{
				var v = _timerProp.GetValue( _machine );
				if ( v is TimeUntil tu ) seconds = tu.Relative;
				if ( _labelSrcProp != null ) label = _labelSrcProp.GetValue( _machine )?.ToString() ?? """";
			}}
			else if ( _currentProp != null )
			{{
				var state = _currentProp.GetValue( _machine ) as Component;
				if ( state != null && state.IsValid() )
				{{
					// Cache the state type's reflection lookups -- states swap per phase.
					var t = state.GetType();
					if ( _stateType != t )
					{{
						_stateType = t;
						var std = TypeLibrary.GetType( t );
						_stateTimerProp = std != null ? std.Properties.FirstOrDefault( x => x.Name == ""TimeLeft"" && x.PropertyType == typeof( TimeUntil ) ) : null;
						_stateIdentProp = std != null ? std.Properties.FirstOrDefault( x => x.Name == ""Identifier"" ) : null;
					}}
					if ( _stateTimerProp != null )
					{{
						var v = _stateTimerProp.GetValue( state );
						if ( v is TimeUntil tu ) seconds = tu.Relative;
					}}
					if ( _stateIdentProp != null ) label = _stateIdentProp.GetValue( state )?.ToString() ?? """";
				}}
			}}
		}}

		if ( seconds < 0f )
		{{
			_wholeSeconds = -1;
			_timeText = ""--:--"";
			_low = false;
		}}
		else
		{{
			int total = (int) System.MathF.Ceiling( seconds );
			if ( total < 0 ) total = 0;
			_wholeSeconds = total;
			_timeText = ( total / 60 ).ToString() + "":"" + ( total % 60 ).ToString( ""00"" );
			_low = LowTimeSeconds > 0f && seconds <= LowTimeSeconds;
		}}
		_label = label;
	}}

	// Fold the WHOLE second (not the raw float) -- the panel re-renders at 1 Hz.
	protected override int BuildHash() => System.HashCode.Combine( _bound, _wholeSeconds, _label, _low );
}}
";
	}

	static string BuildScss()
	{
		return @".round-timer {
	position: absolute;
	top: 24px;
	left: 0;
	right: 0;
	flex-direction: column;
	align-items: center;
	pointer-events: none;
	transition: opacity 0.25s;
}

.round-timer.unbound {
	opacity: 0;
}

.plate {
	display: flex;
	flex-direction: column;
	align-items: center;
	background-color: rgba(0,0,0,0.6);
	border-radius: 10px;
	padding: 8px 24px;
}

.label {
	font-size: 16px;
	color: rgba(255,255,255,0.7);
	text-transform: uppercase;
	letter-spacing: 2px;
}

.time {
	font-size: 42px;
	font-weight: bold;
	color: white;
}

.time.low {
	color: #ff5040;
}
";
	}
}

// -----------------------------------------------------------------------------
// scaffold_map_vote_flow -- end-of-round map vote. Three files:
//   {name}.cs            sealed host-authoritative vote controller
//   {name}Panel.razor    vote UI (one button per map, live tallies, countdown)
//   {name}Panel.razor.scss
// Votes travel client -> host via [Rpc.Host] with the caller re-resolved host-
// side (Rpc.Caller null-check -- Connection has NO IsValid); tallies replicate
// via [Sync(FromHost)] NetList<int>; ties break deterministically via one LCG
// scramble of a time seed (no System.Random); the winner loads via
// Scene.LoadFromFile (verified live on this SDK).
// -----------------------------------------------------------------------------
public class ScaffoldMapVoteFlowHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			var ci = System.Globalization.CultureInfo.InvariantCulture;

			var name      = p.TryGetProperty( "name",      out var n ) && !string.IsNullOrWhiteSpace( n.GetString() ) ? n.GetString() : "MapVote";
			var directory = p.TryGetProperty( "directory", out var d ) && !string.IsNullOrWhiteSpace( d.GetString() ) ? d.GetString() : "Code";

			var maps = new List<string>();
			if ( p.TryGetProperty( "maps", out var mv ) && mv.ValueKind == JsonValueKind.Array )
			{
				foreach ( var e in mv.EnumerateArray() )
				{
					var s = e.ValueKind == JsonValueKind.String ? e.GetString() : null;
					if ( string.IsNullOrWhiteSpace( s ) ) continue;
					// Escape any embedded quotes/backslashes so they can't break the generated literal.
					var clean = s.Trim().Replace( "\\", "/" ).Replace( "\"", "" );
					if ( !maps.Contains( clean ) ) maps.Add( clean );
				}
			}

			float voteDur = p.TryGetProperty( "voteDurationSeconds", out var vv ) && vv.TryGetSingle( out var vf ) ? vf : 20f;
			if ( voteDur < 3f ) voteDur = 3f;
			float linger = p.TryGetProperty( "resultLingerSeconds", out var rv ) && rv.TryGetSingle( out var rf ) ? rf : 4f;
			if ( linger < 0f ) linger = 0f;
			bool autoStart = p.TryGetProperty( "autoStart", out var av ) && av.ValueKind == JsonValueKind.True;

			var className = ClaudeBridge.SanitizeIdentifier( name.EndsWith( ".cs" ) ? Path.GetFileNameWithoutExtension( name ) : name );
			var panelClass = className + "Panel";

			var csFile    = className + ".cs";
			var razorFile = panelClass + ".razor";
			var scssFile  = panelClass + ".razor.scss";

			if ( !UiFeedbackHelpers.Resolve( directory, csFile, out var csPath, out var relCs, out var cerr ) )
				return Task.FromResult<object>( cerr );
			if ( !UiFeedbackHelpers.Resolve( directory, razorFile, out var razorPath, out var relRazor, out var rerr ) )
				return Task.FromResult<object>( rerr );
			if ( !UiFeedbackHelpers.Resolve( directory, scssFile, out var scssPath, out var relScss, out var serr ) )
				return Task.FromResult<object>( serr );

			string mapsInit = maps.Count == 0
				? "new System.Collections.Generic.List<string>()"
				: "new System.Collections.Generic.List<string>() { " + string.Join( ", ", maps.Select( m => "\"" + m + "\"" ) ) + " }";

			ScaffoldHelpers.WriteCode( csPath, BuildController( className, mapsInit, voteDur.ToString( ci ) + "f", linger.ToString( ci ) + "f", autoStart ? "true" : "false" ) );
			ScaffoldHelpers.WriteCode( razorPath, BuildPanelRazor( panelClass, className ) );
			ScaffoldHelpers.WriteCode( scssPath, BuildPanelScss() );

			return Task.FromResult<object>( new
			{
				created = true,
				componentPath = relCs,
				razorPath = relRazor,
				scssPath = relScss,
				className,
				panelClassName = panelClass,
				maps = maps.ToArray(),
				voteDurationSeconds = voteDur,
				resultLingerSeconds = linger,
				autoStart,
				note = "The controller must live on a NETWORK-SPAWNED object in multiplayer or the [Sync] tallies/countdown never replicate. "
				     + "The winning scene load runs on the HOST via Scene.LoadFromFile (API verified live); clients follow through the scene "
				     + "networking layer -- verify the actual client hand-off in a real multi-client session.",
				nextSteps = new[]
				{
					"trigger_hotload to compile " + className + " + " + panelClass + " into the game assembly.",
					"Attach " + className + " to a persistent, network-spawned GameObject (your game-manager object): add_component_with_properties (component=\"" + className + "\").",
					( maps.Count == 0 ? "Fill the MapScenes list in the inspector (e.g. \"scenes/arena.scene\") -- it was generated EMPTY and StartVote() refuses with a warning until it has entries." : "MapScenes was pre-filled with " + maps.Count + " entries -- confirm the .scene paths exist (list_scenes)." ),
					"Show the UI: add_screen_panel, then add " + panelClass + " to the same object. It finds the controller automatically.",
					"Open the vote from your round flow: on the host call GetComponent<" + className + ">()?.StartVote() -- e.g. from a post-round state's Begin() or " + className + ".OnVoteFinished to chain systems.",
					"Verify: start_play, StartVote() via invoke_method, click a map button, watch the tally rise and the winner load after the linger."
				}
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"scaffold_map_vote_flow failed: {ex.Message}" } );
		}
	}

	static string BuildController( string className, string mapsInit, string voteDur, string linger, string autoStart )
	{
		return $@"using Sandbox;
using System;

/// <summary>
/// {className} -- a host-authoritative end-of-round map vote.
///
/// Flow: the host calls StartVote() (usually from a post-round phase/state) ->
/// every client's {className}Panel shows one button per MapScenes entry ->
/// clicks route client -> host via [Rpc.Host] SubmitVote (the caller is
/// re-resolved HOST-SIDE from Rpc.Caller; client args are re-validated; re-votes
/// overwrite) -> live tallies replicate to everyone via [Sync(FromHost)]
/// NetList<int> -> when the [Sync] countdown expires the host picks the winner
/// (most votes; ties break deterministically via one LCG scramble of a time
/// seed -- no System.Random) -> after ResultLingerSeconds the HOST loads the
/// winning scene with Scene.LoadFromFile.
///
/// MULTIPLAYER REQUIREMENT: this component must live on a NETWORK-SPAWNED object
/// (e.g. via a NetworkHelper) or the [Sync] state never replicates. Single-player
/// safe: IsProxy is false, the RPC runs locally, Connection.Local is the voter.
///
/// Usage:
///   GetComponent<{className}>()?.StartVote();                  // host-only
///   {className}.OnVoteFinished += scene => Log.Info( scene );  // fires everywhere
/// </summary>
public sealed class {className} : Component
{{
	/// Scene files players vote between, e.g. ""scenes/arena.scene"". Edit in the inspector.
	[Property] public System.Collections.Generic.List<string> MapScenes {{ get; set; }} = {mapsInit};

	/// Seconds the vote stays open once StartVote() is called.
	[Property] public float VoteDurationSeconds {{ get; set; }} = {voteDur};

	/// Seconds the winner banner lingers before the host loads the scene.
	[Property] public float ResultLingerSeconds {{ get; set; }} = {linger};

	/// Start the vote automatically on spawn (host only). Usually left off --
	/// call StartVote() from your round machine's post-round state instead.
	[Property] public bool AutoStart {{ get; set; }} = {autoStart};

	[Sync( SyncFlags.FromHost )] public bool VoteActive {{ get; set; }}
	[Sync( SyncFlags.FromHost )] public TimeUntil VoteEndsIn {{ get; set; }}
	[Sync( SyncFlags.FromHost )] public int WinnerIndex {{ get; set; }} = -1;

	/// Live vote counts, index-aligned with MapScenes. Host writes, everyone reads.
	[Sync( SyncFlags.FromHost )] public NetList<int> Tallies {{ get; set; }} = new NetList<int>();

	/// Fires on EVERY machine (host + proxies) when the winner lands. Arg = the
	/// winning scene file. Hook goodbye banners / stat saves here.
	public static Action<string> OnVoteFinished {{ get; set; }}

	// Host-only ballots (SteamId -> map index); re-votes overwrite. Never synced.
	private System.Collections.Generic.Dictionary<ulong, int> _ballots = new System.Collections.Generic.Dictionary<ulong, int>();
	private int _lastAnnouncedWinner = -1;
	private bool _loadQueued;
	private TimeUntil _loadAt;

	protected override void OnStart()
	{{
		if ( !IsProxy && AutoStart ) StartVote();
	}}

	/// <summary>Host-only: open the vote. Resets ballots + tallies and arms the countdown.</summary>
	public void StartVote()
	{{
		if ( IsProxy || VoteActive ) return;
		if ( MapScenes == null || MapScenes.Count == 0 )
		{{
			Log.Warning( ""{className}: no MapScenes configured -- nothing to vote on."" );
			return;
		}}
		_ballots.Clear();
		Tallies.Clear();
		for ( int i = 0; i < MapScenes.Count; i++ ) Tallies.Add( 0 );
		WinnerIndex = -1;
		_lastAnnouncedWinner = -1;
		_loadQueued = false;
		VoteEndsIn = VoteDurationSeconds;
		VoteActive = true;
	}}

	/// <summary>Client entry point -- call from UI. Routes to the host; re-votes overwrite.</summary>
	public void CastVote( int mapIndex )
	{{
		SubmitVote( mapIndex );
	}}

	// Public so the RPC source generator is happy. The re-validation INSIDE the
	// body is what protects it: any client can invoke an [Rpc.Host] with forged args.
	[Rpc.Host]
	public void SubmitVote( int mapIndex )
	{{
		// Re-resolve the caller HOST-SIDE -- never trust client args for identity.
		// Connection has NO IsValid on this SDK: null-check only.
		var caller = Networking.IsActive ? Rpc.Caller : Connection.Local;
		if ( caller == null ) caller = Connection.Local;
		if ( caller == null ) return;

		if ( !VoteActive ) return;
		if ( MapScenes == null || mapIndex < 0 || mapIndex >= MapScenes.Count ) return;   // re-validate the client-sent index

		_ballots[(ulong) caller.SteamId] = mapIndex;
		RebuildTallies();
	}}

	protected override void OnUpdate()
	{{
		// Winner announce -- change-detected so it fires exactly once on every
		// machine (host + proxies) when the [Sync] WinnerIndex lands.
		if ( WinnerIndex >= 0 && WinnerIndex != _lastAnnouncedWinner )
		{{
			_lastAnnouncedWinner = WinnerIndex;
			var sceneFile = ( MapScenes != null && WinnerIndex < MapScenes.Count ) ? MapScenes[WinnerIndex] : """";
			OnVoteFinished?.Invoke( sceneFile );
		}}

		if ( IsProxy ) return;

		if ( VoteActive && VoteEndsIn <= 0f ) FinishVote();

		if ( _loadQueued && _loadAt <= 0f )
		{{
			_loadQueued = false;
			if ( MapScenes != null && WinnerIndex >= 0 && WinnerIndex < MapScenes.Count )
			{{
				var file = MapScenes[WinnerIndex];
				if ( !string.IsNullOrEmpty( file ) )
				{{
					// Verified live on this SDK: Scene.LoadFromFile( string ) -> bool.
					// The HOST performs the load; clients follow via the scene
					// networking layer. For a loading screen or additive load use
					// SceneLoadOptions (ShowLoadingScreen / SetScene) with
					// Scene.Load( options ) instead.
					Scene.LoadFromFile( file );
				}}
			}}
		}}
	}}

	private void FinishVote()
	{{
		VoteActive = false;
		WinnerIndex = PickWinner();
		_loadQueued = true;
		_loadAt = ResultLingerSeconds;
	}}

	private void RebuildTallies()
	{{
		if ( MapScenes == null ) return;
		while ( Tallies.Count < MapScenes.Count ) Tallies.Add( 0 );
		for ( int i = 0; i < Tallies.Count; i++ ) Tallies[i] = 0;
		foreach ( var kv in _ballots )
		{{
			if ( kv.Value >= 0 && kv.Value < Tallies.Count ) Tallies[kv.Value] = Tallies[kv.Value] + 1;
		}}
	}}

	private int PickWinner()
	{{
		int best = 0; int bestVotes = -1; int tieCount = 0;
		for ( int i = 0; i < Tallies.Count; i++ )
		{{
			if ( Tallies[i] > bestVotes ) {{ bestVotes = Tallies[i]; best = i; tieCount = 1; }}
			else if ( Tallies[i] == bestVotes ) tieCount++;
		}}
		if ( tieCount <= 1 ) return best;

		// Deterministic tie-break WITHOUT System.Random (whitelist caution): one
		// LCG scramble of a time seed picks among the tied indices. Host-only.
		var tied = new System.Collections.Generic.List<int>();
		for ( int i = 0; i < Tallies.Count; i++ )
		{{
			if ( Tallies[i] == bestVotes ) tied.Add( i );
		}}
		int seed = (int) ( Time.Now * 1000f );
		unchecked {{ seed = seed * 1103515245 + 12345; }}
		int pick = (int) ( (uint) seed % (uint) tied.Count );
		return tied[pick];
	}}
}}
";
	}

	static string BuildPanelRazor( string panelClass, string controllerClass )
	{
		// NOTE: all {{ }} inside the $@"" are C# string-escape doubled braces.
		return $@"@using Sandbox;
@using Sandbox.UI;
@using System.Linq;
@inherits PanelComponent;

@* {panelClass} -- vote UI for {controllerClass}.
   Finds the controller in the scene automatically (re-scans every second until
   found). One button per MapScenes entry with a live tally; your own pick gets
   the ""mine"" class. Renders the winner banner during the result linger, and
   nothing at all while no vote is running. Host under a ScreenPanel
   (add_screen_panel); buttons need the mouse released to the UI in play mode.
   BuildHash folds whole seconds + tallies, so it re-renders on real changes. *@

@if ( Ctrl == null )
{{
	<div class=""map-vote"">
		<div class=""panel-box"">
			<div class=""title"">Map vote</div>
			<div class=""hint"">Add a {controllerClass} component to the scene.</div>
		</div>
	</div>
}}
else if ( Ctrl.VoteActive )
{{
	<div class=""map-vote"">
		<div class=""panel-box"">
			<div class=""title"">Vote for the next map</div>
			<div class=""timer"">@TimeText</div>
			<div class=""options"">
				@for ( int i = 0; i < Ctrl.MapScenes.Count; i++ )
				{{
					int idx = i;
					<button class=""option @( _myVote == idx ? ""mine"" : """" )"" onclick=@( () => Cast( idx ) )>
						<span class=""mapname"">@DisplayName( idx )</span>
						<span class=""tally"">@TallyOf( idx )</span>
					</button>
				}}
			</div>
		</div>
	</div>
}}
else if ( Ctrl.WinnerIndex >= 0 )
{{
	<div class=""map-vote"">
		<div class=""panel-box"">
			<div class=""title"">Next map</div>
			<div class=""winner"">@DisplayName( Ctrl.WinnerIndex )</div>
		</div>
	</div>
}}

@code {{
	private {controllerClass} _controller;
	private int _myVote = -1;
	private RealTimeSince _sinceScan = 999f;

	private {controllerClass} Ctrl
	{{
		get
		{{
			if ( ( _controller == null || !_controller.IsValid() ) && _sinceScan > 1f )
			{{
				_sinceScan = 0f;
				_controller = Scene != null ? Scene.GetAllComponents<{controllerClass}>().FirstOrDefault() : null;
			}}
			return _controller;
		}}
	}}

	private string TimeText
	{{
		get
		{{
			var c = Ctrl;
			if ( c == null || !c.VoteActive ) return """";
			int total = (int) System.MathF.Ceiling( c.VoteEndsIn.Relative );
			if ( total < 0 ) total = 0;
			return ( total / 60 ).ToString() + "":"" + ( total % 60 ).ToString( ""00"" );
		}}
	}}

	private void Cast( int idx )
	{{
		var c = Ctrl;
		if ( c == null || !c.VoteActive ) return;
		_myVote = idx;
		c.CastVote( idx );   // [Rpc.Host] under the hood -- routes to the host
	}}

	private string DisplayName( int idx )
	{{
		var c = Ctrl;
		if ( c == null || c.MapScenes == null || idx < 0 || idx >= c.MapScenes.Count ) return ""?"";
		var s = c.MapScenes[idx] ?? """";
		int slash = s.LastIndexOf( '/' );
		if ( slash >= 0 ) s = s.Substring( slash + 1 );
		if ( s.EndsWith( "".scene"" ) ) s = s.Substring( 0, s.Length - 6 );
		return s;
	}}

	private int TallyOf( int idx )
	{{
		var c = Ctrl;
		if ( c == null || c.Tallies == null || idx < 0 || idx >= c.Tallies.Count ) return 0;
		return c.Tallies[idx];
	}}

	// Whole seconds only (1 Hz), plus every tally + the vote lifecycle flags.
	protected override int BuildHash()
	{{
		var c = Ctrl;
		int h = System.HashCode.Combine( c != null, _myVote );
		if ( c != null )
		{{
			int secs = c.VoteActive ? (int) c.VoteEndsIn.Relative : -1;
			h = System.HashCode.Combine( h, c.VoteActive, c.WinnerIndex, secs );
			if ( c.Tallies != null )
			{{
				foreach ( var t in c.Tallies ) h = System.HashCode.Combine( h, t );
			}}
		}}
		return h;
	}}
}}
";
	}

	static string BuildPanelScss()
	{
		return @".map-vote {
	position: absolute;
	top: 0;
	left: 0;
	right: 0;
	bottom: 0;
	justify-content: center;
	align-items: center;
	flex-direction: column;
}

.panel-box {
	display: flex;
	flex-direction: column;
	align-items: center;
	background-color: rgba(10,10,16,0.88);
	border: 2px solid rgba(255,255,255,0.12);
	border-radius: 12px;
	padding: 28px 36px;
	min-width: 420px;
}

.title {
	font-size: 26px;
	font-weight: bold;
	color: white;
	text-transform: uppercase;
	letter-spacing: 2px;
	margin-bottom: 6px;
}

.timer {
	font-size: 38px;
	font-weight: bold;
	color: #ffe080;
	margin-bottom: 14px;
}

.options {
	display: flex;
	flex-direction: column;
	width: 100%;
}

.option {
	display: flex;
	flex-direction: row;
	justify-content: space-between;
	background-color: rgba(255,255,255,0.08);
	color: white;
	font-size: 20px;
	padding: 10px 16px;
	margin: 4px 0;
	border-radius: 8px;
}

.option:hover {
	background-color: rgba(120,180,255,0.35);
}

.option.mine {
	background-color: rgba(80,140,255,0.65);
	border: 1px solid rgba(160,200,255,0.9);
}

.mapname {
	font-weight: bold;
}

.tally {
	color: #ffe080;
	min-width: 40px;
	text-align: right;
}

.winner {
	font-size: 34px;
	font-weight: bold;
	color: #80ff9f;
	margin-top: 6px;
}

.hint {
	font-size: 16px;
	color: rgba(255,255,255,0.5);
}
";
	}
}

// -----------------------------------------------------------------------------
// add_panel_buildhash -- razor_lint's companion FIXER. Not a codegen scaffold:
// patches an EXISTING .razor file, inserting a BuildHash() override at the end
// of its @code block that folds the fields/properties declared there via
// System.HashCode.Combine. Heuristic string/regex parsing -- review the diff.
// Timer-typed members (TimeSince/TimeUntil/RealTimeSince/RealTimeUntil) are
// folded as whole seconds so they re-render at 1 Hz instead of every frame.
// -----------------------------------------------------------------------------
public class AddPanelBuildhashHandler : IBridgeHandler
{
	static readonly System.Text.RegularExpressions.Regex RxAttr =
		new System.Text.RegularExpressions.Regex( @"^\s*\[[^\]]*\]\s*" );

	// Auto-property on one line: modifiers, type, name, then "{ ... get".
	static readonly System.Text.RegularExpressions.Regex RxProp =
		new System.Text.RegularExpressions.Regex(
			@"^(?:public|private|protected|internal)\s+(?:(?:new|virtual|override|required)\s+)*(?<type>[A-Za-z_][\w\.<>\[\], \?]*?)\s+(?<name>[A-Za-z_]\w*)\s*\{[^}]*\bget\b" );

	// Field: modifiers, type, name, then '=' (but not '=>') or ';'.
	static readonly System.Text.RegularExpressions.Regex RxField =
		new System.Text.RegularExpressions.Regex(
			@"^(?:public|private|protected|internal)\s+(?:readonly\s+)?(?<type>[A-Za-z_][\w\.<>\[\], \?]*?)\s+(?<name>[A-Za-z_]\w*)\s*(?:=(?!>)|;)" );

	// Lines that can never be hashable instance state.
	static readonly System.Text.RegularExpressions.Regex RxSkip =
		new System.Text.RegularExpressions.Regex(
			@"\b(static|const|event|delegate|class|struct|enum|interface|record|using|namespace)\b" );

	static readonly HashSet<string> TimerTypes = new()
	{
		"TimeSince", "TimeUntil", "RealTimeSince", "RealTimeUntil",
		"Sandbox.TimeSince", "Sandbox.TimeUntil", "Sandbox.RealTimeSince", "Sandbox.RealTimeUntil"
	};

	public Task<object> Execute( JsonElement p )
	{
		try
		{
			var path = p.TryGetProperty( "path", out var pv ) ? pv.GetString() : null;
			if ( string.IsNullOrWhiteSpace( path ) )
				return Task.FromResult<object>( new { error = "path is required (project-relative .razor file, e.g. Code/UI/MyHud.razor)" } );

			var relPath = path.Replace( '\\', '/' );
			if ( !relPath.EndsWith( ".razor", StringComparison.OrdinalIgnoreCase ) )
				return Task.FromResult<object>( new { error = $"Not a .razor file: {relPath}. This tool patches Razor panels only (the .razor.scss needs no BuildHash)." } );

			if ( !ClaudeBridge.TryResolveProjectPath( relPath, out var fullPath, out var pathErr ) )
				return Task.FromResult<object>( new { error = pathErr } );
			if ( !File.Exists( fullPath ) )
				return Task.FromResult<object>( new { error = $"File not found: {relPath}" } );

			var text = File.ReadAllText( fullPath );

			// Same containment check razor_lint uses -- only PanelComponent panels
			// have the BuildHash virtual to override.
			if ( !text.Contains( "inherits PanelComponent" ) )
				return Task.FromResult<object>( new { error = $"{relPath} does not '@inherits PanelComponent' -- BuildHash is a PanelComponent override, so there is nothing to patch." } );

			if ( text.Contains( "BuildHash" ) )
			{
				return Task.FromResult<object>( new
				{
					alreadyPresent = true,
					path = relPath,
					note = "A BuildHash already exists -- file left unchanged. Extend it by hand if it misses render-driving state."
				} );
			}

			// Find the FIRST @code { ... } block by brace-matching the raw string
			// (string-index based so the rest of the file is preserved byte-for-byte).
			int atCode = text.IndexOf( "@code", StringComparison.Ordinal );
			if ( atCode < 0 )
				return Task.FromResult<object>( new { error = $"No @code block found in {relPath}. Add one (even empty) and re-run, or the panel simply has no state to hash." } );

			int open = text.IndexOf( '{', atCode );
			if ( open < 0 )
				return Task.FromResult<object>( new { error = $"Found @code but no opening brace in {relPath}." } );

			int depth = 0, close = -1;
			for ( int i = open; i < text.Length; i++ )
			{
				if ( text[i] == '{' ) depth++;
				else if ( text[i] == '}' )
				{
					depth--;
					if ( depth == 0 ) { close = i; break; }
				}
			}
			if ( close < 0 )
				return Task.FromResult<object>( new { error = $"Unbalanced braces in the @code block of {relPath} -- fix the file first (a brace inside a string literal can also confuse this heuristic parser)." } );

			// Collect hashable members declared in the block (heuristic, line-based).
			var body = text.Substring( open + 1, close - open - 1 );
			var hashedMembers = new List<string>();
			var exprs = new List<string>();
			var seen = new HashSet<string>();

			foreach ( var raw in body.Split( '\n' ) )
			{
				var line = raw.TrimEnd( '\r' ).Trim();
				if ( line.Length == 0 || line.StartsWith( "//" ) || line.StartsWith( "///" ) ) continue;

				// Strip leading [Attribute] prefixes (possibly several).
				while ( true )
				{
					var am = RxAttr.Match( line );
					if ( !am.Success || am.Length == 0 ) break;
					line = line.Substring( am.Length );
				}

				if ( RxSkip.IsMatch( line ) ) continue;

				string type = null, memberName = null;
				var pm = RxProp.Match( line );
				if ( pm.Success ) { type = pm.Groups["type"].Value.Trim(); memberName = pm.Groups["name"].Value; }
				else
				{
					var fm = RxField.Match( line );
					if ( fm.Success ) { type = fm.Groups["type"].Value.Trim(); memberName = fm.Groups["name"].Value; }
				}

				if ( memberName == null || type == null ) continue;
				if ( type == "void" || memberName == "BuildHash" ) continue;
				// Delegates change render state only indirectly -- skip them.
				if ( type.StartsWith( "Action" ) || type.StartsWith( "Func" )
					|| type.StartsWith( "System.Action" ) || type.StartsWith( "System.Func" ) ) continue;
				if ( !seen.Add( memberName ) ) continue;

				if ( TimerTypes.Contains( type ) )
				{
					// Whole-second fold so a running timer re-renders at 1 Hz, not every frame.
					exprs.Add( $"(int) (float) {memberName}" );
					hashedMembers.Add( $"{memberName} (timer -- folded at whole-second granularity)" );
				}
				else
				{
					exprs.Add( memberName );
					hashedMembers.Add( memberName );
				}
			}

			string combineExpr = BuildCombine( exprs );
			string nl = text.Contains( "\r\n" ) ? "\r\n" : "\n";
			string hashLine = exprs.Count == 0
				? "protected override int BuildHash() => 0;   // add_panel_buildhash: no state members found in @code -- fold your render-driving state here"
				: $"protected override int BuildHash() => {combineExpr};";
			string insertion = $"{nl}\t// Added by add_panel_buildhash (heuristic -- review): folds the @code state so the panel re-renders when it changes.{nl}\t{hashLine}{nl}";

			var patched = text.Insert( close, insertion );
			ScaffoldHelpers.WriteCode( fullPath, patched );

			return Task.FromResult<object>( new
			{
				patched = true,
				path = relPath,
				hashedMembers = hashedMembers.ToArray(),
				buildHash = hashLine,
				note = exprs.Count == 0
					? "No fields/properties were detected in @code, so a stub returning 0 was inserted (silences razor_lint but the panel will NOT re-render on state changes) -- replace 0 with your real state."
					: "Heuristic parse: reference-typed members fold by REFERENCE (a mutated List re-renders only when Count-style state is also folded), computed (=>) properties are skipped, timers fold at 1 Hz. Review the diff, then trigger_hotload."
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"add_panel_buildhash failed: {ex.Message}" } );
		}
	}

	// System.HashCode.Combine tops out at 8 args -- nest chunks when there are more.
	static string BuildCombine( List<string> parts )
	{
		if ( parts.Count == 0 ) return "0";
		if ( parts.Count <= 8 )
			return "System.HashCode.Combine( " + string.Join( ", ", parts ) + " )";
		var chunks = new List<string>();
		for ( int i = 0; i < parts.Count; i += 8 )
			chunks.Add( "System.HashCode.Combine( " + string.Join( ", ", parts.Skip( i ).Take( 8 ) ) + " )" );
		return BuildCombine( chunks );
	}
}