Editor/AiSystemsHandlers.cs

Editor handlers that generate C# source files for AI and systems features in the game project. Each IBridgeHandler builds a source string (NeedsSystem, Utility AI, NPC schedule brain, EventBus, TTS speaker), writes it to disk, and optionally places the generated type on a scene GameObject via the editor bridge.

File AccessExternal DownloadNative InteropNetworking
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.Tasks;

// ═══════════════════════════════════════════════════════════════════════════
//  AI & Systems — Feature Wave (create_needs_system / create_utility_ai /
//  create_npc_schedule_brain / create_event_bus / add_tts_voice)
//
//  Compiles into the SAME editor assembly as MyEditorMenu.cs, so it uses the
//  shared helpers directly: ClaudeBridge.TryResolveProjectPath / SanitizeIdentifier /
//  ParseVector3 / SerializeGo, ScaffoldHelpers.PrepareCodeFile / WriteCode, and the
//  IBridgeHandler dispatch contract. Handler code here is UNSANDBOXED editor code.
//
//  The C# *strings these handlers generate* run in the SANDBOX (the game). Every
//  template below was live-compile-verified on 2026-07-12 (written into the live
//  project with default params, hotloaded, compile clean, TypeLibrary-load confirmed
//  for every class, then deleted): sealed Components + [Sync(SyncFlags.FromHost)],
//  nested data classes in [Property] List<T>, an abstract Component base with virtual
//  members, a static (non-Component) class, a C# record, TypeLibrary.GetType(Type) +
//  PropertyDescription.GetValue in game code, Rotation.LookAt(Vector3),
//  Sandbox.Speech.Synthesizer (fluent TrySetVoice/WithText/WithRate/Play), and
//  SoundHandle (Stop(fade)/IsPlaying/IsValid/SetParent/ListenLocal/LipSync.Enabled).
//
//  Registration lines + the _sceneMutatingCommands additions are wired by the main
//  agent in MyEditorMenu.cs (see this wave's summary) to avoid a merge conflict.
// ═══════════════════════════════════════════════════════════════════════════

/// <summary>
/// Shared helpers for the AI &amp; Systems generators. Kept internal to this file so
/// it does not collide with anything in MyEditorMenu.cs or sibling handler files.
/// </summary>
internal static class AiSystemsHelpers
{
	/// <summary>Read an optional float param — tolerates a JSON number OR a numeric string.</summary>
	public static float Float( JsonElement p, string key, float fallback )
	{
		if ( !p.TryGetProperty( key, out var e ) ) return fallback;
		if ( e.ValueKind == JsonValueKind.Number && e.TryGetSingle( out var f ) ) return f;
		if ( e.ValueKind == JsonValueKind.String
		     && float.TryParse( e.GetString(), System.Globalization.NumberStyles.Float,
		                        System.Globalization.CultureInfo.InvariantCulture, out var fs ) ) return fs;
		return fallback;
	}

	public static int Int( JsonElement p, string key, int fallback )
	{
		if ( !p.TryGetProperty( key, out var e ) ) return fallback;
		if ( e.ValueKind == JsonValueKind.Number && e.TryGetInt32( out var i ) ) return i;
		if ( e.ValueKind == JsonValueKind.String && int.TryParse( e.GetString(), out var iss ) ) return iss;
		return fallback;
	}

	public static bool Bool( JsonElement p, string key, bool fallback )
	{
		if ( !p.TryGetProperty( key, out var e ) ) return fallback;
		if ( e.ValueKind == JsonValueKind.True ) return true;
		if ( e.ValueKind == JsonValueKind.False ) return false;
		if ( e.ValueKind == JsonValueKind.String && bool.TryParse( e.GetString(), out var b ) ) return b;
		return fallback;
	}

	public static string Str( JsonElement p, string key, string fallback )
	{
		if ( p.TryGetProperty( key, out var e ) && e.ValueKind == JsonValueKind.String )
		{
			var s = e.GetString();
			if ( !string.IsNullOrWhiteSpace( s ) ) return s;
		}
		return fallback;
	}

	/// <summary>
	/// Format a float as an invariant-culture C# literal with an 'f' suffix (130 -> "130f").
	/// Invariant culture matters: a comma-decimal locale must not emit "0,25f".
	/// </summary>
	public static string F( float v )
	{
		var s = v.ToString( "0.0###", System.Globalization.CultureInfo.InvariantCulture );
		return s + "f";
	}

	/// <summary>
	/// Escape a user string for embedding inside a REGULAR C# string literal ("...") in
	/// generated code: backslash-escape \ and ", strip/escape control chars. (EscVerbatim-style
	/// quote-doubling is only valid inside @"" literals — the generated property defaults and
	/// list initializers are regular literals, caught live by the quote-in-need-name test.)
	/// </summary>
	public static string EscString( string raw )
	{
		return ( raw ?? "" )
			.Replace( "\\", "\\\\" )
			.Replace( "\"", "\\\"" )
			.Replace( "\r", "\\r" )
			.Replace( "\n", "\\n" )
			.Replace( "\t", "\\t" );
	}

	/// <summary>
	/// Attach the generated component to a scene GameObject by GUID — only possible if
	/// the type is ALREADY in the TypeLibrary (i.e. after a hotload). Mirrors the proven
	/// PlaceOnTarget in ScaffoldHandlers/EconomySaveHandlers.
	/// </summary>
	public static object PlaceOnTarget( string targetId, string className, out string note )
	{
		note = null;
		var scene = SceneEditorSession.Active?.Scene;
		if ( scene == null ) { note = "No active scene to place into."; return null; }
		if ( !Guid.TryParse( targetId, out var guid ) ) { note = "Invalid targetId GUID."; return null; }
		var go = scene.Directory.FindByGuid( guid );
		if ( go == null ) { note = $"Target GameObject not found: {targetId}"; return null; }
		var typeDesc = Game.TypeLibrary.GetType( className );
		if ( typeDesc == null )
		{
			note = $"Generated {className}.cs but it is not in the TypeLibrary yet — trigger_hotload, then add it with add_component_with_properties.";
			return null;
		}
		try { go.Components.Create( typeDesc ); return ClaudeBridge.SerializeGo( go ); }
		catch ( Exception ex ) { note = $"Placement failed ({ex.Message})."; return null; }
	}
}

// ═══════════════════════════════════════════════════════════════════════════
//  1. create_needs_system  (code-gen; scene-mutating)
//     Sim/tycoon needs engine: [Property] list of need definitions, per-need
//     0..100 values decaying over Time.Delta, Satisfy(name, amount), weighted-
//     mean Happiness, static OnNeedCritical / OnHappinessChanged events.
// ═══════════════════════════════════════════════════════════════════════════
public class CreateNeedsSystemHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			if ( !ScaffoldHelpers.PrepareCodeFile( p, "NeedsSystem", out var fullPath, out var relPath, out var className, out var err ) )
				return Task.FromResult<object>( err );

			var networked = AiSystemsHelpers.Bool( p, "networked", true );

			// ── Need definitions: explicit `needs` array wins, else the classic sim trio.
			var needLines = new StringBuilder();
			var needNames = new List<string>();
			if ( p.TryGetProperty( "needs", out var arr ) && arr.ValueKind == JsonValueKind.Array && arr.GetArrayLength() > 0 )
			{
				foreach ( var e in arr.EnumerateArray() )
				{
					var nName  = AiSystemsHelpers.Str(   e, "name", "Need" );
					var decay  = AiSystemsHelpers.Float( e, "decayPerSecond", 0.5f );
					var crit   = AiSystemsHelpers.Float( e, "criticalThreshold", 20f );
					var weight = AiSystemsHelpers.Float( e, "weight", 1f );
					needNames.Add( nName );
					needLines.Append( "\t\tnew NeedDefinition { Name = \"" + AiSystemsHelpers.EscString( nName )
						+ "\", DecayPerSecond = " + AiSystemsHelpers.F( decay )
						+ ", CriticalThreshold = " + AiSystemsHelpers.F( crit )
						+ ", Weight = " + AiSystemsHelpers.F( weight ) + " },\n" );
				}
			}
			else
			{
				needNames.AddRange( new[] { "Hunger", "Energy", "Fun" } );
				needLines.Append( "\t\tnew NeedDefinition { Name = \"Hunger\", DecayPerSecond = 0.8f, CriticalThreshold = 20f, Weight = 1f },\n" );
				needLines.Append( "\t\tnew NeedDefinition { Name = \"Energy\", DecayPerSecond = 0.5f, CriticalThreshold = 15f, Weight = 1f },\n" );
				needLines.Append( "\t\tnew NeedDefinition { Name = \"Fun\", DecayPerSecond = 0.3f, CriticalThreshold = 10f, Weight = 0.5f },\n" );
			}

			var code = BuildSource( className, networked, needLines.ToString() );
			ScaffoldHelpers.WriteCode( fullPath, code );

			object placedOn = null; string placeNote = null;
			if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
				placedOn = AiSystemsHelpers.PlaceOnTarget( tid.GetString(), className, out placeNote );

			return Task.FromResult<object>( new
			{
				created = true,
				path = relPath,
				className,
				networked,
				needs = needNames,
				propertyNames = new[] { "Needs", "Happiness" },
				placedOn,
				placementNote = placeNote,
				note = "Per-need values live on the simulating machine only (read with GetNeed(name), restore with Satisfy(name, amount)); " +
				       "the aggregate Happiness (weighted mean 0..100) " +
				       ( networked
				         ? "is [Sync(FromHost)] so clients can read it. Host-authoritative: decay + Satisfy only run on the host — route client actions through an [Rpc.Host] method that calls Satisfy. A no-session solo playtest makes everything a proxy (use networked:false to iterate solo). "
				         : "updates locally (networked:false build — no [Sync], no proxy guard; ticks in a single-machine playtest). " ) +
				       "OnNeedCritical is edge-triggered (fires once crossing below threshold, re-arms above it); OnHappinessChanged fires on >0.25-point moves. " +
				       "Both static events fire on the simulating machine only. Needs list is inspector-editable per instance."
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"create_needs_system failed: {ex.Message}" } );
		}
	}

	private static string BuildSource( string className, bool networked, string needLines )
	{
		var syncAttr    = networked ? "[Sync( SyncFlags.FromHost )] " : "";
		var updateGuard = networked ? "\t\tif ( IsProxy ) return;   // host-authoritative — only the host decays\n\n" : "";
		var satisfyGuard= networked ? "\t\tif ( IsProxy ) return;\n" : "";
		var headerNote  = networked
			? "// Host-authoritative needs engine. Only the host decays/mutates needs; the aggregate\n// Happiness is [Sync]'d so clients can read it. Per-need values live host-side only.\n"
			: "// Local needs engine (networked:false — no [Sync], no proxy guard). Ticks in a\n// single-machine playtest; every machine runs its own copy if used networked.\n";

		return
$@"using Sandbox;
using System;
using System.Collections.Generic;

{headerNote}public sealed class {className} : Component
{{
	/// <summary>One tunable need: value starts at 100 and decays toward 0 at DecayPerSecond.</summary>
	public sealed class NeedDefinition
	{{
		public string Name {{ get; set; }} = ""Need"";
		public float DecayPerSecond {{ get; set; }} = 0.5f;    // points lost per second (0..100 scale)
		public float CriticalThreshold {{ get; set; }} = 20f;  // OnNeedCritical fires when value falls below this
		public float Weight {{ get; set; }} = 1f;              // contribution to the Happiness weighted mean
	}}

	[Property] public List<NeedDefinition> Needs {{ get; set; }} = new()
	{{
{needLines}	}};

	/// <summary>Weighted mean of all need values, 0..100.</summary>
	{syncAttr}public float Happiness {{ get; private set; }} = 100f;

	/// <summary>Fires on the simulating machine when a need first crosses below its critical threshold. Re-arms when satisfied back above it.</summary>
	public static Action<{className}, string> OnNeedCritical {{ get; set; }}
	/// <summary>Fires when Happiness moves by more than 0.25 points. Arg = new happiness.</summary>
	public static Action<{className}, float> OnHappinessChanged {{ get; set; }}

	private readonly Dictionary<string, float> _values = new();
	private readonly HashSet<string> _critical = new();
	private float _lastHappiness = -1f;

	protected override void OnStart()
	{{
		foreach ( var need in Needs )
			if ( need != null && !string.IsNullOrEmpty( need.Name ) && !_values.ContainsKey( need.Name ) )
				_values[need.Name] = 100f;
	}}

	protected override void OnUpdate()
	{{
{updateGuard}		foreach ( var need in Needs )
		{{
			if ( need == null || string.IsNullOrEmpty( need.Name ) ) continue;
			if ( !_values.TryGetValue( need.Name, out var v ) ) {{ v = 100f; }}

			var nv = MathX.Clamp( v - need.DecayPerSecond * Time.Delta, 0f, 100f );
			_values[need.Name] = nv;

			// Edge-triggered: fires once on crossing below threshold, re-arms above it.
			if ( nv < need.CriticalThreshold )
			{{
				if ( _critical.Add( need.Name ) ) OnNeedCritical?.Invoke( this, need.Name );
			}}
			else
			{{
				_critical.Remove( need.Name );
			}}
		}}

		RecomputeHappiness();
	}}

	/// <summary>Current value (0..100) of a need by name, or -1 if unknown.</summary>
	public float GetNeed( string name )
		=> name != null && _values.TryGetValue( name, out var v ) ? v : -1f;

	/// <summary>Restore a need by amount (clamped 0..100).</summary>
	public void Satisfy( string name, float amount )
	{{
{satisfyGuard}		if ( name == null || !_values.ContainsKey( name ) ) return;
		_values[name] = MathX.Clamp( _values[name] + amount, 0f, 100f );
		RecomputeHappiness();
	}}

	private void RecomputeHappiness()
	{{
		float total = 0f, weight = 0f;
		foreach ( var need in Needs )
		{{
			if ( need == null || string.IsNullOrEmpty( need.Name ) ) continue;
			if ( !_values.TryGetValue( need.Name, out var v ) ) continue;
			total += v * need.Weight;
			weight += need.Weight;
		}}
		var h = weight > 0f ? total / weight : 100f;
		if ( System.MathF.Abs( h - _lastHappiness ) > 0.25f )
		{{
			_lastHappiness = h;
			Happiness = h;
			OnHappinessChanged?.Invoke( this, h );
		}}
	}}
}}
";
	}
}

// ═══════════════════════════════════════════════════════════════════════════
//  2. create_utility_ai  (code-gen; scene-mutating)
//     Scored-action brain: abstract {Prefix}Action base (Score 0..1 +
//     Begin/Tick/End) + sealed {Prefix}Brain that picks the highest-scoring
//     sibling action every EvaluateInterval (hysteresis bonus prevents
//     flip-flopping) + two example actions (Idle, Wander).
// ═══════════════════════════════════════════════════════════════════════════
public class CreateUtilityAiHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			var rawName   = AiSystemsHelpers.Str( p, "name", "Utility" );
			if ( rawName.EndsWith( ".cs", StringComparison.OrdinalIgnoreCase ) )
				rawName = rawName.Substring( 0, rawName.Length - 3 );
			var directory = AiSystemsHelpers.Str( p, "directory", "Code" );

			var prefix   = ClaudeBridge.SanitizeIdentifier( rawName, "Utility" );
			var fileName = $"{prefix}Ai.cs";
			if ( !ClaudeBridge.TryResolveProjectPath( Path.Combine( directory, fileName ), out var fullPath, out var pathErr ) )
				return Task.FromResult<object>( new { error = pathErr } );
			if ( File.Exists( fullPath ) )
				return Task.FromResult<object>( new { error = $"File already exists: {directory}/{fileName}. Choose a different name." } );

			var evaluateInterval = AiSystemsHelpers.Float( p, "evaluateInterval", 0.25f );
			var hysteresisBonus  = AiSystemsHelpers.Float( p, "hysteresisBonus",  0.15f );
			var moveSpeed        = AiSystemsHelpers.Float( p, "moveSpeed",        80f );
			var wanderRadius     = AiSystemsHelpers.Float( p, "wanderRadius",     300f );
			var networked        = AiSystemsHelpers.Bool(  p, "networked",        true );

			var brainName  = $"{prefix}Brain";
			var actionBase = $"{prefix}Action";
			var idleName   = $"{prefix}IdleAction";
			var wanderName = $"{prefix}WanderAction";

			var code = BuildSource( brainName, actionBase, idleName, wanderName, networked,
				evaluateInterval, hysteresisBonus, moveSpeed, wanderRadius );

			Directory.CreateDirectory( Path.GetDirectoryName( fullPath ) );
			ScaffoldHelpers.WriteCode( fullPath, code );

			object placedOn = null; string placeNote = null;
			if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
				placedOn = AiSystemsHelpers.PlaceOnTarget( tid.GetString(), brainName, out placeNote );

			return Task.FromResult<object>( new
			{
				created = true,
				path = $"{directory}/{fileName}",
				classNames = new[] { actionBase, brainName, idleName, wanderName },
				networked,
				propertyNames = new[] { "EvaluateInterval", "HysteresisBonus", "ScoreWeight", "BaseScore", "MoveSpeed", "WanderRadius", "SecondsToFullDesire" },
				placedOn,
				placementNote = placeNote,
				note = $"Utility AI vs create_npc_brain: the FSM brain has FIXED transitions (Idle→Chase→Search…); this brain has NO transition table — " +
				       $"every {actionBase} sibling self-scores 0..1 each EvaluateInterval and the highest (score × ScoreWeight, current action +HysteresisBonus) wins, " +
				       "so behavior emerges from the scores. Add behaviors by subclassing the abstract base ON THE SAME GameObject as the brain " +
				       "(targetId placement attaches ONLY the brain — add the example actions with add_component_with_properties after a hotload). " +
				       "The two examples alternate emergently: Wander desire builds while idle, collapses on arrival. Wander moves by direct transform walk (no navmesh, walks through walls). " +
				       ( networked
				         ? "Networked: host-authoritative (IsProxy guard) + [Sync] CurrentActionName — needs a host session; use networked:false to iterate solo."
				         : "Solo/local build: no proxy guard, ticks in a single-machine playtest." )
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"create_utility_ai failed: {ex.Message}" } );
		}
	}

	private static string BuildSource(
		string brainName, string actionBase, string idleName, string wanderName, bool networked,
		float evaluateInterval, float hysteresisBonus, float moveSpeed, float wanderRadius )
	{
		string F( float v ) => AiSystemsHelpers.F( v );

		var syncAttr   = networked ? "[Sync( SyncFlags.FromHost )] " : "";
		var proxyGuard = networked ? "\t\tif ( IsProxy ) return;   // host-authoritative — only the host thinks\n\n" : "";
		var headerNote = networked
			? "// Host-authoritative: only the host evaluates + ticks actions; CurrentActionName is\n// [Sync]'d for client UI. A no-session solo playtest makes everything a proxy —\n// generate with networked:false to iterate solo.\n"
			: "// Solo / local brain (networked:false — no proxy guard). Ticks in a single-machine playtest.\n";

		return
$@"using Sandbox;
using System;

// Utility AI — scored-action brain. Unlike an FSM (fixed transition table), actions
// self-score 0..1 every EvaluateInterval and the highest score wins (emergent switching).
// Add more actions by subclassing {actionBase} on the same GameObject.
{headerNote}
/// <summary>Base class for utility actions. Put subclasses on the SAME GameObject as the brain.</summary>
public abstract class {actionBase} : Component
{{
	/// <summary>Multiplier applied to Score() — raise to bias this action.</summary>
	[Property] public float ScoreWeight {{ get; set; }} = 1f;

	/// <summary>Desirability this instant, 0..1. Highest-scoring sibling action wins.</summary>
	public abstract float Score();

	/// <summary>Called once when this action becomes the active one.</summary>
	public virtual void Begin() {{ }}
	/// <summary>Called every frame while this action is active.</summary>
	public virtual void Tick() {{ }}
	/// <summary>Called once when a better-scoring action takes over.</summary>
	public virtual void End() {{ }}
}}

/// <summary>Picks and runs the highest-scoring sibling {actionBase}.</summary>
public sealed class {brainName} : Component
{{
	/// <summary>Seconds between score evaluations (the active action Ticks every frame regardless).</summary>
	[Property] public float EvaluateInterval {{ get; set; }} = {F( evaluateInterval )};
	/// <summary>Score bonus the CURRENT action gets during evaluation — hysteresis so near-ties don't flip-flop.</summary>
	[Property] public float HysteresisBonus {{ get; set; }} = {F( hysteresisBonus )};

	{syncAttr}public string CurrentActionName {{ get; private set; }} = """";

	public {actionBase} Current {{ get; private set; }}

	/// <summary>Fires on the simulating machine when the active action changes. Args = brain, new action type name.</summary>
	public static Action<{brainName}, string> OnActionChanged {{ get; set; }}

	private TimeSince _sinceEval;

	protected override void OnStart()
	{{
		_sinceEval = 999f;   // evaluate on the first eligible frame
	}}

	protected override void OnUpdate()
	{{
{proxyGuard}		if ( _sinceEval >= EvaluateInterval )
		{{
			_sinceEval = 0f;
			Evaluate();
		}}

		if ( Current != null && Current.IsValid() && Current.Active )
			Current.Tick();
	}}

	private void Evaluate()
	{{
		{actionBase} best = null;
		float bestScore = float.MinValue;

		foreach ( var action in Components.GetAll<{actionBase}>() )
		{{
			if ( action == null || !action.IsValid() || !action.Active ) continue;
			float score = MathX.Clamp( action.Score(), 0f, 1f ) * action.ScoreWeight;
			if ( action == Current ) score += HysteresisBonus;
			if ( score > bestScore ) {{ bestScore = score; best = action; }}
		}}

		if ( best == Current ) return;

		if ( Current != null && Current.IsValid() ) Current.End();
		Current = best;
		CurrentActionName = best != null ? best.GetType().Name : """";
		if ( best != null ) best.Begin();
		OnActionChanged?.Invoke( this, CurrentActionName );
	}}
}}

/// <summary>Example action: constant low score — the fallback when nothing else wants to run.</summary>
public sealed class {idleName} : {actionBase}
{{
	[Property] public float BaseScore {{ get; set; }} = 0.1f;

	public override float Score() => BaseScore;
}}

/// <summary>Example action: desire builds while not wandering; walks to random points near home, then resets.</summary>
public sealed class {wanderName} : {actionBase}
{{
	[Property] public float MoveSpeed {{ get; set; }} = {F( moveSpeed )};
	[Property] public float WanderRadius {{ get; set; }} = {F( wanderRadius )};
	/// <summary>Seconds of not-wandering until desire reaches 1.0.</summary>
	[Property] public float SecondsToFullDesire {{ get; set; }} = 6f;

	private Vector3 _home;
	private Vector3 _target;
	private TimeSince _sinceSatisfied;

	protected override void OnStart()
	{{
		_home = WorldPosition;
		_target = WorldPosition;
		_sinceSatisfied = 0f;
	}}

	public override float Score()
		=> MathX.Clamp( _sinceSatisfied / System.MathF.Max( SecondsToFullDesire, 0.1f ), 0f, 1f );

	public override void Begin() => PickTarget();

	public override void Tick()
	{{
		var flat = ( _target - WorldPosition ).WithZ( 0f );
		if ( flat.Length <= 8f )
		{{
			_sinceSatisfied = 0f;   // reached — desire collapses, idle takes over until it rebuilds
			PickTarget();
			return;
		}}

		var step = flat.Normal * MoveSpeed * Time.Delta;
		if ( step.Length > flat.Length ) step = flat;
		WorldPosition += step;
		WorldRotation = Rotation.LookAt( flat.Normal );
	}}

	private void PickTarget()
	{{
		_target = _home + new Vector3(
			Random.Shared.Float( -WanderRadius, WanderRadius ),
			Random.Shared.Float( -WanderRadius, WanderRadius ),
			0f );
	}}
}}
";
	}
}

// ═══════════════════════════════════════════════════════════════════════════
//  3. create_npc_schedule_brain  (code-gen; scene-mutating)
//     Daily-routine NPC: schedule entries (startHour/endHour/task/target),
//     reads the hour from any create_day_night_clock component (capability
//     match: float TimeOfDay), falls back to an internal clock, walks to the
//     active task's target, idles outside the schedule. Static OnTaskChanged.
// ═══════════════════════════════════════════════════════════════════════════
public class CreateNpcScheduleBrainHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			if ( !ScaffoldHelpers.PrepareCodeFile( p, "NpcScheduleBrain", out var fullPath, out var relPath, out var className, out var err ) )
				return Task.FromResult<object>( err );

			var moveSpeed      = AiSystemsHelpers.Float( p, "moveSpeed", 100f );
			var arriveDistance = AiSystemsHelpers.Float( p, "arriveDistance", 32f );
			var fallbackDayLen = AiSystemsHelpers.Float( p, "fallbackDayLengthSeconds", 600f );
			var fallbackStart  = AiSystemsHelpers.Float( p, "fallbackStartHour", 8f );
			var useNavMesh     = AiSystemsHelpers.Bool(  p, "useNavMeshAgent", false );
			var networked      = AiSystemsHelpers.Bool(  p, "networked", true );

			// ── Schedule entries: explicit `schedule` array wins, else a work/relax default.
			var entryLines = new StringBuilder();
			var taskNames  = new List<string>();
			if ( p.TryGetProperty( "schedule", out var arr ) && arr.ValueKind == JsonValueKind.Array && arr.GetArrayLength() > 0 )
			{
				foreach ( var e in arr.EnumerateArray() )
				{
					var start  = AiSystemsHelpers.Float( e, "startHour", 8f );
					var end    = AiSystemsHelpers.Float( e, "endHour", 17f );
					var task   = AiSystemsHelpers.Str(   e, "taskName", "Task" );
					var target = AiSystemsHelpers.Str(   e, "targetName", "" );
					taskNames.Add( task );

					var line = "\t\tnew ScheduleEntry { StartHour = " + AiSystemsHelpers.F( start )
						+ ", EndHour = " + AiSystemsHelpers.F( end )
						+ ", TaskName = \"" + AiSystemsHelpers.EscString( task ) + "\"";
					if ( !string.IsNullOrEmpty( target ) )
						line += ", TargetName = \"" + AiSystemsHelpers.EscString( target ) + "\"";
					if ( e.TryGetProperty( "targetPosition", out var posEl ) && posEl.ValueKind != JsonValueKind.Null )
					{
						var v = ClaudeBridge.ParseVector3( posEl );
						line += ", TargetPosition = new Vector3( " + AiSystemsHelpers.F( v.x ) + ", " + AiSystemsHelpers.F( v.y ) + ", " + AiSystemsHelpers.F( v.z ) + " )";
					}
					entryLines.Append( line + " },\n" );
				}
			}
			else
			{
				taskNames.AddRange( new[] { "Work", "Relax" } );
				entryLines.Append( "\t\tnew ScheduleEntry { StartHour = 8f, EndHour = 17f, TaskName = \"Work\", TargetName = \"WorkSpot\" },\n" );
				entryLines.Append( "\t\tnew ScheduleEntry { StartHour = 17f, EndHour = 22f, TaskName = \"Relax\", TargetName = \"HomeSpot\" },\n" );
			}

			var code = BuildSource( className, networked, useNavMesh, entryLines.ToString(),
				moveSpeed, arriveDistance, fallbackDayLen, fallbackStart );
			ScaffoldHelpers.WriteCode( fullPath, code );

			object placedOn = null; string placeNote = null;
			if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
				placedOn = AiSystemsHelpers.PlaceOnTarget( tid.GetString(), className, out placeNote );

			return Task.FromResult<object>( new
			{
				created = true,
				path = relPath,
				className,
				networked,
				useNavMeshAgent = useNavMesh,
				tasks = taskNames,
				propertyNames = new[] { "Schedule", "MoveSpeed", "ArriveDistance", "FallbackDayLengthSeconds", "FallbackStartHour" },
				placedOn,
				placementNote = placeNote,
				note = "Time source: binds by CAPABILITY to any component exposing a float TimeOfDay property (the create_day_night_clock contract) — " +
				       "same GameObject first, then scene-wide, re-scanned every 5s while unbound. If NO clock exists it honestly falls back to its own " +
				       "internal clock (FallbackDayLengthSeconds per 24h, starting at FallbackStartHour) — check UsingClockComponent at runtime. " +
				       "A clock with a different shape (e.g. a 0..1 DayProgress) will NOT bind — generate a create_day_night_clock or match the contract. " +
				       "Entries with EndHour < StartHour wrap past midnight. TargetName resolves a scene GameObject by name (case-insensitive, cached per task); " +
				       "missing names mean the NPC idles. Outside every entry the NPC idles in place. " +
				       ( useNavMesh
				         ? "Movement: NavMeshAgent.MoveTo — REQUIRES a baked navmesh (bake_navmesh) or the NPC won't move. "
				         : "Movement: direct transform walk (no navmesh, walks through walls — pass useNavMeshAgent:true for pathfinding). " ) +
				       ( networked
				         ? "Networked: host-authoritative (IsProxy guard) + [Sync] CurrentTask — needs a host session; use networked:false to iterate solo."
				         : "Solo/local build: no proxy guard, ticks in a single-machine playtest." )
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"create_npc_schedule_brain failed: {ex.Message}" } );
		}
	}

	private static string BuildSource(
		string className, bool networked, bool useNavMesh, string entryLines,
		float moveSpeed, float arriveDistance, float fallbackDayLen, float fallbackStart )
	{
		string F( float v ) => AiSystemsHelpers.F( v );

		var syncAttr   = networked ? "[Sync( SyncFlags.FromHost )] " : "";
		var proxyGuard = networked ? "\t\tif ( IsProxy ) return;   // host-authoritative — only the host routes\n\n" : "";
		var headerNote = networked
			? "// Host-authoritative daily-routine brain. Only the host reads the clock and moves the\n// NPC; CurrentTask is [Sync]'d for client UI. A no-session solo playtest makes everything\n// a proxy — generate with networked:false to iterate solo.\n"
			: "// Solo / local daily-routine brain (networked:false — no proxy guard).\n";

		// NavMeshAgent variant swaps the movement body; MoveTo/Stop/MaxSpeed are the same
		// calls the shipped create_npc_brain generator emits (proven sandbox surface).
		var agentField   = useNavMesh ? "\tprivate NavMeshAgent _agent;\n" : "";
		var agentOnStart = useNavMesh ? "\t\t_agent = GetOrAddComponent<NavMeshAgent>();\n" : "";
		var moveBody = useNavMesh
			?
@"		var flat = ( target - WorldPosition ).WithZ( 0f );
		if ( flat.Length <= ArriveDistance ) { _agent.Stop(); return; }   // arrived — idle at the task spot
		_agent.MaxSpeed = MoveSpeed;
		_agent.MoveTo( target );"
			:
@"		var flat = ( target - WorldPosition ).WithZ( 0f );
		if ( flat.Length <= ArriveDistance ) return;   // arrived — idle at the task spot

		var step = flat.Normal * MoveSpeed * Time.Delta;
		if ( step.Length > flat.Length ) step = flat;
		WorldPosition += step;
		WorldRotation = Rotation.LookAt( flat.Normal );";

		return
$@"using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

// Daily-routine NPC brain. Reads the hour from any create_day_night_clock component
// (capability match: a float TimeOfDay property) found on this GameObject or in the
// scene; falls back to its own internal clock when none exists. Walks the NPC to the
// active schedule entry's target and idles outside the schedule.
{headerNote}public sealed class {className} : Component
{{
	/// <summary>One routine block. EndHour smaller than StartHour wraps past midnight (e.g. 22 -> 6).</summary>
	public sealed class ScheduleEntry
	{{
		public float StartHour {{ get; set; }} = 8f;    // inclusive, 0..24
		public float EndHour {{ get; set; }} = 17f;     // exclusive
		public string TaskName {{ get; set; }} = ""Task"";
		public string TargetName {{ get; set; }} = """";  // named scene GameObject to walk to (wins over TargetPosition)
		public Vector3 TargetPosition {{ get; set; }}   // fixed world position, used when TargetName is empty
	}}

	[Property] public List<ScheduleEntry> Schedule {{ get; set; }} = new()
	{{
{entryLines}	}};

	[Property] public float MoveSpeed {{ get; set; }} = {F( moveSpeed )};
	[Property] public float ArriveDistance {{ get; set; }} = {F( arriveDistance )};

	// Internal fallback clock — used ONLY when no TimeOfDay clock component is found.
	[Property] public float FallbackDayLengthSeconds {{ get; set; }} = {F( fallbackDayLen )};
	[Property] public float FallbackStartHour {{ get; set; }} = {F( fallbackStart )};

	{syncAttr}public string CurrentTask {{ get; private set; }} = """";

	/// <summary>The hour (0..24) currently driving the schedule.</summary>
	public float CurrentHour {{ get; private set; }}
	/// <summary>True when bound to a scene clock component, false when on the internal fallback.</summary>
	public bool UsingClockComponent => _clock != null && _clock.IsValid();

	/// <summary>Fires on the simulating machine when the active task changes. Args = brain, new task name ("""" = idle).</summary>
	public static Action<{className}, string> OnTaskChanged {{ get; set; }}

	private Component _clock;
	private PropertyDescription _hourProp;
	private float _fallbackHour;
	private GameObject _targetGo;
	private string _resolvedTargetName;
	private RealTimeSince _sinceClockScan;
{agentField}
	protected override void OnStart()
	{{
{agentOnStart}		_fallbackHour = MathX.Clamp( FallbackStartHour, 0f, 24f );
		_sinceClockScan = 999f;
	}}

	protected override void OnUpdate()
	{{
{proxyGuard}		// Bind (and occasionally re-bind) to a clock — one may hotload/spawn later.
		if ( ( _clock == null || !_clock.IsValid() ) && _sinceClockScan > 5f )
			TryBindClock();

		CurrentHour = ReadHour();

		var entry = ActiveEntry( CurrentHour );
		var task = entry != null ? ( entry.TaskName ?? """" ) : """";
		if ( task != CurrentTask )
		{{
			CurrentTask = task;
			_targetGo = null;
			_resolvedTargetName = null;
			OnTaskChanged?.Invoke( this, task );
		}}

		if ( entry == null ) return;   // outside every schedule block — idle in place

		var target = ResolveTarget( entry );
		if ( target == null ) return;
		MoveToward( target.Value );
	}}

	private void TryBindClock()
	{{
		_sinceClockScan = 0f;
		_clock = null;
		_hourProp = null;
		if ( Scene == null ) return;

		// Same-GameObject components first, then the whole scene. Capability match:
		// a float TimeOfDay property (the create_day_night_clock contract).
		var candidates = Components.GetAll<Component>().Concat( Scene.GetAllComponents<Component>() );
		foreach ( var c in candidates )
		{{
			if ( c == null || c == this || !c.IsValid() ) continue;
			var td = TypeLibrary.GetType( c.GetType() );
			if ( td == null ) continue;
			var hour = td.Properties.FirstOrDefault( x => x.Name == ""TimeOfDay"" && x.PropertyType == typeof( float ) );
			if ( hour == null ) continue;
			_clock = c;
			_hourProp = hour;
			return;
		}}
	}}

	private float ReadHour()
	{{
		if ( _clock != null && _clock.IsValid() && _hourProp != null )
		{{
			var v = _hourProp.GetValue( _clock );
			if ( v is float f ) return MathX.Clamp( f, 0f, 24f );
		}}

		// Internal fallback: 24 in-game hours elapse per FallbackDayLengthSeconds.
		_fallbackHour += ( 24f / MathX.Clamp( FallbackDayLengthSeconds, 1f, 86400f ) ) * Time.Delta;
		while ( _fallbackHour >= 24f ) _fallbackHour -= 24f;
		return _fallbackHour;
	}}

	private ScheduleEntry ActiveEntry( float hour )
	{{
		if ( Schedule == null ) return null;
		foreach ( var e in Schedule )
		{{
			if ( e == null ) continue;
			bool active = e.StartHour <= e.EndHour
				? hour >= e.StartHour && hour < e.EndHour
				: hour >= e.StartHour || hour < e.EndHour;   // wraps past midnight
			if ( active ) return e;
		}}
		return null;
	}}

	private Vector3? ResolveTarget( ScheduleEntry entry )
	{{
		if ( !string.IsNullOrEmpty( entry.TargetName ) )
		{{
			if ( _targetGo != null && _targetGo.IsValid() && _resolvedTargetName == entry.TargetName )
				return _targetGo.WorldPosition;

			_targetGo = FindByNameRecursive( Scene, entry.TargetName );
			_resolvedTargetName = entry.TargetName;
			if ( _targetGo != null && _targetGo.IsValid() ) return _targetGo.WorldPosition;
			return null;   // named target missing from the scene — idle
		}}
		return entry.TargetPosition;
	}}

	private static GameObject FindByNameRecursive( GameObject root, string name )
	{{
		if ( root == null ) return null;
		foreach ( var child in root.Children )
		{{
			if ( child == null ) continue;
			if ( string.Equals( child.Name, name, StringComparison.OrdinalIgnoreCase ) ) return child;
			var found = FindByNameRecursive( child, name );
			if ( found != null ) return found;
		}}
		return null;
	}}

	private void MoveToward( Vector3 target )
	{{
{moveBody}
	}}
}}
";
	}
}

// ═══════════════════════════════════════════════════════════════════════════
//  4. create_event_bus  (code-gen; scene-mutating [writes a file])
//     Typed LOCAL pub/sub: static class with Subscribe<T>(owner, Action<T>),
//     Unsubscribe(owner), Publish<T>(evt). Plain owner-keyed handler lists —
//     no weak refs; owners must Unsubscribe in OnDestroy. Not a Component.
// ═══════════════════════════════════════════════════════════════════════════
public class CreateEventBusHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			if ( !ScaffoldHelpers.PrepareCodeFile( p, "EventBus", out var fullPath, out var relPath, out var className, out var err ) )
				return Task.FromResult<object>( err );

			var code = BuildSource( className );
			ScaffoldHelpers.WriteCode( fullPath, code );

			return Task.FromResult<object>( new
			{
				created = true,
				path = relPath,
				className,
				exampleEvent = $"{className}Ping",
				api = new[] { "Subscribe<T>(object owner, Action<T> handler)", "Unsubscribe(object owner)", "Publish<T>(T evt)", "Count<T>()", "Clear()" },
				note = "Pure STATIC class — nothing to place in the scene (no targetId). LOCAL only: Publish runs handlers synchronously on the " +
				       "publishing machine, exact-type-T subscribers only (no base-type dispatch); NOT networked — pair with [Rpc.Broadcast]/[Rpc.Host] " +
				       "methods that Publish on arrival for networked events. Handler lists hold PLAIN references (no weak refs): every subscriber MUST " +
				       "call Unsubscribe(this) in OnDestroy or the handler AND the owner leak for the scene's life; call Clear() on scene teardown. " +
				       $"A tiny example event record ({className}Ping) is included — define your own events as small records/classes."
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"create_event_bus failed: {ex.Message}" } );
		}
	}

	private static string BuildSource( string className )
	{
		return
$@"using System;
using System.Collections.Generic;

/// <summary>
/// {className} — typed LOCAL pub/sub. Subscribe with an owner object, publish typed
/// events, handlers run synchronously on the publishing machine. NOT networked — pair
/// with [Rpc.Broadcast] / [Rpc.Host] methods that Publish on arrival for networked events.
///
/// Handler lists hold PLAIN references (no weak refs): every subscriber MUST call
/// Unsubscribe(this) in OnDestroy, or the handler AND the owner leak for the scene's life.
/// </summary>
public static class {className}
{{
	private static readonly Dictionary<Type, List<(object Owner, Delegate Handler)>> _subs = new();

	/// <summary>Register a handler for events of type T. owner is your component (used by Unsubscribe).</summary>
	public static void Subscribe<T>( object owner, Action<T> handler )
	{{
		if ( owner == null || handler == null ) return;
		if ( !_subs.TryGetValue( typeof( T ), out var list ) )
		{{
			list = new List<(object, Delegate)>();
			_subs[typeof( T )] = list;
		}}
		list.Add( (owner, handler) );
	}}

	/// <summary>Remove ALL handlers registered by this owner, across every event type. Call in OnDestroy.</summary>
	public static void Unsubscribe( object owner )
	{{
		if ( owner == null ) return;
		foreach ( var list in _subs.Values )
			list.RemoveAll( s => ReferenceEquals( s.Owner, owner ) );
	}}

	/// <summary>Deliver evt to every exact-type-T subscriber, synchronously, in subscribe order.</summary>
	public static void Publish<T>( T evt )
	{{
		if ( !_subs.TryGetValue( typeof( T ), out var list ) || list.Count == 0 ) return;

		// Snapshot so a handler may Subscribe/Unsubscribe mid-publish safely.
		foreach ( var sub in list.ToArray() )
		{{
			if ( sub.Handler is Action<T> a ) a( evt );
		}}
	}}

	/// <summary>Handlers currently registered for T (diagnostics).</summary>
	public static int Count<T>() => _subs.TryGetValue( typeof( T ), out var l ) ? l.Count : 0;

	/// <summary>Drop every subscription — call on scene teardown / game restart.</summary>
	public static void Clear() => _subs.Clear();
}}

/// <summary>Example event — define your own as small records and Publish them.</summary>
public record {className}Ping( string Message );
";
	}
}

// ═══════════════════════════════════════════════════════════════════════════
//  5. add_tts_voice  (code-gen; scene-mutating)
//     TTS speaker component over the verified Sandbox.Speech.Synthesizer:
//     Say(text) → TrySetVoice → WithText → WithRate → Play() → SoundHandle,
//     stop-previous-on-say, positional/2D routing, optional viseme-data
//     extraction (Handle.LipSync.Enabled). Audio-only — see note for why
//     Sandbox.LipSync is not auto-wired.
// ═══════════════════════════════════════════════════════════════════════════
public class AddTtsVoiceHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			if ( !ScaffoldHelpers.PrepareCodeFile( p, "TtsSpeaker", out var fullPath, out var relPath, out var className, out var err ) )
				return Task.FromResult<object>( err );

			var voiceName     = AiSystemsHelpers.Str(   p, "voiceName", "" );
			var voiceGender   = AiSystemsHelpers.Str(   p, "voiceGender", "" );
			var voiceAge      = AiSystemsHelpers.Str(   p, "voiceAge", "" );
			var rate          = AiSystemsHelpers.Int(   p, "rate", 0 );
			var volume        = AiSystemsHelpers.Float( p, "volume", 1f );
			var positional    = AiSystemsHelpers.Bool(  p, "positional", true );
			var stopPrevious  = AiSystemsHelpers.Bool(  p, "stopPreviousOnSay", true );
			var stopFade      = AiSystemsHelpers.Float( p, "stopFadeSeconds", 0.1f );
			var enableVisemes = AiSystemsHelpers.Bool(  p, "enableVisemeData", false );

			var code = BuildSource( className,
				AiSystemsHelpers.EscString( voiceName ),
				AiSystemsHelpers.EscString( voiceGender ),
				AiSystemsHelpers.EscString( voiceAge ),
				rate, volume, positional, stopPrevious, stopFade, enableVisemes );
			ScaffoldHelpers.WriteCode( fullPath, code );

			object placedOn = null; string placeNote = null;
			if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
				placedOn = AiSystemsHelpers.PlaceOnTarget( tid.GetString(), className, out placeNote );

			return Task.FromResult<object>( new
			{
				created = true,
				path = relPath,
				className,
				propertyNames = new[] { "VoiceName", "VoiceGender", "VoiceAge", "Rate", "Volume", "Positional", "StopPreviousOnSay", "StopFadeSeconds", "EnableVisemeData" },
				placedOn,
				placementNote = placeNote,
				note = "Call <class>.Say(\"text\") from game code (LOCAL audio — wrap in [Rpc.Broadcast] for everyone to hear). " +
				       "The Synthesizer API surface compiles (verified live) but the editor cannot playtest audio, so RUNTIME behavior " +
				       "(actual speech, voice selection, viseme data) is UNVERIFIED — verify in play mode with your ears. " +
				       "Voice availability is machine/OS-specific: call LogVoices() in play mode to list installed voices; TrySetVoice is " +
				       "best-effort (falls back to the OS default). Gender/age hint strings (e.g. \"Female\"/\"Adult\") are passed through unvalidated. " +
				       "LIPSYNC: audio-only by design — s&box's Sandbox.LipSync component consumes a BaseSoundComponent (verified), not the raw " +
				       "SoundHandle TTS produces, and Synthesizer.OnVisemeReached's delegate arg types can't be confirmed via reflection, so neither is " +
				       "auto-wired. enableVisemeData:true sets Handle.LipSync.Enabled so your own mouth-drive code can read Handle.LipSync.Visemes (runtime-unverified)."
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"add_tts_voice failed: {ex.Message}" } );
		}
	}

	private static string BuildSource(
		string className, string voiceNameLit, string voiceGenderLit, string voiceAgeLit,
		int rate, float volume, bool positional, bool stopPrevious, float stopFade, bool enableVisemes )
	{
		string F( float v ) => AiSystemsHelpers.F( v );
		string B( bool b ) => b ? "true" : "false";

		return
$@"using Sandbox;
using System;

/// <summary>
/// {className} — speaks text through the OS speech synthesizer (Sandbox.Speech.Synthesizer).
/// LOCAL audio only: Say() synthesizes and plays on the calling machine. For networked
/// voice, call Say from inside an [Rpc.Broadcast] handler so every client speaks it.
/// </summary>
public sealed class {className} : Component
{{
	/// <summary>Exact installed OS voice name (see LogVoices). Empty = use VoiceGender/VoiceAge, or the OS default.</summary>
	[Property] public string VoiceName {{ get; set; }} = ""{voiceNameLit}"";
	/// <summary>Voice gender hint, used only when VoiceName is empty (e.g. ""Female"", ""Male""). Needs VoiceAge too.</summary>
	[Property] public string VoiceGender {{ get; set; }} = ""{voiceGenderLit}"";
	/// <summary>Voice age hint paired with VoiceGender (e.g. ""Adult"", ""Child"", ""Senior"").</summary>
	[Property] public string VoiceAge {{ get; set; }} = ""{voiceAgeLit}"";
	/// <summary>Speaking rate offset: negative = slower, positive = faster, 0 = normal.</summary>
	[Property] public int Rate {{ get; set; }} = {rate};
	[Property] public float Volume {{ get; set; }} = {F( volume )};
	/// <summary>True: 3D sound parented to this GameObject (follows the speaker). False: flat 2D voice on the listener.</summary>
	[Property] public bool Positional {{ get; set; }} = {B( positional )};
	/// <summary>Fade out any still-playing previous line when Say is called again.</summary>
	[Property] public bool StopPreviousOnSay {{ get; set; }} = {B( stopPrevious )};
	[Property] public float StopFadeSeconds {{ get; set; }} = {F( stopFade )};
	/// <summary>Enable viseme extraction on the played handle (read Handle.LipSync.Visemes from your own mouth-drive code).</summary>
	[Property] public bool EnableVisemeData {{ get; set; }} = {B( enableVisemes )};

	/// <summary>The most recent line's SoundHandle (null before the first Say).</summary>
	public SoundHandle Handle {{ get; private set; }}
	public bool IsSpeaking => Handle != null && Handle.IsValid && Handle.IsPlaying;

	/// <summary>Synthesize and play a line. Repeated calls interrupt the previous line when StopPreviousOnSay.</summary>
	public void Say( string text )
	{{
		if ( string.IsNullOrWhiteSpace( text ) ) return;

		if ( StopPreviousOnSay && Handle != null && Handle.IsPlaying )
			Handle.Stop( StopFadeSeconds );

		var synth = new Sandbox.Speech.Synthesizer();

		if ( !string.IsNullOrWhiteSpace( VoiceName ) )
			synth.TrySetVoice( VoiceName );
		else if ( !string.IsNullOrWhiteSpace( VoiceGender ) && !string.IsNullOrWhiteSpace( VoiceAge ) )
			synth.TrySetVoice( VoiceGender, VoiceAge );

		var handle = synth.WithText( text ).WithRate( Rate ).Play();
		if ( handle == null ) return;

		handle.Volume = Volume;
		if ( Positional )
		{{
			handle.Position = WorldPosition;
			handle.SetParent( GameObject );   // follows the speaker as it moves
		}}
		else
		{{
			handle.ListenLocal = true;
		}}

		if ( EnableVisemeData )
			handle.LipSync.Enabled = true;

		Handle = handle;
	}}

	/// <summary>Fade out the current line (no-op when nothing is playing).</summary>
	public void StopSpeaking()
	{{
		if ( Handle != null && Handle.IsPlaying ) Handle.Stop( StopFadeSeconds );
	}}

	/// <summary>Log every installed OS voice + the currently selected one (voice availability is machine-specific).</summary>
	public void LogVoices()
	{{
		var synth = new Sandbox.Speech.Synthesizer();
		if ( !string.IsNullOrWhiteSpace( VoiceName ) ) synth.TrySetVoice( VoiceName );
		foreach ( var v in synth.InstalledVoices )
			Log.Info( $""[{className}] voice: {{v}}"" );
		Log.Info( $""[{className}] selected: {{synth.CurrentVoice}}"" );
	}}

	protected override void OnDestroy()
	{{
		if ( Handle != null && Handle.IsPlaying ) Handle.Stop( 0f );
	}}
}}
";
	}
}