π s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
π Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=sboxskinsgg.claudebridge&take=20
Showing code results for query:
*
(74 total matches found)
Editor
library
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// debug_draw_* / debug_clear β visualize debug primitives in the scene.
//
// Ported from the Claude Bridge for Unity's debug_draw_* family. s&box has no
// bridge debug-viz; this fills the gap so a raycast hit / physics_overlap
// volume / trigger_zone bounds / NPC sight cone / patrol path can be SEEN
// (and screenshot-verified) instead of reasoned about blind.
//
// ONE component, dual render path:
// β’ EDIT scene β Gizmo.Draw.* inside ClaudeDebugDraw.DrawGizmos()
// β’ PLAY scene β Game.ActiveScene.DebugOverlay.* re-emitted each OnUpdate()
// A single NotSaved holder GameObject ("__ClaudeDebugDraw") stores the prim
// list; the draw handlers append, debug_clear destroys it.
//
// APIs reflected live on this SDK (describe_type, 2026-06-18):
// Gizmo.Draw: Line(a,b) Β· Arrow(from,to,len,width) Β· LineBBox(bbox) Β·
// LineSphere(Sphere,rings) Β· Color/LineThickness/IgnoreDepth
// Scene.DebugOverlay (DebugOverlaySystem):
// Line(from,to,color,dur,tx,overlay) Β· Box(BBox,color,dur,tx,overlay) Β·
// Sphere(Sphere,color,dur,tx,overlay)
//
// Must work WHILE playing β these are NOT added to _sceneMutatingCommands.
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
public enum DebugDrawKind { Line, Ray, Box, Sphere }
public sealed class DebugDrawPrim
{
public DebugDrawKind Kind;
public Vector3 A; // line/ray start Β· box/sphere center
public Vector3 B; // line/ray end
public Vector3 Size; // box full extents
public float Radius; // sphere
public Color Color = Color.Yellow;
public float Thickness = 2f;
}
/// <summary>
/// Holds bridge-issued debug primitives and renders them in both the editor
/// (DrawGizmos) and play mode (DebugOverlay). One per scene, NotSaved.
/// </summary>
public sealed class ClaudeDebugDraw : Component
{
public List<DebugDrawPrim> Prims { get; set; } = new();
protected override void DrawGizmos()
{
if ( Prims == null ) return;
foreach ( var p in Prims )
{
Gizmo.Draw.Color = p.Color;
Gizmo.Draw.LineThickness = p.Thickness;
Gizmo.Draw.IgnoreDepth = true;
switch ( p.Kind )
{
case DebugDrawKind.Line: Gizmo.Draw.Line( p.A, p.B ); break;
case DebugDrawKind.Ray: Gizmo.Draw.Arrow( p.A, p.B, 8f, 3f ); break;
case DebugDrawKind.Box: Gizmo.Draw.LineBBox( new BBox( p.A - p.Size * 0.5f, p.A + p.Size * 0.5f ) ); break;
case DebugDrawKind.Sphere: Gizmo.Draw.LineSphere( new Sphere( p.A, p.Radius ), 16 ); break;
}
}
}
protected override void OnUpdate()
{
if ( !Game.IsPlaying || Prims == null ) return;
var ov = Scene?.DebugOverlay;
if ( ov == null ) return;
const float dur = 0.1f; // refreshed every frame while in the list
var tx = global::Transform.Zero; // identity β world-space coords (Transform is global-namespace, not Sandbox.*)
foreach ( var p in Prims )
{
switch ( p.Kind )
{
case DebugDrawKind.Line:
case DebugDrawKind.Ray:
ov.Line( p.A, p.B, p.Color, dur, tx, true );
break;
case DebugDrawKind.Box:
ov.Box( new BBox( p.A - p.Size * 0.5f, p.A + p.Size * 0.5f ), p.Color, dur, tx, true );
break;
case DebugDrawKind.Sphere:
ov.Sphere( new Sphere( p.A, p.Radius ), p.Color, dur, tx, true );
break;
}
}
}
}
internal static class DebugDrawHelpers
{
static readonly CultureInfo Inv = CultureInfo.InvariantCulture;
// ponytail: one global holder per session, recreated if invalidated by a
// scene change / hotload. Debug viz is inherently global, so a single
// instance is correct β no per-call scene scan needed.
static ClaudeDebugDraw _holder;
public static Scene CurrentScene()
=> Game.IsPlaying ? Game.ActiveScene : SceneEditorSession.Active?.Scene;
public static ClaudeDebugDraw EnsureHolder()
{
var scene = CurrentScene();
if ( scene == null ) return null;
if ( _holder.IsValid() && _holder.Scene == scene ) return _holder;
var go = scene.CreateObject( true );
go.Name = "__ClaudeDebugDraw";
go.Flags = GameObjectFlags.NotSaved;
_holder = go.AddComponent<ClaudeDebugDraw>();
return _holder;
}
public static int ClearHolder()
{
int n = 0;
// cached holder β reliable for the common same-scene case
if ( _holder.IsValid() )
{
n += _holder.Prims?.Count ?? 0;
_holder.GameObject?.Destroy();
}
// plus any holders orphaned by an editβplay scene switch (the static ref
// only tracks the most recent scene's holder)
var scene = CurrentScene();
if ( scene != null )
{
foreach ( var c in scene.GetAllComponents<ClaudeDebugDraw>().ToList() )
{
if ( c == _holder ) continue;
n += c.Prims?.Count ?? 0;
c.GameObject?.Destroy();
}
}
_holder = null;
return n;
}
public static bool TryVec( JsonElement p, string key, out Vector3 v )
{
v = Vector3.Zero;
if ( !p.TryGetProperty( key, out var e ) ) return false;
switch ( e.ValueKind )
{
case JsonValueKind.String:
var s = e.GetString().Split( ',' );
if ( s.Length < 3 ) return false;
v = new Vector3( F( s[0] ), F( s[1] ), F( s[2] ) );
return true;
case JsonValueKind.Array:
if ( e.GetArrayLength() < 3 ) return false;
v = new Vector3( (float)e[0].GetDouble(), (float)e[1].GetDouble(), (float)e[2].GetDouble() );
return true;
case JsonValueKind.Object:
v = new Vector3(
(float)e.GetProperty( "x" ).GetDouble(),
(float)e.GetProperty( "y" ).GetDouble(),
(float)e.GetProperty( "z" ).GetDouble() );
return true;
default:
return false;
}
}
public static Color Col( JsonElement p, string key, Color def )
{
if ( !p.TryGetProperty( key, out var e ) || e.ValueKind != JsonValueKind.String ) return def;
var s = e.GetString().Split( ',' );
if ( s.Length < 3 ) return def;
float a = s.Length >= 4 ? F( s[3] ) : 1f;
return new Color( F( s[0] ), F( s[1] ), F( s[2] ), a );
}
public static float Flt( JsonElement p, string key, float def )
=> p.TryGetProperty( key, out var e ) && e.ValueKind == JsonValueKind.Number ? (float)e.GetDouble() : def;
static float F( string s ) => float.Parse( s.Trim(), Inv );
}
// ββ handlers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
public class DebugDrawLineHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !DebugDrawHelpers.TryVec( p, "from", out var a ) || !DebugDrawHelpers.TryVec( p, "to", out var b ) )
return Task.FromResult<object>( new { error = "from and to are required (\"x,y,z\")" } );
var h = DebugDrawHelpers.EnsureHolder();
if ( h == null ) return Task.FromResult<object>( new { error = "no active scene" } );
h.Prims.Add( new DebugDrawPrim
{
Kind = DebugDrawKind.Line, A = a, B = b,
Color = DebugDrawHelpers.Col( p, "color", Color.Yellow ),
Thickness = DebugDrawHelpers.Flt( p, "thickness", 2f )
} );
return Task.FromResult<object>( new { drawn = "line", count = h.Prims.Count, mode = Game.IsPlaying ? "play" : "edit" } );
}
catch ( Exception ex ) { return Task.FromResult<object>( new { error = $"debug_draw_line failed: {ex.Message}" } ); }
}
}
public class DebugDrawRayHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !DebugDrawHelpers.TryVec( p, "origin", out var o ) || !DebugDrawHelpers.TryVec( p, "direction", out var d ) )
return Task.FromResult<object>( new { error = "origin and direction are required (\"x,y,z\")" } );
float len = DebugDrawHelpers.Flt( p, "length", 64f );
var h = DebugDrawHelpers.EnsureHolder();
if ( h == null ) return Task.FromResult<object>( new { error = "no active scene" } );
h.Prims.Add( new DebugDrawPrim
{
Kind = DebugDrawKind.Ray, A = o, B = o + d.Normal * len,
Color = DebugDrawHelpers.Col( p, "color", Color.Yellow ),
Thickness = DebugDrawHelpers.Flt( p, "thickness", 2f )
} );
return Task.FromResult<object>( new { drawn = "ray", count = h.Prims.Count, mode = Game.IsPlaying ? "play" : "edit" } );
}
catch ( Exception ex ) { return Task.FromResult<object>( new { error = $"debug_draw_ray failed: {ex.Message}" } ); }
}
}
public class DebugDrawBoxHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !DebugDrawHelpers.TryVec( p, "center", out var c ) )
return Task.FromResult<object>( new { error = "center is required (\"x,y,z\")" } );
Vector3 size = DebugDrawHelpers.TryVec( p, "size", out var sz ) ? sz : new Vector3( 32f, 32f, 32f );
var h = DebugDrawHelpers.EnsureHolder();
if ( h == null ) return Task.FromResult<object>( new { error = "no active scene" } );
h.Prims.Add( new DebugDrawPrim
{
Kind = DebugDrawKind.Box, A = c, Size = size,
Color = DebugDrawHelpers.Col( p, "color", Color.Green ),
Thickness = DebugDrawHelpers.Flt( p, "thickness", 2f )
} );
return Task.FromResult<object>( new { drawn = "box", count = h.Prims.Count, mode = Game.IsPlaying ? "play" : "edit" } );
}
catch ( Exception ex ) { return Task.FromResult<object>( new { error = $"debug_draw_box failed: {ex.Message}" } ); }
}
}
public class DebugDrawSphereHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !DebugDrawHelpers.TryVec( p, "center", out var c ) )
return Task.FromResult<object>( new { error = "center is required (\"x,y,z\")" } );
float r = DebugDrawHelpers.Flt( p, "radius", 32f );
var h = DebugDrawHelpers.EnsureHolder();
if ( h == null ) return Task.FromResult<object>( new { error = "no active scene" } );
h.Prims.Add( new DebugDrawPrim
{
Kind = DebugDrawKind.Sphere, A = c, Radius = r,
Color = DebugDrawHelpers.Col( p, "color", Color.Red ),
Thickness = DebugDrawHelpers.Flt( p, "thickness", 2f )
} );
return Task.FromResult<object>( new { drawn = "sphere", count = h.Prims.Count, mode = Game.IsPlaying ? "play" : "edit" } );
}
catch ( Exception ex ) { return Task.FromResult<object>( new { error = $"debug_draw_sphere failed: {ex.Message}" } ); }
}
}
public class DebugClearHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
int removed = DebugDrawHelpers.ClearHolder();
return Task.FromResult<object>( new { cleared = true, removed } );
}
catch ( Exception ex ) { return Task.FromResult<object>( new { error = $"debug_clear failed: {ex.Message}" } ); }
}
}
Editor
library
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;
// =============================================================================
// Economy & Save family (Track E) -- six Tier-2 scaffolds (code-gen; scene-mutating):
//
// create_currency_account audited host-authoritative ledger: [Sync(FromHost)]
// balance + Deposit/Withdraw/TryTransfer + fixed-size
// transaction ring buffer (Time.Now, reason, amount)
// create_idle_economy geometric bulk-buy: BaseCost * Growth^Owned, closed-form
// Buy 1 / Buy N / Buy Max, income tick auto-wired to a
// sibling wallet via TypeLibrary reflection
// create_signed_save tamper-evident save: FNV-1a signature over payload+salt,
// verify-on-load, clamp Sanitize() hook, forced reset on
// mismatch, versioned
// create_meta_progression between-runs roguelite meta: persistent meta-currency +
// unlock flags, Grant/TrySpend/Unlock/IsUnlocked,
// OnUnlocked static event, BankRun(int) run-end seam
// add_steam_stat_currency currency persisted over Sandbox.Services.Stats
// (SetValue/Flush; read-back via GetLocalPlayerStats)
// create_loot_table_resource GameResource-based loot tables ([AssetType], .loot files)
// with nested-table entries + depth-capped resolver component
//
// Compiles into the SAME editor assembly as MyEditorMenu.cs / ScaffoldHandlers.cs,
// so it reuses the shared statics on ClaudeBridge (TryResolveProjectPath,
// SanitizeIdentifier, SerializeGo) and ScaffoldHelpers (PrepareCodeFile /
// WriteCode / Utf8NoBom). Handler code here is UNSANDBOXED editor code (System.* fine).
//
// The C# *strings these handlers WRITE TO DISK* are SANDBOXED game code:
// - sealed Component classes, no virtual members.
// - [Sync(SyncFlags.FromHost)] for host-auth state (create_economy_wallet-verified);
// IsProxy guards on every mutation.
// - System.Math/MathF compile on this SDK; Array.Clone() is blocked (not used).
// - FileSystem.Data.ReadJsonOrDefault<T>/WriteJson + ReadAllText/WriteAllText/
// FileExists/DeleteFile all verified live via describe_type BaseFileSystem.
// - Sandbox.Json.Serialize(object)/Deserialize<T>(string) verified live.
// - Sandbox.Services.Stats: Increment(string,double), SetValue(string,double,string,object),
// Flush(), GetLocalPlayerStats(string packageIdent) -> Stats.PlayerStats (NESTED type;
// .Get(name) returns Stats.PlayerStat with .Value) -- all verified live. There is NO
// Stats.LocalPlayer on this SDK.
// - GameResourceAttribute is [Obsolete] on this SDK -- generated resources use
// [AssetType( Name=..., Extension=..., Category=... )] (the modern corpus pattern).
// - TypeLibrary wallet wiring copies the compile-verified create_idle_income shape:
// Game.TypeLibrary.GetType(comp.GetType()) -> Methods.FirstOrDefault(...) ->
// Invoke / InvokeWithReturn<bool> (both verified on MethodDescription);
// PropertyDescription.GetValue(object) verified live.
//
// Register(...) lines + the _sceneMutatingCommands additions live in MyEditorMenu.cs
// (orchestrator integration) to keep the files decoupled -- see the handoff summary.
// =============================================================================
// -----------------------------------------------------------------------------
// create_currency_account -- the audited sibling of create_economy_wallet.
// Wallet = simple money (AddMoney/TrySpend). Account = money + a fixed-size
// transaction ring buffer (timestamp, reason, amount, balance-after) with
// GetRecentTransactions() for ledger UIs / audit trails, plus TryTransfer
// between accounts. Folds the corpus asks create_economy_ledger / create_currency.
// -----------------------------------------------------------------------------
public class CreateCurrencyAccountHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "CurrencyAccount", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
long start = p.TryGetProperty( "startingBalance", out var sv ) && sv.TryGetInt64( out var sl ) ? sl : 0L;
int history = p.TryGetProperty( "historySize", out var hv ) && hv.TryGetInt32( out var hi ) ? hi : 32;
if ( history < 1 ) history = 1;
if ( history > 4096 ) history = 4096;
var code = BuildCode( className, start, history );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = EconomySaveHelpers.PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
startingBalance = start,
historySize = history,
placedOn,
note,
nextSteps = new[]
{
$"trigger_hotload to compile {className} into the game assembly.",
placedOn != null
? $"{className} was attached to the target GameObject."
: $"Place it on a per-player or bank GameObject: add_component_to_new_object (component=\"{className}\") after the hotload, or re-run with targetId.",
$"Move money host-side: GetComponent<{className}>()?.Deposit( 100, \"quest reward\" ); .Withdraw( 50, \"shop\" ); .TryTransfer( other, 25, \"trade\" );",
$"Read the ledger (host-side, newest first): foreach ( var t in GetComponent<{className}>().GetRecentTransactions() ) Log.Info( $\"{{t.Time}} {{t.Amount}} {{t.Reason}} -> {{t.BalanceAfter}}\" );",
$"Bind a HUD: GetComponent<{className}>().OnBalanceChanged = bal => {{ /* update label */ }}; Balance is [Sync(FromHost)] so clients can read it directly.",
$"History keeps the last {history} transactions (HistorySize, fixed once the first transaction is recorded); older entries are overwritten silently. The ledger itself is host-side only -- it does not replicate."
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_currency_account failed: {ex.Message}" } );
}
}
static string BuildCode( string className, long start, int history )
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
string st = start.ToString( ci );
string hs = history.ToString( ci );
return $@"using Sandbox;
using System;
using System.Collections.Generic;
/// <summary>
/// {className} -- a host-authoritative currency ACCOUNT: an audited ledger.
///
/// Use create_economy_wallet's Wallet when you just need money; use this when you need
/// money PLUS an audit trail. Balance is [Sync(SyncFlags.FromHost)] so only the host
/// writes it (clients can't author their own balance); every Deposit / Withdraw /
/// TryTransfer records a Transaction (Time.Now, reason, signed amount, balance-after)
/// into a fixed-size ring buffer, newest overwriting oldest past HistorySize.
///
/// The ledger is HOST-SIDE ONLY -- it does not replicate. Balance replicates; feed a
/// client-side ledger UI over an RPC if you need remote history. Single-player safe
/// (IsProxy is false with no networking active).
///
/// Usage (host-side):
/// GetComponent<{className}>()?.Deposit( 100, ""quest reward"" );
/// if ( GetComponent<{className}>().Withdraw( 50, ""shop"" ) ) {{ /* grant the item */ }}
/// GetComponent<{className}>().TryTransfer( otherAccount, 25, ""trade"" );
/// foreach ( var t in GetComponent<{className}>().GetRecentTransactions() ) {{ /* newest first */ }}
/// </summary>
public sealed class {className} : Component
{{
/// Balance the account opens with (host seeds it in OnStart).
[Property] public long StartingBalance {{ get; set; }} = {st}L;
/// Ring-buffer capacity. Fixed once the first transaction is recorded.
[Property] public int HistorySize {{ get; set; }} = {hs};
// Host-authoritative balance -- replicates to clients, only the host writes.
[Sync( SyncFlags.FromHost )] public long Balance {{ get; set; }}
/// One ledger line. Amount is signed: positive = deposit, negative = withdrawal.
public struct Transaction
{{
public float Time; // Time.Now when recorded
public long Amount; // signed delta
public string Reason; // free-form audit string
public long BalanceAfter; // balance after applying the delta
}}
/// Fired (on the writing machine) whenever the balance changes -- bind a HUD here.
public Action<long> OnBalanceChanged {{ get; set; }}
// Host-side ring buffer. _head = next write slot, _count = filled slots.
private Transaction[] _history;
private int _head;
private int _count;
protected override void OnStart()
{{
if ( IsProxy ) return; // only the authority seeds the balance
Balance = StartingBalance;
if ( StartingBalance != 0 ) Record( StartingBalance, ""opening balance"" );
OnBalanceChanged?.Invoke( Balance );
}}
public bool CanAfford( long amount ) => Balance >= amount;
/// <summary>Deposit (host-authoritative). Non-positive amounts are ignored.</summary>
public void Deposit( long amount, string reason = ""deposit"" )
{{
if ( IsProxy || amount <= 0 ) return;
Balance += amount;
Record( amount, reason );
OnBalanceChanged?.Invoke( Balance );
}}
/// <summary>Withdraw if affordable; returns false and changes nothing if not (host-authoritative).</summary>
public bool Withdraw( long amount, string reason = ""withdraw"" )
{{
if ( IsProxy || amount <= 0 ) return false;
if ( Balance < amount ) return false;
Balance -= amount;
Record( -amount, reason );
OnBalanceChanged?.Invoke( Balance );
return true;
}}
/// <summary>
/// Atomically move money into another account (host-authoritative). Both legs are
/// recorded in their respective ledgers. Returns false (nothing moves) when the
/// target is missing/self, the amount is non-positive, or funds are short.
/// </summary>
public bool TryTransfer( {className} to, long amount, string reason = ""transfer"" )
{{
if ( IsProxy || to == null || to == this || amount <= 0 ) return false;
if ( Balance < amount ) return false;
Balance -= amount;
Record( -amount, reason );
OnBalanceChanged?.Invoke( Balance );
to.ReceiveTransfer( amount, reason );
return true;
}}
// The receiving leg of TryTransfer -- runs on the host alongside the sending leg.
private void ReceiveTransfer( long amount, string reason )
{{
Balance += amount;
Record( amount, reason );
OnBalanceChanged?.Invoke( Balance );
}}
/// <summary>
/// The most recent transactions, NEWEST FIRST. max = 0 returns everything retained
/// (up to HistorySize). Host-side only -- proxies always get an empty list.
/// </summary>
public List<Transaction> GetRecentTransactions( int max = 0 )
{{
var list = new List<Transaction>();
if ( _history == null || _count == 0 ) return list;
int take = _count;
if ( max > 0 && max < take ) take = max;
for ( int i = 0; i < take; i++ )
{{
int idx = ( _head - 1 - i + _history.Length * 2 ) % _history.Length;
list.Add( _history[idx] );
}}
return list;
}}
private void Record( long amount, string reason )
{{
if ( _history == null )
_history = new Transaction[HistorySize < 1 ? 1 : HistorySize];
_history[_head] = new Transaction
{{
Time = Time.Now,
Amount = amount,
Reason = reason ?? """",
BalanceAfter = Balance
}};
_head = ( _head + 1 ) % _history.Length;
if ( _count < _history.Length ) _count++;
}}
}}
";
}
}
// -----------------------------------------------------------------------------
// create_idle_economy -- geometric bulk-buy purchasing. Generators follow the
// classic BaseCost * Growth^Owned curve; Buy 1 / Buy N / Buy Max use the
// closed-form geometric series (no loops). Income ticks grant into a sibling
// wallet's AddMoney via TypeLibrary reflection (the compile-verified
// create_idle_income pattern); purchases spend via the sibling's TrySpend and
// Buy Max reads its Money property the same way.
// -----------------------------------------------------------------------------
public class CreateIdleEconomyHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
if ( !ScaffoldHelpers.PrepareCodeFile( p, "IdleEconomy", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
float tick = p.TryGetProperty( "tickSeconds", out var tv ) && tv.TryGetSingle( out var tf ) ? tf : 1f;
if ( tick < 0.1f ) tick = 0.1f;
var gens = ParseGenerators( p );
var code = BuildCode( className, gens, tick, ci );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = EconomySaveHelpers.PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
generators = gens.Select( g => g.Name ).ToArray(),
tickSeconds = tick,
placedOn,
note,
nextSteps = new[]
{
$"trigger_hotload to compile {className} into the game assembly.",
placedOn != null
? $"{className} was attached to the target GameObject."
: $"Place it NEXT TO a wallet component (create_economy_wallet / create_currency_account) on the same GameObject: add_component_to_new_object (component=\"{className}\") after the hotload, or re-run with targetId.",
"It auto-wires the sibling wallet by reflection: income invokes AddMoney(long|int), purchases invoke TrySpend(long|int), Buy Max reads the Money property. No wallet sibling = purchases refused with a Log.Warning (never silent).",
$"Buy from game code: GetComponent<{className}>().TryBuy( 0, 1 ); .TryBuy( 0, 10 ); int n = GetComponent<{className}>().BuyMax( 0 );",
$"Show prices: double cost = GetComponent<{className}>().CostOf( 0, 10 ); int max = GetComponent<{className}>().MaxAffordable( 0 ); -- both closed-form geometric series, no loops.",
$"React to events: {className}.OnPurchased += ( index, count, cost ) => {{ }}; {className}.OnIncomeTick += ( amount, total ) => {{ }};",
"Tune GeneratorNames / BaseCosts / Growths / IncomesPerSecond (parallel lists) in the inspector or with set_property. Owned counts are host-side state (not replicated); pair with create_offline_progress for away-time earnings."
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_idle_economy failed: {ex.Message}" } );
}
}
internal struct GeneratorDef { public string Name; public float BaseCost; public float Growth; public float IncomePerSecond; }
static List<GeneratorDef> ParseGenerators( JsonElement p )
{
var result = new List<GeneratorDef>();
if ( p.TryGetProperty( "generators", out var gv ) && gv.ValueKind == JsonValueKind.Array )
{
foreach ( var item in gv.EnumerateArray() )
{
var g = new GeneratorDef
{
Name = item.TryGetProperty( "name", out var nv ) && !string.IsNullOrWhiteSpace( nv.GetString() ) ? nv.GetString() : "Generator",
BaseCost = item.TryGetProperty( "baseCost", out var bv ) && bv.TryGetSingle( out var bf ) ? bf : 15f,
Growth = item.TryGetProperty( "growth", out var grv ) && grv.TryGetSingle( out var grf ) ? grf : 1.15f,
IncomePerSecond = item.TryGetProperty( "incomePerSecond", out var iv ) && iv.TryGetSingle( out var inf ) ? inf : 0.5f
};
// Escape-strip: the name is baked into a generated string literal.
g.Name = ( g.Name ?? "Generator" ).Replace( "\\", "" ).Replace( "\"", "" );
if ( g.BaseCost <= 0f ) g.BaseCost = 1f;
if ( g.Growth < 1f ) g.Growth = 1f;
if ( g.IncomePerSecond < 0f ) g.IncomePerSecond = 0f;
result.Add( g );
}
}
if ( result.Count == 0 )
{
result.Add( new GeneratorDef { Name = "Cursor", BaseCost = 15f, Growth = 1.15f, IncomePerSecond = 0.5f } );
result.Add( new GeneratorDef { Name = "Farm", BaseCost = 200f, Growth = 1.15f, IncomePerSecond = 4f } );
result.Add( new GeneratorDef { Name = "Factory", BaseCost = 3000f, Growth = 1.12f, IncomePerSecond = 30f } );
}
return result;
}
static string BuildCode( string className, List<GeneratorDef> gens, float tick, System.Globalization.CultureInfo ci )
{
string nameLits = string.Join( ", ", gens.Select( g => $"\"{g.Name}\"" ) );
string costLits = string.Join( ", ", gens.Select( g => g.BaseCost.ToString( ci ) + "f" ) );
string growthLits = string.Join( ", ", gens.Select( g => g.Growth.ToString( ci ) + "f" ) );
string incomeLits = string.Join( ", ", gens.Select( g => g.IncomePerSecond.ToString( ci ) + "f" ) );
string tk = tick.ToString( ci ) + "f";
return $@"using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
/// <summary>
/// {className} -- a geometric idle economy: generators, bulk buying, passive income.
///
/// COST CURVE: buying copy k of generator i costs BaseCosts[i] * Growths[i]^k -- the
/// classic incremental-game curve. CostOf / MaxAffordable / TryBuy all use the CLOSED-FORM
/// geometric series (no per-copy loops), so Buy 1000 is the same math as Buy 1:
/// cost(n) = c0 * (g^n - 1) / (g - 1) where c0 = BaseCost * g^Owned
/// buyMax = floor( log_g( funds*(g-1)/c0 + 1 ) )
///
/// WALLET WIRING (TypeLibrary reflection -- no compile-time wallet dependency): income
/// invokes AddMoney(long|int) on the first sibling component that has one; purchases
/// invoke TrySpend(long|int); Buy Max reads the sibling's Money property. Works out of
/// the box next to a create_economy_wallet or create_currency_account scaffold. No wallet
/// sibling = purchases are REFUSED with a Log.Warning (never silent).
///
/// HOST-AUTHORITATIVE: all mutation is IsProxy-guarded; owned counts are host-side state
/// (not replicated -- replicate via your own [Sync]/RPC if clients need them). TotalEarned
/// is [Sync(FromHost)]. Single-player safe.
///
/// Usage:
/// GetComponent<{className}>().TryBuy( 0, 1 ); // Buy 1
/// GetComponent<{className}>().TryBuy( 0, 10 ); // Buy N
/// int bought = GetComponent<{className}>().BuyMax( 0 ); // Buy Max
/// {className}.OnPurchased += ( i, count, cost ) => {{ /* refresh shop UI */ }};
/// {className}.OnIncomeTick += ( amount, total ) => {{ /* +N popup */ }};
/// </summary>
public sealed class {className} : Component
{{
/// Generator display names -- parallel to BaseCosts / Growths / IncomesPerSecond.
[Property] public List<string> GeneratorNames {{ get; set; }} = new List<string> {{ {nameLits} }};
/// Cost of the FIRST copy of each generator (curve: BaseCost * Growth^Owned).
[Property] public List<float> BaseCosts {{ get; set; }} = new List<float> {{ {costLits} }};
/// Per-copy cost multiplier (1.15 = the classic curve). Values below 1 are treated as 1 (flat cost).
[Property] public List<float> Growths {{ get; set; }} = new List<float> {{ {growthLits} }};
/// Income each owned copy produces per second.
[Property] public List<float> IncomesPerSecond {{ get; set; }} = new List<float> {{ {incomeLits} }};
/// Seconds between income grants.
[Property] public float TickSeconds {{ get; set; }} = {tk};
/// Total income ever granted (host-authoritative, replicates to clients).
[Sync( SyncFlags.FromHost )] public float TotalEarned {{ get; set; }}
/// Fires host-side after a purchase: (generatorIndex, countBought, totalCost).
public static Action<int, int, double> OnPurchased {{ get; set; }}
/// Fires host-side after each income grant: (amount, newTotalEarned).
public static Action<float, float> OnIncomeTick {{ get; set; }}
// Host-side owned counts, parallel to the property lists.
private int[] _owned;
private TimeUntil _nextTick;
protected override void OnStart()
{{
_nextTick = TickSeconds;
}}
protected override void OnFixedUpdate()
{{
if ( IsProxy ) return;
if ( !_nextTick ) return;
_nextTick = TickSeconds;
EnsureOwned();
float amount = 0f;
for ( int i = 0; i < _owned.Length; i++ )
amount += _owned[i] * IncomeOf( i ) * TickSeconds;
if ( amount <= 0f ) return;
TotalEarned += amount;
GrantIncome( amount );
OnIncomeTick?.Invoke( amount, TotalEarned );
}}
/// <summary>Copies of a generator owned (host-side state; 0 on proxies).</summary>
public int GetOwned( int index )
{{
EnsureOwned();
return index >= 0 && index < _owned.Length ? _owned[index] : 0;
}}
/// <summary>
/// Closed-form cost of the next `count` copies of generator `index` from the current
/// owned count. 0 for an invalid index or non-positive count.
/// </summary>
public double CostOf( int index, int count )
{{
if ( count <= 0 || !ValidIndex( index ) ) return 0.0;
double g = GrowthOf( index );
double c0 = BaseCosts[index] * Math.Pow( g, GetOwned( index ) );
if ( Math.Abs( g - 1.0 ) < 0.0001 ) return c0 * count;
return c0 * ( Math.Pow( g, count ) - 1.0 ) / ( g - 1.0 );
}}
/// <summary>
/// Closed-form Buy-Max count against the sibling wallet's current Money.
/// 0 when nothing is affordable or no wallet sibling exposes a Money property.
/// </summary>
public int MaxAffordable( int index )
{{
if ( !ValidIndex( index ) ) return 0;
double funds = ReadWalletBalance();
if ( funds <= 0.0 ) return 0;
double g = GrowthOf( index );
double c0 = BaseCosts[index] * Math.Pow( g, GetOwned( index ) );
if ( c0 <= 0.0 ) return 0;
if ( Math.Abs( g - 1.0 ) < 0.0001 ) return (int) Math.Floor( funds / c0 );
return (int) Math.Floor( Math.Log( funds * ( g - 1.0 ) / c0 + 1.0 ) / Math.Log( g ) );
}}
/// <summary>
/// Buy `count` copies if the sibling wallet's TrySpend accepts the closed-form cost
/// (rounded up to whole currency). Host-only; false when unaffordable or no wallet.
/// </summary>
public bool TryBuy( int index, int count )
{{
if ( IsProxy || count <= 0 || !ValidIndex( index ) ) return false;
EnsureOwned();
double cost = CostOf( index, count );
if ( !SpendFromWallet( cost ) ) return false;
_owned[index] += count;
OnPurchased?.Invoke( index, count, cost );
return true;
}}
/// <summary>
/// Buy as many copies as the wallet can afford. Returns the count bought (0 = none).
/// Steps down once past a whole-currency rounding edge rather than failing.
/// </summary>
public int BuyMax( int index )
{{
int n = MaxAffordable( index );
while ( n > 0 )
{{
if ( TryBuy( index, n ) ) return n;
n--; // ceil-rounding edge: the closed form said n, the wallet said no -- step down
}}
return 0;
}}
private bool ValidIndex( int index )
=> BaseCosts != null && index >= 0 && index < BaseCosts.Count;
private double GrowthOf( int index )
{{
float g = Growths != null && index < Growths.Count ? Growths[index] : 1.15f;
return g < 1f ? 1.0 : g;
}}
private float IncomeOf( int index )
=> IncomesPerSecond != null && index < IncomesPerSecond.Count && index >= 0 ? IncomesPerSecond[index] : 0f;
private void EnsureOwned()
{{
int size = BaseCosts?.Count ?? 0;
int names = GeneratorNames?.Count ?? 0;
if ( names > size ) size = names;
if ( size < 1 ) size = 1;
if ( _owned == null )
{{
_owned = new int[size];
}}
else if ( _owned.Length < size )
{{
var grown = new int[size];
for ( int i = 0; i < _owned.Length; i++ ) grown[i] = _owned[i];
_owned = grown;
}}
}}
// ---- sibling-wallet wiring (TypeLibrary reflection; no hard wallet dependency) ----
// Deliver income: AddMoney(long|int) on the first sibling that has one.
private void GrantIncome( float amount )
{{
foreach ( var comp in Components.GetAll() )
{{
if ( comp == this || comp is null ) continue;
var type = Game.TypeLibrary?.GetType( comp.GetType() );
var method = type?.Methods?.FirstOrDefault( m => m.Name == ""AddMoney"" );
if ( method == null ) continue;
try {{ method.Invoke( comp, new object[] {{ (long) amount }} ); return; }}
catch {{ }}
try {{ method.Invoke( comp, new object[] {{ (int) amount }} ); return; }}
catch {{ /* wrong signature -- keep looking */ }}
}}
// No wallet sibling -- TotalEarned still accumulates; read it directly.
}}
// Spend: TrySpend(long|int) on the first sibling that has one. Never silent on failure.
private bool SpendFromWallet( double cost )
{{
if ( cost <= 0.0 ) return false;
long rounded = (long) Math.Ceiling( cost );
foreach ( var comp in Components.GetAll() )
{{
if ( comp == this || comp is null ) continue;
var type = Game.TypeLibrary?.GetType( comp.GetType() );
var method = type?.Methods?.FirstOrDefault( m => m.Name == ""TrySpend"" );
if ( method == null ) continue;
try {{ return method.InvokeWithReturn<bool>( comp, new object[] {{ rounded }} ); }}
catch {{ }}
try {{ return method.InvokeWithReturn<bool>( comp, new object[] {{ (int) rounded }} ); }}
catch {{ /* wrong signature -- keep looking */ }}
}}
Log.Warning( $""[{className}] No sibling wallet with TrySpend found -- add a create_economy_wallet / create_currency_account component next to it. Purchase refused."" );
return false;
}}
// Read funds for Buy Max: the first sibling exposing a numeric Money property.
private double ReadWalletBalance()
{{
foreach ( var comp in Components.GetAll() )
{{
if ( comp == this || comp is null ) continue;
var type = Game.TypeLibrary?.GetType( comp.GetType() );
var prop = type?.Properties?.FirstOrDefault( pp => pp.Name == ""Money"" || pp.Name == ""Balance"" );
if ( prop == null ) continue;
try
{{
object v = prop.GetValue( comp );
if ( v is long l ) return l;
if ( v is int i ) return i;
if ( v is float f ) return f;
if ( v is double d ) return d;
}}
catch {{ /* unreadable -- keep looking */ }}
}}
return 0.0;
}}
}}
";
}
}
// -----------------------------------------------------------------------------
// create_signed_save -- tamper-evident save file. The payload POCO is serialized
// to JSON (Sandbox.Json), FNV-1a-64 hashed together with a salt + version, and
// written inside a signed envelope via FileSystem.Data. Load verifies the
// signature; a mismatch = forced reset (delete + defaults) + OnTampered event.
// Clamp-on-load Sanitize() hook + versioning copy create_save_system's shape.
// -----------------------------------------------------------------------------
public class CreateSignedSaveHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "SignedSave", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
var ci = System.Globalization.CultureInfo.InvariantCulture;
string fileName = p.TryGetProperty( "fileName", out var fn ) && !string.IsNullOrWhiteSpace( fn.GetString() ) ? fn.GetString() : "save_signed.json";
int version = p.TryGetProperty( "version", out var vv ) && vv.TryGetInt32( out var vi ) ? vi : 1;
float autosave = p.TryGetProperty( "autosaveSeconds", out var av ) && av.TryGetSingle( out var af ) ? af : 10f;
string salt = p.TryGetProperty( "salt", out var sv ) && !string.IsNullOrWhiteSpace( sv.GetString() )
? sv.GetString()
: Guid.NewGuid().ToString( "N" ); // unique per generated file by default
// These are baked into generated string literals -- strip escape characters.
fileName = fileName.Replace( "\\", "" ).Replace( "\"", "" );
salt = salt.Replace( "\\", "" ).Replace( "\"", "" );
var code = BuildCode( className, fileName, version.ToString( ci ), autosave.ToString( ci ) + "f", salt );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = EconomySaveHelpers.PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
fileName,
version,
autosaveSeconds = autosave,
placedOn,
note,
nextSteps = new[]
{
$"trigger_hotload to compile {className} into the game assembly.",
placedOn != null
? $"{className} was attached to the target GameObject."
: $"Place it on your save-manager GameObject: add_component_to_new_object (component=\"{className}\") after the hotload, or re-run with targetId.",
$"Add your game fields to the SaveData inner class in {className}.cs, extend Sanitize() to clamp them, and bump Version when the shape changes.",
$"Use it: GetComponent<{className}>().Data.Money += 100; GetComponent<{className}>().MarkDirty(); -- the dirty-flag autosave (or OnDestroy) writes and re-signs.",
$"React: {className}.OnLoaded += d => {{ }}; {className}.OnSaved += d => {{ }}; {className}.OnTampered += reason => {{ /* tell the player their save was reset */ }};",
"TAMPER = FORCED RESET: an edited payload fails the FNV-1a signature check on load, the file is DELETED and defaults are used (OnTampered fires with the reason). This is tamper-EVIDENT, not cryptographically secure -- the salt ships in the game code, so a determined user can re-sign; it stops casual notepad edits, not reverse engineers."
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_signed_save failed: {ex.Message}" } );
}
}
static string BuildCode( string className, string fileName, string version, string autosave, string salt )
{
return $@"using Sandbox;
using System;
/// <summary>
/// {className} -- a tamper-evident, versioned save system.
///
/// The SaveData payload is serialized to JSON, hashed with FNV-1a-64 over
/// payload + version + salt, and written inside a signed envelope to
/// FileSystem.Data. Load re-computes the signature: a mismatch (hand-edited or
/// corrupt file) triggers a FORCED RESET -- the file is deleted, defaults are
/// used, and the static OnTampered event fires. A version mismatch starts fresh
/// (add migrations in Load if you need them). Loaded values pass through the
/// Sanitize() clamp hook so even a re-signed save can't smuggle absurd values.
///
/// NOT cryptography: the salt ships inside the game assembly, so this is
/// tamper-EVIDENT (stops notepad edits), not tamper-PROOF.
///
/// Host/owner-only (IsProxy-guarded). Dirty-flag autosave every AutosaveSeconds
/// plus a final save in OnDestroy.
///
/// Usage:
/// var save = GetComponent<{className}>();
/// save.Data.Money += 100; save.MarkDirty();
/// {className}.OnTampered += reason => Log.Warning( $""save reset: {{reason}}"" );
/// </summary>
public sealed class {className} : Component
{{
/// FileSystem.Data path the signed envelope is written to.
[Property] public string FileName {{ get; set; }} = ""{fileName}"";
/// Autosave cadence in seconds. 0 disables the heartbeat (OnDestroy still saves).
[Property] public float AutosaveSeconds {{ get; set; }} = {autosave};
/// Save-shape version -- bump when SaveData changes so old files start fresh.
public const int Version = {version};
// Baked-in signing salt (unique to this generated file). Changing it invalidates existing saves.
private const string Salt = ""{salt}"";
/// The save payload. Add your own fields here; clamp them in Sanitize().
public class SaveData
{{
public int Money {{ get; set; }}
public int Day {{ get; set; }} = 1;
// Add game fields here.
}}
/// The envelope actually written to disk: version + raw payload JSON + signature.
public class SaveEnvelope
{{
public int Version {{ get; set; }}
public string Payload {{ get; set; }}
public ulong Signature {{ get; set; }}
}}
public SaveData Data {{ get; private set; }} = new SaveData();
public bool IsDirty {{ get; private set; }}
/// Fires after a successful Load() with the loaded (sanitized) data.
public static Action<SaveData> OnLoaded {{ get; set; }}
/// Fires after every Save().
public static Action<SaveData> OnSaved {{ get; set; }}
/// Fires when the signature check fails and the save is force-reset. Arg = reason.
public static Action<string> OnTampered {{ get; set; }}
private TimeUntil _nextAutosave;
protected override void OnStart()
{{
if ( IsProxy ) return; // only the owning machine loads
Load();
_nextAutosave = AutosaveSeconds;
}}
protected override void OnUpdate()
{{
if ( IsProxy || AutosaveSeconds <= 0f ) return;
if ( _nextAutosave )
{{
_nextAutosave = AutosaveSeconds;
if ( IsDirty ) Save();
}}
}}
protected override void OnDestroy()
{{
if ( !IsProxy && IsDirty ) Save();
}}
/// Mark the data changed so the next autosave tick (or OnDestroy) writes + re-signs it.
public void MarkDirty() => IsDirty = true;
public void Load()
{{
var envelope = FileSystem.Data.ReadJsonOrDefault<SaveEnvelope>( FileName, null );
if ( envelope == null )
{{
// Missing or unreadable envelope: start fresh (not treated as tampering).
Data = new SaveData();
IsDirty = true;
}}
else if ( envelope.Version != Version )
{{
// Old save shape: start fresh (add migrations here later).
Data = new SaveData();
IsDirty = true;
}}
else if ( envelope.Payload == null || ComputeSignature( envelope.Payload ) != envelope.Signature )
{{
ForceReset( ""signature mismatch -- save file was modified outside the game"" );
return; // ForceReset already fired OnLoaded
}}
else
{{
SaveData loaded = null;
try {{ loaded = Json.Deserialize<SaveData>( envelope.Payload ); }}
catch {{ }}
if ( loaded == null )
{{
ForceReset( ""payload failed to parse despite a valid signature"" );
return;
}}
Data = Sanitize( loaded );
IsDirty = false;
}}
OnLoaded?.Invoke( Data );
}}
public void Save()
{{
var payload = Json.Serialize( Data );
var envelope = new SaveEnvelope
{{
Version = Version,
Payload = payload,
Signature = ComputeSignature( payload )
}};
FileSystem.Data.WriteJson( FileName, envelope );
IsDirty = false;
OnSaved?.Invoke( Data );
}}
/// <summary>Delete the save file and reset to defaults. Fires OnTampered then OnLoaded.</summary>
public void ForceReset( string reason )
{{
try
{{
if ( FileSystem.Data.FileExists( FileName ) )
FileSystem.Data.DeleteFile( FileName );
}}
catch {{ }}
Data = new SaveData();
IsDirty = true;
OnTampered?.Invoke( reason ?? ""forced reset"" );
OnLoaded?.Invoke( Data );
}}
/// Clamp-on-load: keep loaded values inside sane ranges so even a re-signed
/// save can't smuggle absurd values. Extend per field you add.
private SaveData Sanitize( SaveData d )
{{
if ( d.Money < 0 ) d.Money = 0;
if ( d.Day < 1 ) d.Day = 1;
return d;
}}
// FNV-1a 64-bit over payload + version + salt. Deterministic, allocation-light.
private static ulong ComputeSignature( string payload )
{{
const ulong offsetBasis = 14695981039346656037UL;
const ulong prime = 1099511628211UL;
ulong hash = offsetBasis;
string material = payload + ""|"" + Version + ""|"" + Salt;
for ( int i = 0; i < material.Length; i++ )
{{
hash ^= material[i];
hash *= prime;
}}
return hash;
}}
}}
";
}
}
// -----------------------------------------------------------------------------
// create_meta_progression -- the between-runs roguelite meta layer: persistent
// meta-currency + unlock-flag dictionary saved to FileSystem.Data JSON.
// Grant/TrySpend/Unlock/IsUnlocked + a BankRun(int) run-end seam + a static
// OnUnlocked event. Persistence copies create_save_system's dirty-flag shape.
// -----------------------------------------------------------------------------
public class CreateMetaProgressionHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "MetaProgression", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
var ci = System.Globalization.CultureInfo.InvariantCulture;
string fileName = p.TryGetProperty( "fileName", out var fn ) && !string.IsNullOrWhiteSpace( fn.GetString() ) ? fn.GetString() : "meta.json";
int version = p.TryGetProperty( "version", out var vv ) && vv.TryGetInt32( out var vi ) ? vi : 1;
float autosave = p.TryGetProperty( "autosaveSeconds", out var av ) && av.TryGetSingle( out var af ) ? af : 10f;
fileName = fileName.Replace( "\\", "" ).Replace( "\"", "" );
var code = BuildCode( className, fileName, version.ToString( ci ), autosave.ToString( ci ) + "f" );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = EconomySaveHelpers.PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
fileName,
version,
placedOn,
note,
nextSteps = new[]
{
$"trigger_hotload to compile {className} into the game assembly.",
placedOn != null
? $"{className} was attached to the target GameObject."
: $"Place it on a persistent manager GameObject (one that exists in your hub/menu scene): add_component_to_new_object (component=\"{className}\") after the hotload, or re-run with targetId.",
$"At run end, bank the earnings: GetComponent<{className}>().BankRun( runCurrencyEarned ); -- it grants and saves immediately.",
$"Gate content: if ( GetComponent<{className}>().TrySpend( 50 ) ) GetComponent<{className}>().Unlock( \"double_jump\" ); then check IsUnlocked( \"double_jump\" ) when building the player.",
$"React to unlocks anywhere: {className}.OnUnlocked += key => {{ /* flash the new item in the meta shop */ }};",
"MetaCurrency and the unlock flags persist to FileSystem.Data across sessions (dirty-flag autosave + OnDestroy). IsProxy-guarded: in multiplayer each machine banks only its own meta file."
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_meta_progression failed: {ex.Message}" } );
}
}
static string BuildCode( string className, string fileName, string version, string autosave )
{
return $@"using Sandbox;
using System;
using System.Collections.Generic;
/// <summary>
/// {className} -- the between-runs roguelite meta layer.
///
/// Persists a meta-currency plus an unlock-flag dictionary to FileSystem.Data JSON
/// (dirty-flag autosave + OnDestroy, create_save_system's shape). During a run you earn
/// normal run-currency; at run end call BankRun(earned) to convert it into persistent
/// meta-currency. Spend meta-currency on permanent Unlock() flags and gate content with
/// IsUnlocked(). The static OnUnlocked event fires on every new unlock.
///
/// Owner-only (IsProxy-guarded): each machine banks only its own meta file.
///
/// Usage:
/// GetComponent<{className}>().BankRun( 120 ); // run over
/// if ( GetComponent<{className}>().TrySpend( 50 ) )
/// GetComponent<{className}>().Unlock( ""double_jump"" );
/// if ( GetComponent<{className}>().IsUnlocked( ""double_jump"" ) ) {{ /* enable it */ }}
/// {className}.OnUnlocked += key => {{ /* celebrate */ }};
/// </summary>
public sealed class {className} : Component
{{
/// FileSystem.Data path the meta state is written to.
[Property] public string FileName {{ get; set; }} = ""{fileName}"";
/// Autosave cadence in seconds. 0 disables the heartbeat (OnDestroy still saves).
[Property] public float AutosaveSeconds {{ get; set; }} = {autosave};
/// The persisted payload. Bump Version when the shape changes so old files start fresh.
public class MetaData
{{
public int Version {{ get; set; }} = {version};
public long MetaCurrency {{ get; set; }}
public int RunsBanked {{ get; set; }}
public Dictionary<string, bool> Unlocks {{ get; set; }} = new Dictionary<string, bool>();
}}
public MetaData Data {{ get; private set; }} = new MetaData();
public bool IsDirty {{ get; private set; }}
/// Fires (on the owning machine) when a key is unlocked for the FIRST time.
public static Action<string> OnUnlocked {{ get; set; }}
/// Fires whenever MetaCurrency changes -- bind the meta-shop balance label here.
public Action<long> OnCurrencyChanged {{ get; set; }}
private TimeUntil _nextAutosave;
protected override void OnStart()
{{
if ( IsProxy ) return; // only the owning machine loads
Load();
_nextAutosave = AutosaveSeconds;
}}
protected override void OnUpdate()
{{
if ( IsProxy || AutosaveSeconds <= 0f ) return;
if ( _nextAutosave )
{{
_nextAutosave = AutosaveSeconds;
if ( IsDirty ) Save();
}}
}}
protected override void OnDestroy()
{{
if ( !IsProxy && IsDirty ) Save();
}}
/// <summary>Add meta-currency. Non-positive amounts are ignored.</summary>
public void Grant( long amount )
{{
if ( IsProxy || amount <= 0 ) return;
Data.MetaCurrency += amount;
IsDirty = true;
OnCurrencyChanged?.Invoke( Data.MetaCurrency );
}}
/// <summary>Spend meta-currency if affordable; false and no change otherwise.</summary>
public bool TrySpend( long amount )
{{
if ( IsProxy || amount <= 0 ) return false;
if ( Data.MetaCurrency < amount ) return false;
Data.MetaCurrency -= amount;
IsDirty = true;
OnCurrencyChanged?.Invoke( Data.MetaCurrency );
return true;
}}
/// <summary>Set a permanent unlock flag. Idempotent; OnUnlocked fires only the first time. Saves immediately.</summary>
public void Unlock( string key )
{{
if ( IsProxy || string.IsNullOrEmpty( key ) ) return;
if ( Data.Unlocks.TryGetValue( key, out var already ) && already ) return;
Data.Unlocks[key] = true;
Save(); // unlocks are precious -- write through immediately
OnUnlocked?.Invoke( key );
}}
/// <summary>True when a key has been permanently unlocked.</summary>
public bool IsUnlocked( string key )
=> !string.IsNullOrEmpty( key ) && Data.Unlocks.TryGetValue( key, out var v ) && v;
/// <summary>
/// Run-end seam: convert this run's earnings into persistent meta-currency and
/// save immediately. Call it from your round machine's end-of-run transition.
/// </summary>
public void BankRun( int earned )
{{
if ( IsProxy ) return;
if ( earned > 0 ) Data.MetaCurrency += earned;
Data.RunsBanked += 1;
Save();
OnCurrencyChanged?.Invoke( Data.MetaCurrency );
}}
/// Mark the data changed so the next autosave tick (or OnDestroy) writes it.
public void MarkDirty() => IsDirty = true;
public void Load()
{{
var loaded = FileSystem.Data.ReadJsonOrDefault<MetaData>( FileName, null );
if ( loaded == null || loaded.Version != {version} )
{{
Data = new MetaData();
IsDirty = true;
}}
else
{{
if ( loaded.MetaCurrency < 0 ) loaded.MetaCurrency = 0;
if ( loaded.RunsBanked < 0 ) loaded.RunsBanked = 0;
if ( loaded.Unlocks == null ) loaded.Unlocks = new Dictionary<string, bool>();
Data = loaded;
IsDirty = false;
}}
OnCurrencyChanged?.Invoke( Data.MetaCurrency );
}}
public void Save()
{{
FileSystem.Data.WriteJson( FileName, Data );
IsDirty = false;
}}
}}
";
}
}
// -----------------------------------------------------------------------------
// add_steam_stat_currency -- currency persisted over Sandbox.Services.Stats.
// Verified live on this SDK: static Stats.Increment(string,double),
// Stats.SetValue(string,double,string,object), Stats.Flush(), and
// Stats.GetLocalPlayerStats(string packageIdent) returning the NESTED
// Stats.PlayerStats (Get(name) -> Stats.PlayerStat with .Value). There is
// NO Stats.LocalPlayer property on this SDK.
// -----------------------------------------------------------------------------
public class AddSteamStatCurrencyHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "SteamStatCurrency", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
string statName = p.TryGetProperty( "statName", out var sv ) && !string.IsNullOrWhiteSpace( sv.GetString() ) ? sv.GetString() : "currency";
string packageIdent = p.TryGetProperty( "packageIdent", out var pv ) && !string.IsNullOrWhiteSpace( pv.GetString() ) ? pv.GetString() : "";
bool flushEveryChange = p.TryGetProperty( "flushEveryChange", out var fv ) && fv.ValueKind == JsonValueKind.True;
// Baked into generated string literals -- strip escape characters.
statName = statName.Replace( "\\", "" ).Replace( "\"", "" );
packageIdent = packageIdent.Replace( "\\", "" ).Replace( "\"", "" );
var code = BuildCode( className, statName, packageIdent, flushEveryChange );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = EconomySaveHelpers.PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
statName,
packageIdent = string.IsNullOrEmpty( packageIdent ) ? "(Game.Ident -- the running package)" : packageIdent,
flushEveryChange,
placedOn,
note,
nextSteps = new[]
{
$"trigger_hotload to compile {className} into the game assembly.",
placedOn != null
? $"{className} was attached to the target GameObject."
: $"Place it on the LOCAL player's GameObject (each player writes only their own Steam stat): add_component_to_new_object (component=\"{className}\") after the hotload, or re-run with targetId.",
$"Use it: GetComponent<{className}>().Add( 25 ); if ( GetComponent<{className}>().TrySpend( 10 ) ) {{ }} -- Balance is the in-session truth; every change pushes Stats.SetValue.",
$"React: {className}.OnBalanceLoaded += bal => {{ }}; and instance OnBalanceChanged for HUD labels. Wait for IsLoaded before showing the balance -- the read-back is async.",
"CLOUD SEMANTICS: stats writes are buffered by the backend (Flush() pushes; the component flushes on destroy) and only apply to the LOCAL Steam user -- calling it for another player silently does nothing. Read-back is eventually consistent and can lag minutes; the in-session Balance property is authoritative while playing.",
"Stats persist per Steam account per package ident -- dev sessions without a real published ident may read back nothing (you'll get balance 0 + a log line). This is Steam-cloud persistence, not a local save file; pair with create_signed_save if you need offline saves."
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"add_steam_stat_currency failed: {ex.Message}" } );
}
}
static string BuildCode( string className, string statName, string packageIdent, bool flushEveryChange )
{
string flushLit = flushEveryChange ? "true" : "false";
return $@"using Sandbox;
using Sandbox.Services;
using System;
/// <summary>
/// {className} -- a currency persisted over Sandbox.Services.Stats (Steam cloud).
///
/// The stat named StatName stores the ABSOLUTE balance (Stats.SetValue on every change);
/// on start the component reads it back asynchronously via
/// Stats.GetLocalPlayerStats(ident).Refresh() -> Get(StatName).Value and fires
/// OnBalanceLoaded. While playing, the in-session Balance property is the authoritative
/// value -- the cloud read-back is eventually consistent and can lag behind writes.
///
/// SCOPE: stats writes apply only to the LOCAL Steam user (writes for other players
/// silently no-op) and persist per package ident. Attach this to the local player's
/// GameObject; IsProxy guards keep remote copies inert. Dev sessions without a real
/// published ident may read back nothing (balance starts at 0).
///
/// Usage:
/// GetComponent<{className}>().Add( 25 );
/// if ( GetComponent<{className}>().TrySpend( 10 ) ) {{ /* grant the thing */ }}
/// {className}.OnBalanceLoaded += bal => {{ /* show the wallet */ }};
/// </summary>
public sealed class {className} : Component
{{
/// The Sandbox.Services stat that stores the balance.
[Property] public string StatName {{ get; set; }} = ""{statName}"";
/// Package ident to read stats from. Empty = the running package (Game.Ident).
[Property] public string PackageIdent {{ get; set; }} = ""{packageIdent}"";
/// Push Stats.Flush() after every change (rate-limited by the backend) instead of
/// relying on the buffered flush + the OnDestroy flush.
[Property] public bool FlushEveryChange {{ get; set; }} = {flushLit};
/// In-session balance -- authoritative while playing. Cloud value catches up on flush.
public double Balance {{ get; private set; }}
/// True once the async cloud read-back has completed (successfully or not).
public bool IsLoaded {{ get; private set; }}
/// Fires once after the cloud read-back completes, with the loaded balance.
public static Action<double> OnBalanceLoaded {{ get; set; }}
/// Fires on every balance change (including the initial load) -- bind a HUD here.
public Action<double> OnBalanceChanged {{ get; set; }}
protected override void OnStart()
{{
if ( IsProxy ) return; // only the local player's machine touches their stats
_ = LoadAsync();
}}
protected override void OnDestroy()
{{
if ( !IsProxy && IsLoaded ) Stats.Flush();
}}
/// <summary>Re-read the balance from the stats backend (async; also runs on start).</summary>
public async System.Threading.Tasks.Task LoadAsync()
{{
double loaded = 0.0;
try
{{
string ident = string.IsNullOrWhiteSpace( PackageIdent ) ? Game.Ident : PackageIdent;
var stats = Stats.GetLocalPlayerStats( ident );
await stats.Refresh();
loaded = stats.Get( StatName ).Value;
}}
catch ( Exception ex )
{{
Log.Warning( $""[{className}] Stat read-back failed ({{ex.Message}}) -- starting at 0. Stats need a valid package ident + Steam session."" );
}}
Balance = loaded;
IsLoaded = true;
OnBalanceLoaded?.Invoke( Balance );
OnBalanceChanged?.Invoke( Balance );
}}
public bool CanAfford( double amount ) => Balance >= amount;
/// <summary>Add currency and push the new balance to the stats backend. Non-positive ignored.</summary>
public void Add( double amount )
{{
if ( IsProxy || amount <= 0.0 ) return;
Balance += amount;
Push();
}}
/// <summary>Spend if affordable; returns false and changes nothing if not.</summary>
public bool TrySpend( double amount )
{{
if ( IsProxy || amount <= 0.0 ) return false;
if ( Balance < amount ) return false;
Balance -= amount;
Push();
return true;
}}
/// <summary>Force-push buffered stat writes to the backend now (rate-limited upstream).</summary>
public void Flush() => Stats.Flush();
// Write the absolute balance to the stat and notify listeners.
private void Push()
{{
Stats.SetValue( StatName, Balance, null, null );
if ( FlushEveryChange ) Stats.Flush();
OnBalanceChanged?.Invoke( Balance );
}}
}}
";
}
}
// -----------------------------------------------------------------------------
// create_loot_table_resource -- the data-asset sibling of create_weighted_loot_table.
// Generates ONE .cs containing: an entry POCO (name, weight, optional nested table
// reference), a GameResource loot-table asset type ([AssetType] -- the modern
// attribute; GameResourceAttribute is [Obsolete] on this SDK), and a resolver
// Component that rolls a table by cumulative weight with a resolve depth cap.
// Designers author .loot files in the asset browser; code rolls them.
// -----------------------------------------------------------------------------
public class CreateLootTableResourceHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "LootTableResource", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
string extension = p.TryGetProperty( "extension", out var ev ) && !string.IsNullOrWhiteSpace( ev.GetString() ) ? ev.GetString() : "loot";
string title = p.TryGetProperty( "title", out var tv ) && !string.IsNullOrWhiteSpace( tv.GetString() ) ? tv.GetString() : "Loot Table";
int maxDepth = p.TryGetProperty( "maxDepth", out var mv ) && mv.TryGetInt32( out var mi ) ? mi : 4;
if ( maxDepth < 0 ) maxDepth = 0;
if ( maxDepth > 16 ) maxDepth = 16;
// Extension: lowercase alphanumerics only.
var extChars = new StringBuilder();
foreach ( var c in extension.ToLowerInvariant() )
if ( ( c >= 'a' && c <= 'z' ) || ( c >= '0' && c <= '9' ) ) extChars.Append( c );
extension = extChars.Length > 0 ? extChars.ToString() : "loot";
// Title is baked into an attribute string literal.
title = title.Replace( "\\", "" ).Replace( "\"", "" );
var resolverClass = className + "Resolver";
var code = BuildCode( className, resolverClass, extension, title, maxDepth );
ScaffoldHelpers.WriteCode( fullPath, code );
// Placement attaches the RESOLVER component (the resource itself is an asset type, not a component).
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = EconomySaveHelpers.PlaceOnTarget( tid.GetString(), resolverClass, out note );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
resolverClass,
extension,
maxDepth,
placedOn,
note,
nextSteps = new[]
{
$"trigger_hotload to compile {className} + {resolverClass} into the game assembly -- the '.{extension}' asset type registers on compile.",
$"Author tables as ASSETS: in the editor asset browser, New > {title} creates a .{extension} file; fill Entries (Name, Weight, optional NestedTable reference to another .{extension}) in the inspector.",
placedOn != null
? $"{resolverClass} was attached to the target GameObject -- assign its Table property to a .{extension} asset (set_property with the asset path)."
: $"Attach the resolver: add_component_to_new_object (component=\"{resolverClass}\") after the hotload, then set its Table property to a .{extension} asset path.",
$"Roll from game code (host-side): string drop = GetComponent<{resolverClass}>().Roll(); {resolverClass}.OnLoot += ( go, item ) => {{ }};",
$"Nested tables: an entry with a NestedTable rolls INTO that table instead of dropping its Name -- capped at MaxDepth ({maxDepth}) with a self-reference guard, so cycles terminate.",
"Use create_weighted_loot_table instead when you want a single inline component with no asset files; use create_gacha_drop_table for pity + duplicate mechanics. Pick an extension that is NOT a suffix of a built-in one (e.g. avoid 'cfg') or ResourceLibrary.GetAll will pick up engine files as phantom instances."
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_loot_table_resource failed: {ex.Message}" } );
}
}
static string BuildCode( string className, string resolverClass, string extension, string title, int maxDepth )
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
string md = maxDepth.ToString( ci );
return $@"using Sandbox;
using System;
using System.Collections.Generic;
/// <summary>
/// One row of a {className} asset. Amount is picked by cumulative weight; when
/// NestedTable is set the roll continues INTO that table instead of dropping Name.
/// </summary>
public sealed class {className}Entry
{{
/// What drops when this entry wins (ignored when NestedTable is set).
[Property] public string Name {{ get; set; }} = """";
/// Relative chance. Bigger = more likely. Entries with weight <= 0 never win.
[Property] public float Weight {{ get; set; }} = 1f;
/// Optional: roll this table instead of dropping Name (depth-capped on resolve).
[Property] public {className} NestedTable {{ get; set; }}
}}
/// <summary>
/// {className} -- a designer-authored loot table ASSET (.{extension} files).
///
/// Each .{extension} file holds weighted entries; entries may reference other
/// .{extension} assets as nested tables (rarity tiers, per-biome sub-tables).
/// Resolve() rolls by cumulative weight and follows nested references up to a
/// depth cap, so cyclic references terminate. Author the files in the editor
/// asset browser; roll them with {resolverClass} or call Resolve() directly.
/// </summary>
[AssetType( Name = ""{title}"", Extension = ""{extension}"", Category = ""Game"" )]
public sealed class {className} : GameResource
{{
/// The weighted rows of this table.
[Property] public List<{className}Entry> Entries {{ get; set; }} = new List<{className}Entry>();
/// <summary>
/// Roll once: pick an entry by cumulative weight; if it references a nested table,
/// keep rolling into it until a plain entry wins or maxDepth is exhausted (then the
/// deepest entry's Name is returned). Null when the table is empty. HOST-authoritative:
/// roll on the host and replicate the result -- clients rolling their own loot is the
/// classic economy exploit.
/// </summary>
public string Resolve( int maxDepth = {md} )
{{
var entry = RollEntry();
if ( entry == null ) return null;
if ( entry.NestedTable != null && entry.NestedTable != this && maxDepth > 0 )
return entry.NestedTable.Resolve( maxDepth - 1 );
return entry.Name;
}}
// Cumulative-weight pick over Entries. Null when empty; first entry when all weights are zero.
private {className}Entry RollEntry()
{{
if ( Entries == null || Entries.Count == 0 ) return null;
float total = 0f;
foreach ( var e in Entries )
if ( e != null && e.Weight > 0f ) total += e.Weight;
if ( total <= 0f ) return Entries[0];
float roll = Game.Random.Float( 0f, total );
float cumulative = 0f;
{className}Entry winner = null;
foreach ( var e in Entries )
{{
if ( e == null || e.Weight <= 0f ) continue;
winner = e;
cumulative += e.Weight;
if ( roll < cumulative ) break;
}}
return winner;
}}
}}
/// <summary>
/// {resolverClass} -- rolls a {className} asset from the scene.
///
/// Assign Table to a .{extension} asset in the inspector (or via set_property with the
/// asset path). Roll() resolves through nested tables up to MaxDepth and fires the
/// static OnLoot event with the winning item name. Call it host-side and replicate
/// the result yourself ([Sync] or an [Rpc.Broadcast]).
///
/// Usage:
/// string drop = GetComponent<{resolverClass}>().Roll();
/// {resolverClass}.OnLoot += ( go, item ) => Log.Info( $""{{go.Name}} got {{item}}"" );
/// </summary>
public sealed class {resolverClass} : Component
{{
/// The loot table asset this resolver rolls.
[Property] public {className} Table {{ get; set; }}
/// How deep nested-table references may chain before the roll settles.
[Property] public int MaxDepth {{ get; set; }} = {md};
/// Fires (on the rolling machine) when Roll() picks a winner: (roller, itemName).
public static Action<GameObject, string> OnLoot {{ get; set; }}
/// <summary>
/// Roll the assigned table once. Null (with a warning) when no Table is assigned or
/// the table is empty. HOST-authoritative by convention -- see the class summary.
/// </summary>
public string Roll()
{{
if ( Table == null )
{{
Log.Warning( $""[{resolverClass}] No Table assigned on {{GameObject.Name}} -- assign a .{extension} asset."" );
return null;
}}
var drop = Table.Resolve( MaxDepth );
if ( drop != null ) OnLoot?.Invoke( GameObject, drop );
return drop;
}}
}}
";
}
}
/// <summary>
/// Shared placement helper for the economy/save handlers -- mirrors the standard scaffold
/// placement (create_economy_wallet / create_weighted_loot_table / LootEconomyHelpers).
/// </summary>
internal static class EconomySaveHelpers
{
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; }
}
}
Editor
library
// AUTO-GENERATED by scripts/emit-mcp-wrappers.mjs β DO NOT EDIT.
// Regenerate: node scripts/extract-manifest.mjs && node scripts/emit-mcp-wrappers.mjs
// Source of truth: sbox-mcp-server/src/tools/ (zod schemas) β scripts/tools-manifest.json
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Editor.Mcp;
/// <summary>
/// Project info and config (.sbproj), file read/write, C# script create/edit/delete, hotload, input
/// actions, and publishing metadata.
/// </summary>
[McpToolset( "bridge_project", "Project info and config (.sbproj), file read/write, C# script create/edit/delete, hotload, input actions, and publishing metadata." )]
public static class BridgeProjectTools
{
/// <summary>
/// Create a new C# component script in the project β a minimal s&box Component class (name is
/// sanitized to a valid identifier), or your exact code when content is provided. Errors if the
/// file already exists. Returns { path, created, className } β the new type is NOT live until a
/// recompile, so call trigger_hotload, then attach it with add_component_with_properties
/// (component=className).
/// </summary>
/// <param name="name">Class name for the component (e.g. 'PlayerController'). Will also be the filename.</param>
/// <param name="directory">Subdirectory under code/ to place the script (e.g. 'Components'). Defaults to 'code/'.</param>
/// <param name="description">Description of what this component does β used to generate appropriate code.</param>
/// <param name="properties">List of [Property] fields to include in the component. JSON array.</param>
/// <param name="content">Full C# file content. If provided, ignores name/properties and writes this directly.</param>
[McpTool( "create_script" )]
public static Task<object> CreateScript( string name, string directory = null, string description = null, JsonNode properties = null, string content = null )
=> McpGate.Run( "create_script", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "description", description ), ( "properties", properties ), ( "content", content ) ) );
/// <summary>
/// Permanently delete a file from the project by its project-relative path (built for C# scripts,
/// but removes any file; no recycle bin, and editor undo cannot restore it). Errors if the file
/// doesn't exist. Returns a confirmation with the path β follow with trigger_hotload so the removed
/// class actually leaves the compiled assembly.
/// </summary>
/// <param name="path">Relative path to the script file to delete.</param>
[McpTool( "delete_script" )]
public static Task<object> DeleteScript( string path )
=> McpGate.Run( "delete_script", McpGate.Args( ( "path", path ) ) );
/// <summary>
/// One-call project orientation: identity (name/ident/org/type), the open scene with object count,
/// scene and prefab file lists (capped at 50 each, Libraries/.sbox excluded), code footprint
/// (.cs/.razor counts), custom Component types (up to 100, engine types excluded), and installed
/// libraries. Returns a structured summary β orient here first, then get_scene_hierarchy for the
/// scene, describe_type for components, find_broken_references for project health. Read-only.
/// </summary>
[McpTool.ReadOnly( "describe_project" )]
public static Task<object> DescribeProject()
=> McpGate.Run( "describe_project", McpGate.Args() );
/// <summary>
/// Edit an existing C# script in place via exact-text find/replace or a full-content overwrite.
/// Errors if the file or the find text isn't found (find/replace replaces ALL occurrences). Returns
/// { path, edited, operation } where operation is 'find_replace' or 'overwrite' β follow with
/// trigger_hotload so the change compiles, then get_compile_errors if in doubt.
/// </summary>
/// <param name="path">Relative path to the script file (e.g. 'code/PlayerController.cs').</param>
/// <param name="operations">List of edit operations to apply in order. JSON array.</param>
[McpTool( "edit_script" )]
public static Task<object> EditScript( string path, JsonNode operations )
=> McpGate.Run( "edit_script", McpGate.Args( ( "path", path ), ( "operations", operations ) ) );
/// <summary>
/// Register a custom named INPUT ACTION in the project so a generated game's custom verbs work in
/// play mode. Writes to <project>.sbproj β Metadata.InputSettings.Actions[]. Idempotent: if
/// the action already exists it is left alone (pass update=true to rebind its key). If the project
/// has no InputSettings yet, the full DEFAULT action set
/// (Forward/Back/Left/Right/Jump/Use/attack1/...) is seeded first so player movement/use are
/// preserved β the engine only auto-injects defaults when a game defines NONE. After adding, call
/// it from game code with Input.Pressed("name") / Input.Down("name") / Input.Released("name").
/// Note: input config is read at project load, so restart_editor (or reload the project) for a new
/// action to take effect in play mode.
/// </summary>
/// <param name="name">The action verb game code will call, e.g. "interact", "sprint", "drop". Matches Input.Pressed("interact").</param>
/// <param name="keyboardKey">Default keyboard binding, e.g. "e", "f", "space", "mouse1", "shift". Omit to add the action with no default key (player can bind it).</param>
/// <param name="group">UI group the action is listed under in the bindings menu (e.g. "Actions", "Movement", "Other"). Defaults to "Actions".</param>
/// <param name="update">If the action already exists, rebind its keyboardKey to the provided value instead of leaving it untouched. Default false (idempotent no-op when present).</param>
[McpTool( "ensure_input_action" )]
public static Task<object> EnsureInputAction( string name, string keyboardKey = null, string group = null, bool? update = null )
=> McpGate.Run( "ensure_input_action", McpGate.Args( ( "name", name ), ( "keyboardKey", keyboardKey ), ( "group", group ), ( "update", update ) ) );
/// <summary>
/// Fetch package information from the s&box package backend (Package.FetchAsync) by ident.
/// Returns { fullIdent, title, summary, description, org } β no download/rating/dependency data is
/// included. Use it to confirm a package exists and what it is before install_asset.
/// </summary>
/// <param name="ident">Package identifier (e.g. 'facepunch.flatgrass', 'myorg.mygame').</param>
[McpTool.ReadOnly( "get_package_details" )]
public static Task<object> GetPackageDetails( string ident )
=> McpGate.Run( "get_package_details", McpGate.Args( ( "ident", ident ) ) );
/// <summary>
/// Read the full project configuration from the .sbproj file including title, description, version,
/// type, package references, metadata, and raw JSON.
/// </summary>
[McpTool.ReadOnly( "get_project_config" )]
public static Task<object> GetProjectConfig()
=> McpGate.Run( "get_project_config", McpGate.Args() );
/// <summary>
/// Get information about the current s&box project β path, name, game type, dependencies, and
/// configuration.
/// </summary>
[McpTool.ReadOnly( "get_project_info" )]
public static Task<object> GetProjectInfo()
=> McpGate.Run( "get_project_info", McpGate.Args() );
/// <summary>
/// Browse the project file tree. Optionally filter by directory path and/or file extension (e.g.
/// '.cs', '.scene'). Returns { path, count, files } as project-root-relative paths β CAPPED AT 500
/// files (count reflects the truncated list, with no marker that more exist), so on large projects
/// narrow with path/extension or use find_in_project. Recursive by default.
/// </summary>
/// <param name="path">Relative directory path to list (e.g. 'code/Components'). Defaults to project root.</param>
/// <param name="extension">Filter by file extension, including the dot (e.g. '.cs', '.scene').</param>
/// <param name="recursive">Whether to list files recursively. Defaults to true.</param>
[McpTool.ReadOnly( "list_project_files" )]
public static Task<object> ListProjectFiles( string path = null, string extension = null, bool? recursive = null )
=> McpGate.Run( "list_project_files", McpGate.Args( ( "path", path ), ( "extension", extension ), ( "recursive", recursive ) ) );
/// <summary>
/// Read the contents of a file in the s&box project (scripts, scenes, configs, etc.).
/// </summary>
/// <param name="path">Relative path to the file within the project (e.g. 'code/PlayerController.cs').</param>
[McpTool.ReadOnly( "read_file" )]
public static Task<object> ReadFile( string path )
=> McpGate.Run( "read_file", McpGate.Args( ( "path", path ) ) );
/// <summary>
/// Update project configuration fields for publishing: title, description, version, type, package
/// ident, summary, visibility. Only provided fields are changed β edits string values in the
/// .sbproj file in place. Returns { updated, path } (the .sbproj path); read the result back with
/// get_project_config to confirm what actually changed.
/// </summary>
/// <param name="title">Project display title.</param>
/// <param name="description">Project description for publishing.</param>
/// <param name="version">Version string (e.g. '1.0.0', '2.1.3').</param>
/// <param name="type">Project type: 'game', 'addon', 'library', or 'template'.</param>
/// <param name="packageIdent">Package identifier (e.g. 'myorg.mygame').</param>
/// <param name="summary">Short summary for asset.party listing.</param>
/// <param name="isPublic">Whether the project is publicly visible.</param>
[McpTool( "set_project_config" )]
public static Task<object> SetProjectConfig( string title = null, string description = null, string version = null, string type = null, string packageIdent = null, string summary = null, bool? isPublic = null )
=> McpGate.Run( "set_project_config", McpGate.Args( ( "title", title ), ( "description", description ), ( "version", version ), ( "type", type ), ( "packageIdent", packageIdent ), ( "summary", summary ), ( "isPublic", isPublic ) ) );
/// <summary>
/// Set or update the project thumbnail image (thumb.png) used for publishing. Provide either a
/// source path or base64 image data.
/// </summary>
/// <param name="sourcePath">Relative path to an image file within the project to use as thumbnail.</param>
/// <param name="base64">Base64-encoded image data to write as thumbnail.</param>
/// <param name="format">Image format when using base64 mode. Defaults to 'png'. One of: png | jpg.</param>
[McpTool( "set_project_thumbnail" )]
public static Task<object> SetProjectThumbnail( string sourcePath = null, string base64 = null, string format = null )
=> McpGate.Run( "set_project_thumbnail", McpGate.Args( ( "sourcePath", sourcePath ), ( "base64", base64 ), ( "format", format ) ) );
/// <summary>
/// Force s&box to recompile and hotload all C# scripts immediately. Use after creating or
/// editing scripts to see changes in real-time.
/// </summary>
[McpTool( "trigger_hotload" )]
public static Task<object> TriggerHotload()
=> McpGate.Run( "trigger_hotload", McpGate.Args() );
/// <summary>
/// Write or overwrite a file in the s&box project (SILENTLY replaces existing content β
/// read_file first if you need to preserve it). Creates parent directories as needed; paths are
/// confined to the project root (traversal outside it is denied). Returns a confirmation with the
/// path β for C# follow with trigger_hotload so it compiles; for assets (.vmat etc.) follow with
/// recompile_asset.
/// </summary>
/// <param name="path">Relative path for the file (e.g. 'code/Components/Health.cs').</param>
/// <param name="content">The full file content to write.</param>
[McpTool( "write_file" )]
public static Task<object> WriteFile( string path, string content )
=> McpGate.Run( "write_file", McpGate.Args( ( "path", path ), ( "content", content ) ) );
}
Editor
library
// AUTO-GENERATED by scripts/emit-mcp-wrappers.mjs β DO NOT EDIT.
// Regenerate: node scripts/extract-manifest.mjs && node scripts/emit-mcp-wrappers.mjs
// Source of truth: sbox-mcp-server/src/tools/ (zod schemas) β scripts/tools-manifest.json
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Editor.Mcp;
/// <summary>
/// Lint and validate the project: networking footguns, sandbox whitelist violations, Razor
/// transpiler footguns, scene setup issues, publishing readiness, save-file inspection, and
/// networked-object state dumps.
/// </summary>
[McpToolset( "bridge_validation", "Lint and validate the project: networking footguns, sandbox whitelist violations, Razor transpiler footguns, scene setup issues, publishing readiness, save-file inspection, and networked-object state dumps." )]
public static class BridgeValidationTools
{
/// <summary>
/// Scan the project for broken references, two layers in one call: (1) every GameObject in the open
/// scene β renderers with no Model (missing_model), component properties pointing at DESTROYED
/// GameObjects/Components (dead_gameobject_ref / dead_component_ref), null component entries whose
/// type no longer exists (missing_component); (2) every .scene/.prefab FILE β prefab references to
/// deleted/renamed files (missing_prefab_file). Returns { total, showing, truncated,
/// objectsScanned, filesScanned, issues } β each issue has { id, name, component, kind, detail }
/// (file-level issues carry the file path in name). Fix missing models with assign_model, dead refs
/// with set_property/set_component_reference, missing prefab files by fixing the path or recreating
/// via create_prefab. Read-only; safe any time. Results cap at `limit` (default 100, max 500).
/// </summary>
/// <param name="limit">Max issues to return (default 100, max 500). total still counts everything.</param>
/// <param name="scanFiles">Include the .scene/.prefab file scan for missing prefab references. Default true.</param>
[McpTool.ReadOnly( "find_broken_references" )]
public static Task<object> FindBrokenReferences( int? limit = null, bool? scanFiles = null )
=> McpGate.Run( "find_broken_references", McpGate.Args( ( "limit", limit ), ( "scanFiles", scanFiles ) ) );
/// <summary>
/// Inspect the live networking contract of a GameObject. Returns {id, name, network: {active,
/// isProxy, isOwner, isCreator, ownerId, ownerSteamId, ownerTransfer, orphaned, flags}, components:
/// [{component, fields: [{name, type, isSync, syncFlags, value}]}]} β by default only [Sync]-marked
/// fields are listed (components with none are omitted). Unlike get_network_status (session-only),
/// this is per-object β the way to verify a host-authoritative or ownership change actually
/// replicated; works in edit or play mode. Follow up with set_ownership to change the owner, or
/// networking_lint to find the code-level cause of a bad [Sync] value.
/// </summary>
/// <param name="id">GUID of the GameObject to inspect.</param>
/// <param name="allProps">Include all component properties, not just [Sync]-marked ones.</param>
[McpTool.ReadOnly( "inspect_networked_object" )]
public static Task<object> InspectNetworkedObject( string id, bool allProps = false )
=> McpGate.Run( "inspect_networked_object", McpGate.Args( ( "id", id ), ( "allProps", allProps ) ) );
/// <summary>
/// Static-scan the project's C# for the highest-frequency networking/authority bugs: a mutator that
/// writes a [Sync] field with no IsProxy/Networking.IsHost guard; money/health/score-shaped fields
/// marked plain [Sync] (should be SyncFlags.FromHost); List<>/Dictionary<> marked
/// [Sync] (should be NetList/NetDictionary); [Sync] fields typed Connection/GameObject (sync a Guid
/// instead); [Rpc.Host] methods that mutate without re-checking Rpc.Caller; and component swaps /
/// reflection writes missing Network.Refresh(). Returns findings with file:line + the suggested
/// fix.
/// </summary>
/// <param name="path">Optional sub-path under the project (e.g. 'Code/Player') to scope the scan; omit for the whole project.</param>
[McpTool.ReadOnly( "networking_lint" )]
public static Task<object> NetworkingLint( string path = null )
=> McpGate.Run( "networking_lint", McpGate.Args( ( "path", path ) ) );
/// <summary>
/// Static-scan .razor and .razor.scss files for the silent footguns that crash the Razor transpiler
/// or stylesheet engine with no useful error message: switch expressions inside @code blocks (use
/// if/else instead), non-ASCII/emoji inside @code (move to markup or a string constant),
/// PanelComponent subclasses missing a BuildHash override (panel never re-renders), and root
/// uppercase type-selector rules in .razor.scss (silently skipped -- use a class selector like
/// .my-panel). Returns { scanned, findings: [{file, line, match, advice}], clean } matching the
/// sandbox_lint shape.
/// </summary>
/// <param name="directory">Subdirectory under the project root to scan (e.g. 'UI', 'Code'). Defaults to 'Code'.</param>
[McpTool.ReadOnly( "razor_lint" )]
public static Task<object> RazorLint( string directory = null )
=> McpGate.Run( "razor_lint", McpGate.Args( ( "directory", directory ) ) );
/// <summary>
/// Static-scan the project's C# for s&box sandbox whitelist violations BEFORE they cause
/// compile errors: System.MathF (use MathX), System.Math (use MathX), Array.Clone() (use
/// .ToArray()), System.Net / raw sockets (use Sandbox.Http), System.IO.File (use FileSystem.Data),
/// and raw System.Threading.Thread (use async/Task or GameTask). Returns { scanned, findings:
/// [{file, line, match, advice}], clean }. Scope to a subdirectory with the directory param.
/// </summary>
/// <param name="directory">Subdirectory under the project root to scan (e.g. 'Code', 'Code/Player'). Defaults to 'Code'.</param>
[McpTool.ReadOnly( "sandbox_lint" )]
public static Task<object> SandboxLint( string directory = null )
=> McpGate.Run( "sandbox_lint", McpGate.Args( ( "directory", directory ) ) );
/// <summary>
/// Inspect the game's FileSystem.Data save files β the assistant is otherwise blind to persisted
/// state. action='list' (default) returns `directories` and `files` [{name, path, size}] under
/// `path` (omit path for the Data root); action='read' returns {path, length, content}, truncating
/// content at 60,000 chars; action='diff' compares two save files key-by-key, returning `diffCount`
/// and up to 200 `diffs` [{key, change: added|removed|changed}]. Use to verify a save actually
/// wrote, debug a load/migration, or confirm a sanitize/clamp ran.
/// </summary>
/// <param name="action">'list' (default) enumerates a folder; 'read' dumps one file's JSON; 'diff' compares `path` vs `pathB`. One of: list | read | diff. Default: "list".</param>
/// <param name="path">File or folder path under FileSystem.Data (e.g. 'lumber_corp2_progress' or '<folder>/steam_123.json').</param>
/// <param name="pathB">Second file path for action='diff'.</param>
[McpTool.ReadOnly( "save_inspect" )]
public static Task<object> SaveInspect( string action = "list", string path = null, string pathB = null )
=> McpGate.Run( "save_inspect", McpGate.Args( ( "action", action ), ( "path", path ), ( "pathB", pathB ) ) );
/// <summary>
/// Validate the active scene for the silent setup footguns that break controllers/physics/cameras:
/// no CameraComponent, no player controller, multiple root Rigidbodies, a Rigidbody with
/// MotionEnabled=false fighting a kinematic root, IsTrigger colliders that Scene.Trace will ignore,
/// child Rigidbodies breaking collider binding, and missing required child anchors. Returns each
/// issue with the GameObject and the exact fix.
/// </summary>
[McpTool.ReadOnly( "scene_validate" )]
public static Task<object> SceneValidate()
=> McpGate.Run( "scene_validate", McpGate.Args() );
/// <summary>
/// Read from Sandbox.Services β the cloud stats/leaderboard layer many games use as their real DB.
/// action='stats' with `name` returns the local player's stat {ident, value, sum, min, max,
/// lastValue, valueString}; without `name` it returns only the package ident plus a usage note (it
/// does NOT list stat definitions). action='leaderboard' (name required) returns {board,
/// displayName, totalEntries, count, entries} with at most `limit` entries (default 10). Read-only;
/// use to verify a Stats.Increment/SetValue path or a leaderboard wired correctly.
/// </summary>
/// <param name="action">'stats' (default) reads a local-player stat by `name`; 'leaderboard' fetches a board's top entries. One of: stats | leaderboard. Default: "stats".</param>
/// <param name="name">Stat name (action='stats') or leaderboard/board name (action='leaderboard').</param>
/// <param name="limit">Max leaderboard entries to return.</param>
[McpTool.ReadOnly( "services_query" )]
public static Task<object> ServicesQuery( string action = "stats", string name = null, int limit = 10 )
=> McpGate.Run( "services_query", McpGate.Args( ( "action", action ), ( "name", name ), ( "limit", limit ) ) );
/// <summary>
/// Validate that the project is ready for publishing. Runs four checks: .sbproj exists, at least
/// one scene, project Ident set, project Title set. Returns { valid, issueCount, issues, checks } β
/// issues are human-readable problems and each checks entry has { check, pass, detail }; fix
/// metadata gaps with set_project_config (it does NOT check compile errors β use get_compile_errors
/// for that).
/// </summary>
[McpTool.ReadOnly( "validate_project" )]
public static Task<object> ValidateProject()
=> McpGate.Run( "validate_project", McpGate.Args() );
}
Editor
library
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// NPC Brains β Feature Wave #3 (Phase 1 + simulate_npc_perception)
//
// Compiles into the SAME editor assembly as MyEditorMenu.cs, so it can use the
// shared helpers there directly: ClaudeBridge.TryResolveProjectPath /
// SanitizeIdentifier / ParseVector3, SceneToolHelpers.*, and the IBridgeHandler
// interface. These handlers run in the UNSANDBOXED editor (System.Math/MathF/IO
// are all fine here).
//
// The C# *strings these handlers generate* run in the SANDBOX (the game). That
// generated code is deliberately restricted to APIs already proven to compile in
// the sandbox by the existing create_npc_controller / create_networked_player
// generators: Component, [Property], [Sync], GetOrAddComponent<NavMeshAgent>(),
// NavMeshAgent.MoveTo(Vector3), IsProxy, TimeSince, Vector3.Dot/.Normal/
// .DistanceBetween, Scene.GetAllComponents<T>(), scene.Trace.Ray(a,b).Run(),
// MathX.Clamp. MathX preferred in generated code; System.Math/MathF also compile on the current SDK (verified 2026-06-09). Array.Clone() still blocked.
//
// Tools in this file:
// create_npc_brain (code-gen; scene-mutating)
// place_patrol_route (scene-mutating)
// assign_patrol_route (scene-mutating)
// create_npc_spawner (code-gen; scene-mutating)
// simulate_npc_perception (READ-ONLY; not scene-mutating)
//
// Register(...) lines + _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 NPC-brain generators. Kept internal to this file so it
/// does not collide with anything in MyEditorMenu.cs.
/// </summary>
internal static class NpcBrainHelpers
{
/// <summary>
/// Read an optional float param, falling back to <paramref name="fallback"/>.
/// Tolerates the value arriving as 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(), 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, e.g.
/// 130 -> "130f", 0.25 -> "0.25f". Invariant culture matters so a comma-decimal
/// locale on the editor machine cannot emit "0,25f" and break compilation.
/// </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 safe embedding inside a C# double-quoted verbatim
/// string ( @"" ), where the only escape needed is doubling the quote char.
/// TargetTag is also identifier-ish but tags can legitimately contain symbols,
/// so we keep it a string literal rather than sanitizing it to an identifier.
/// </summary>
public static string EscVerbatim( string raw ) => ( raw ?? "" ).Replace( "\"", "\"\"" );
/// <summary>
/// cos( fovDegrees / 2 ) computed in the EDITOR (MathF is legal here). Baked as
/// the default of the generated CosFovThreshold property so the sandbox brain
/// never needs trig. Clamped to a sane FOV range first.
/// </summary>
public static float CosHalfFov( float fovDegrees )
{
var fov = Math.Clamp( fovDegrees, 1f, 360f );
var halfRad = ( fov * 0.5f ) * ( MathF.PI / 180f );
return MathF.Cos( halfRad );
}
/// <summary>
/// Resolve the component on <paramref name="go"/> that exposes a property named
/// <paramref name="property"/>, and SET that property to <paramref name="value"/>.
/// Preferred match is a component literally named "NpcBrain"; otherwise the first
/// component whose TypeLibrary description has that property. Returns the matched
/// component (so the caller can report its name), or null if none matched.
///
/// We deliberately do the find+set inside one method so this file never has to
/// name the reflection types (TypeDescription / PropertyDescription) β the rest
/// of the addon always uses `var` for them, which means their namespace is not
/// guaranteed to be importable here. Keeping it all behind `var` mirrors the
/// proven SetPrefabRefHandler pattern exactly.
/// </summary>
public static Component SetComponentProperty( GameObject go, string property, object value )
{
Component fallbackComp = null;
// Pass 1: prefer an NpcBrain. Pass 2: any component exposing the property.
foreach ( var c in go.Components.GetAll() )
{
var td = Game.TypeLibrary.GetType( c.GetType().Name );
var pd = td?.Properties.FirstOrDefault( pp => pp.Name == property );
if ( pd == null ) continue;
if ( c.GetType().Name.Equals( "NpcBrain", StringComparison.OrdinalIgnoreCase ) )
{
pd.SetValue( c, value );
return c;
}
fallbackComp = fallbackComp ?? c;
}
if ( fallbackComp != null )
{
var td = Game.TypeLibrary.GetType( fallbackComp.GetType().Name );
var pd = td?.Properties.FirstOrDefault( pp => pp.Name == property );
pd?.SetValue( fallbackComp, value );
}
return fallbackComp;
}
/// <summary>
/// Find the "perception brain" component on <paramref name="go"/> β the component
/// simulate_npc_perception should read SightRange/FovDegrees/EyeHeight/TargetTag from.
///
/// Why not just match the type name "NpcBrain": a custom-named brain (e.g. BigfootBrain,
/// generated via create_npc_brain with name="BigfootBrain") exposes the same perception
/// [Property] surface but a different type name, so a literal name match silently falls
/// back to spec defaults. We match by CAPABILITY instead:
/// 1. a component literally named "NpcBrain" (the default), else
/// 2. a component whose TypeLibrary description exposes BOTH SightRange and FovDegrees
/// (the perception contract), else
/// 3. a component whose type name ends with "Brain".
/// Returns null if none match (caller then uses defaults / explicit overrides).
/// </summary>
public static Component FindPerceptionBrain( GameObject go )
{
if ( go == null ) return null;
Component byProps = null;
Component byName = null;
foreach ( var c in go.Components.GetAll() )
{
var typeName = c.GetType().Name;
// 1. Exact "NpcBrain" wins immediately (the generated default).
if ( typeName.Equals( "NpcBrain", StringComparison.OrdinalIgnoreCase ) )
return c;
// 2. Capability match: exposes the perception property contract.
if ( byProps == null )
{
var td = Game.TypeLibrary.GetType( typeName );
if ( td != null
&& td.Properties.Any( pp => pp.Name == "SightRange" )
&& td.Properties.Any( pp => pp.Name == "FovDegrees" ) )
{
byProps = c;
}
}
// 3. Name heuristic: "...Brain".
if ( byName == null && typeName.EndsWith( "Brain", StringComparison.OrdinalIgnoreCase ) )
byName = c;
}
return byProps ?? byName;
}
}
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// 1. create_npc_brain (code-gen; scene-mutating)
// Generates an NpcBrain Component: a finite-state machine (Idle/Patrol/
// Wander/Chase/Search/Flee/Ambush) driven by occlusion-aware perception
// (FOV cone + range + LOS trace + hearing) with last-known-position memory.
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
public class CreateNpcBrainHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
var name = NpcBrainHelpers.Str( p, "name", "NpcBrain" );
var directory = NpcBrainHelpers.Str( p, "directory", "Code" );
var fileName = name.EndsWith( ".cs" ) ? name : $"{name}.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}" } );
var className = ClaudeBridge.SanitizeIdentifier( Path.GetFileNameWithoutExtension( fileName ) );
// ββ Preset β defaults. The generated file is identical shape; the preset
// only changes [Property] defaults (StartState, CanFlee).
var behavior = NpcBrainHelpers.Str( p, "behavior", "hunter" ).ToLowerInvariant();
string startState;
bool presetCanFlee;
switch ( behavior )
{
case "patrol": startState = "Patrol"; presetCanFlee = false; break;
case "guard": startState = "Ambush"; presetCanFlee = false; break;
case "swarm": startState = "Wander"; presetCanFlee = false; break;
case "skittish": startState = "Patrol"; presetCanFlee = true; break;
case "hunter":
default: behavior = "hunter"; startState = "Patrol"; presetCanFlee = false; break;
}
// ββ Tunables (params override preset/spec defaults). ββ
var moveSpeed = NpcBrainHelpers.Float( p, "moveSpeed", 130f );
var chaseSpeed = NpcBrainHelpers.Float( p, "chaseSpeed", 200f );
var sightRange = NpcBrainHelpers.Float( p, "sightRange", 1500f );
var fovDegrees = NpcBrainHelpers.Float( p, "fovDegrees", 110f );
var eyeHeight = NpcBrainHelpers.Float( p, "eyeHeight", 64f );
var hearingRadius = NpcBrainHelpers.Float( p, "hearingRadius", 600f );
var giveUpTime = NpcBrainHelpers.Float( p, "giveUpTime", 6f );
var searchRadius = NpcBrainHelpers.Float( p, "searchRadius", 400f );
var waypointStop = NpcBrainHelpers.Float( p, "waypointStopDistance", 80f );
var canFlee = NpcBrainHelpers.Bool( p, "canFlee", presetCanFlee );
var fleeHealth = NpcBrainHelpers.Float( p, "fleeHealthFrac", 0.25f );
var networked = NpcBrainHelpers.Bool( p, "networked", true );
var targetTag = NpcBrainHelpers.Str( p, "targetTag", "player" );
// Citizen locomotion animation: when on (default), the generated brain caches a
// SkinnedModelRenderer + CitizenAnimationHelper in OnStart and drives walk/run/idle
// from the NavMeshAgent each frame (so the NPC and every spawner clone animate
// instead of sliding in bind pose). Proven approach ported from BigfootBrain.cs.
var animate = NpcBrainHelpers.Bool( p, "animate", true );
var cosFov = NpcBrainHelpers.CosHalfFov( fovDegrees );
var code = BuildSource(
className, startState, networked, animate,
NpcBrainHelpers.EscVerbatim( targetTag ),
moveSpeed, chaseSpeed, sightRange, fovDegrees, cosFov, eyeHeight,
hearingRadius, giveUpTime, searchRadius, waypointStop, canFlee, fleeHealth );
Directory.CreateDirectory( Path.GetDirectoryName( fullPath ) );
File.WriteAllText( fullPath, code );
var states = new[] { "Idle", "Patrol", "Wander", "Chase", "Search", "Flee", "Ambush" };
var props = new[]
{
"StartState","MoveSpeed","ChaseSpeed","SightRange","FovDegrees","CosFovThreshold",
"EyeHeight","HearingRadius","TargetTag","GiveUpTime","SearchRadius","WaypointStopDistance",
"PingPong","CanFlee","FleeHealthFrac","CurrentHealthFrac","Waypoints","CurrentState"
};
return Task.FromResult<object>( new
{
created = true,
path = $"{directory}/{fileName}",
className,
behavior,
networked,
animate,
statesIncluded = states,
propertyNames = props,
note = "NavMeshAgent is added automatically via GetOrAddComponent in OnStart. " +
"Requires bake_navmesh + a navmesh-walkable scene for movement. " +
"Assign a patrol route with place_patrol_route + assign_patrol_route. " +
"Verify perception in EDIT mode with simulate_npc_perception; verify chase/search by entering play mode " +
"(get_runtime_property CurrentState + timed screenshot_from). " +
( animate
? "Locomotion animation ON: caches a SkinnedModelRenderer + CitizenAnimationHelper in OnStart and drives walk/run/idle from the NavMeshAgent each frame β attach this brain to a GameObject with a Citizen (or any SkinnedModel) renderer (on it or a child) and it animates while moving instead of sliding. Spawner clones inherit it (each runs its own OnStart). Pass animate:false to disable. "
: "Locomotion animation OFF (animate:false): the NPC slides in bind pose; drive a CitizenAnimationHelper yourself if you want walk/run anims. " ) +
( networked
? "Networked: host-authoritative (if(IsProxy)return) + [Sync] CurrentState β needs a host session; a no-session solo playtest makes everything a proxy so the brain won't think (use networked:false to iterate solo)."
: "Solo/edit build: no IsProxy guard, so it ticks in a single-machine playtest." )
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_npc_brain failed: {ex.Message}" } );
}
}
/// <summary>
/// Build the NpcBrain component source. Everything here must be SANDBOX-LEGAL.
/// Movement uses only the confirmed NavMeshAgent.MoveTo(Vector3); perception
/// uses only Vector3.Dot/.Normal + scene.Trace.Ray(a,b).Run() + Scene.GetAllComponents.
/// FOV uses a baked cosine threshold (no trig in the sandbox).
/// When <paramref name="animate"/> is true the generated brain also caches a
/// CitizenAnimationHelper (off a SkinnedModelRenderer) and feeds it the NavMeshAgent
/// velocity each frame β sandbox-legal locomotion ported from BigfootBrain.cs (uses
/// Sandbox.Citizen + MathX, never System.Math).
/// </summary>
private static string BuildSource(
string className, string startState, bool networked, bool animate, string targetTagLiteral,
float moveSpeed, float chaseSpeed, float sightRange, float fovDegrees, float cosFov,
float eyeHeight, float hearingRadius, float giveUpTime, float searchRadius,
float waypointStop, bool canFlee, float fleeHealth )
{
string F( float v ) => NpcBrainHelpers.F( v );
// Host-authority guard line (networked) vs none (solo). The [Sync] on
// CurrentState lets proxies read the host's state for client-side animation.
var proxyGuard = networked ? "\t\tif ( IsProxy ) return; // host-authoritative β only the host thinks\n" : "";
var stateAttr = networked ? "[Sync] " : "";
var headerNote = networked
? "// Host-authoritative AI brain. Only the host runs the FSM; CurrentState is [Sync]'d\n// so proxy clients can animate the NPC. Needs an active network session (a no-session\n// solo playtest makes everything a proxy β generate with networked:false to iterate solo).\n"
: "// Solo / edit-scene AI brain (no networking guard). Ticks in a single-machine playtest.\n";
// ββ Citizen locomotion animation (ported verbatim from the proven BigfootBrain.cs).
// Everything here is sandbox-legal: Sandbox.Citizen + GetOrAddComponent + the
// NavMeshAgent's own Velocity/WishVelocity, no System.Math. When animate:false these
// fragments are empty strings, so the generated brain is byte-for-byte the old one.
var animUsing = animate ? "using Sandbox.Citizen;\n" : "";
var animFields = animate
? "\n\t// Citizen locomotion. Drives the anim helper from the agent's velocity each frame so the\n" +
"\t// NPC walks/runs/idles instead of sliding in bind pose. Cached off the SkinnedModelRenderer\n" +
"\t// in OnStart (works for the source NPC AND its spawner clones β they each run OnStart).\n" +
"\tprivate CitizenAnimationHelper _anim;\n" +
"\tprivate SkinnedModelRenderer _renderer;\n"
: "";
// OnStart wiring. Wiring _anim.Target avoids a WithWishVelocity NRE (see SBOX_KNOWLEDGE.md).
var animOnStart = animate
? "\n\t\t// Locomotion animation. Find the SkinnedModelRenderer (this GO or a child), then\n" +
"\t\t// get-or-add a CitizenAnimationHelper and wire its Target β the helper NREs in\n" +
"\t\t// WithWishVelocity if Target is null. A Citizen .vmdl already has the locomotion\n" +
"\t\t// anim-graph, so once fed velocity it walks/runs/idles on its own.\n" +
"\t\t_renderer = GetComponent<SkinnedModelRenderer>() ?? GetComponentInChildren<SkinnedModelRenderer>();\n" +
"\t\tif ( _renderer.IsValid() )\n" +
"\t\t{\n" +
"\t\t\t_anim = GetOrAddComponent<CitizenAnimationHelper>();\n" +
"\t\t\t_anim.Target = _renderer;\n" +
"\t\t}\n"
: "";
// Per-frame drive call (placed at the end of OnUpdate) + the method body.
var animUpdateCall = animate ? "\t\tDriveAnimation();\n" : "";
var animMethod = animate
? "\n\t// ββ Locomotion animation ββββββββββββββββββββββββββββββββββββββββββββββββββββ\n" +
"\t/// <summary>Feed the Citizen anim helper from the NavMeshAgent each frame so the NPC\n" +
"\t/// plays walk/run/idle instead of sliding in bind pose. WithVelocity drives the\n" +
"\t/// locomotion blend; WithWishVelocity drives lean/start-stop; IsGrounded keeps it out\n" +
"\t/// of the fall pose. Glance toward the chased target, else toward travel direction.</summary>\n" +
"\tprivate void DriveAnimation()\n" +
"\t{\n" +
"\t\tif ( _anim == null || !_anim.IsValid() ) return;\n" +
"\n" +
"\t\tvar velocity = _agent.Velocity;\n" +
"\t\t_anim.WithVelocity( velocity );\n" +
"\t\t_anim.WithWishVelocity( _agent.WishVelocity );\n" +
"\t\t_anim.IsGrounded = true;\n" +
"\n" +
"\t\tVector3 lookDir;\n" +
"\t\tif ( CurrentState == BrainState.Chase && _target.IsValid() )\n" +
"\t\t\tlookDir = ( _target.WorldPosition - WorldPosition ).WithZ( 0f );\n" +
"\t\telse\n" +
"\t\t\tlookDir = velocity.WithZ( 0f );\n" +
"\n" +
"\t\tif ( lookDir.Length > 1f )\n" +
"\t\t\t_anim.WithLook( lookDir.Normal, 1f, 0.6f, 0.2f );\n" +
"\t}\n"
: "";
return
$@"using Sandbox;
{animUsing}using System;
using System.Collections.Generic;
using System.Linq;
{headerNote}public sealed class {className} : Component
{{
public enum BrainState {{ Idle, Patrol, Wander, Chase, Search, Flee, Ambush }}
// ββ Tunables (all [Property] so the bridge can set_property / tune later) ββ
[Property] public BrainState StartState {{ get; set; }} = BrainState.{startState};
[Property] public float MoveSpeed {{ get; set; }} = {F( moveSpeed )};
[Property] public float ChaseSpeed {{ get; set; }} = {F( chaseSpeed )};
// Perception
[Property] public float SightRange {{ get; set; }} = {F( sightRange )};
// FovDegrees is the human-readable full cone angle. The actual gate compares a
// dot product against CosFovThreshold = cos(FovDegrees/2), which is baked here so
// the sandbox needs no trig. If you change FovDegrees at runtime, also update
// CosFovThreshold (tune_npc_perception / set_property), or call SetFov(...) below.
[Property] public float FovDegrees {{ get; set; }} = {F( fovDegrees )};
[Property] public float CosFovThreshold {{ get; set; }} = {F( cosFov )};
[Property] public float EyeHeight {{ get; set; }} = {F( eyeHeight )};
[Property] public float HearingRadius {{ get; set; }} = {F( hearingRadius )};
[Property] public string TargetTag {{ get; set; }} = @""{targetTagLiteral}"";
// Memory / timing
[Property] public float GiveUpTime {{ get; set; }} = {F( giveUpTime )};
[Property] public float SearchRadius {{ get; set; }} = {F( searchRadius )};
[Property] public float WaypointStopDistance {{ get; set; }} = {F( waypointStop )};
[Property] public bool PingPong {{ get; set; }} = false;
// Flee (health source is generic: the game sets CurrentHealthFrac 0..1, or
// override ShouldFlee() in a partial/subclass β no hard coupling to any HP comp).
[Property] public bool CanFlee {{ get; set; }} = {( canFlee ? "true" : "false" )};
[Property] public float FleeHealthFrac {{ get; set; }} = {F( fleeHealth )};
[Property] public float CurrentHealthFrac {{ get; set; }} = 1f;
// Patrol route (placed + wired by assign_patrol_route, or hand-set in editor).
[Property] public List<GameObject> Waypoints {{ get; set; }} = new();
// ββ Runtime state ββ
{stateAttr}public BrainState CurrentState {{ get; private set; }}
private GameObject _target;
private Vector3 _lastKnownPos;
private TimeSince _timeSinceSeen;
private Vector3 _wanderTarget;
private TimeSince _timeSinceWanderPick;
private int _waypointIndex;
private int _waypointDir = 1;
private NavMeshAgent _agent;
{animFields}
protected override void OnStart()
{{
_agent = GetOrAddComponent<NavMeshAgent>();
{animOnStart} CurrentState = StartState;
_timeSinceSeen = 999f;
_lastKnownPos = WorldPosition;
_wanderTarget = WorldPosition;
}}
protected override void OnUpdate()
{{
{proxyGuard} if ( _agent == null ) return;
Perceive();
Think();
Act();
{animUpdateCall} }}
{animMethod}
/// <summary>Recompute the FOV cosine from a degree value at runtime (no trig in
/// the sandbox: cos(x) via the half-angle identity from a normalized sweep is
/// overkill, so we keep it simple β set both together).</summary>
public void SetFov( float degrees, float cosThreshold )
{{
FovDegrees = degrees;
CosFovThreshold = cosThreshold;
}}
// ββ Perception ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
private void Perceive()
{{
var eye = WorldPosition + Vector3.Up * EyeHeight;
var best = FindVisibleTarget( eye, out var sawSomething );
if ( best.IsValid() )
{{
_target = best;
_lastKnownPos = best.WorldPosition;
_timeSinceSeen = 0f;
return;
}}
// Passive hearing: a candidate within HearingRadius is ""heard"" (sets a
// last-known position to investigate) but is NOT treated as seen β so the
// NPC investigates rather than instantly aggroing.
var heard = FindNearestCandidate( WorldPosition, HearingRadius );
if ( heard.IsValid() )
_lastKnownPos = heard.WorldPosition;
// keep _target ref while it grows stale; _timeSinceSeen advances on its own.
}}
/// <summary>Pick the nearest candidate that passes range + FOV cone + LOS.</summary>
private GameObject FindVisibleTarget( Vector3 eye, out bool any )
{{
any = false;
GameObject bestGo = null;
float bestDist = float.MaxValue;
foreach ( var cand in Candidates() )
{{
var to = cand.WorldPosition - eye;
float dist = to.Length;
if ( dist > SightRange ) continue;
if ( dist < 0.01f ) continue;
var dir = to.Normal;
// FOV cone gate (cheap): dot >= cos(half-fov). No trig needed.
if ( Vector3.Dot( WorldRotation.Forward, dir ) < CosFovThreshold ) continue;
// Occlusion trace from the eye to the candidate. IgnoreGameObjectHierarchy
// excludes the NPC's own colliders so it can't ""see"" itself. Clear when the
// ray hits the candidate directly, hits nothing, or the first hit is
// essentially at the candidate (a child collider) β a distance test that
// needs no extra API. Anything blocking earlier (a tree/wall) fails LOS.
var tr = Scene.Trace.Ray( eye, cand.WorldPosition ).IgnoreGameObjectHierarchy( GameObject ).Run();
bool clear = !tr.Hit || tr.GameObject == cand || tr.Distance >= dist - 8f;
if ( !clear ) continue;
any = true;
if ( dist < bestDist ) {{ bestDist = dist; bestGo = cand; }}
}}
return bestGo;
}}
private GameObject FindNearestCandidate( Vector3 from, float maxDist )
{{
GameObject best = null;
float bestDist = maxDist;
foreach ( var cand in Candidates() )
{{
float d = Vector3.DistanceBetween( from, cand.WorldPosition );
if ( d <= bestDist ) {{ bestDist = d; best = cand; }}
}}
return best;
}}
/// <summary>Candidate targets = GameObjects tagged TargetTag, excluding self.
/// Uses Scene.GetAllComponents to enumerate, then filters by tag.</summary>
private IEnumerable<GameObject> Candidates()
{{
foreach ( var c in Scene.GetAllComponents<Collider>() )
{{
var go = c.GameObject;
if ( go == null || go == GameObject ) continue;
if ( !go.Tags.Has( TargetTag ) ) continue;
yield return go;
}}
}}
// ββ Transition table ββββββββββββββββββββββββββββββββββββββββββββββββββββ
private void Think()
{{
bool canSee = _target.IsValid() && _timeSinceSeen < 0.1f;
if ( CanFlee && ShouldFlee() ) {{ CurrentState = BrainState.Flee; return; }}
switch ( CurrentState )
{{
case BrainState.Idle:
case BrainState.Patrol:
case BrainState.Wander:
case BrainState.Ambush:
if ( canSee ) CurrentState = BrainState.Chase;
break;
case BrainState.Chase:
if ( !canSee && _timeSinceSeen > 0.25f ) CurrentState = BrainState.Search;
break;
case BrainState.Search:
if ( canSee ) CurrentState = BrainState.Chase;
else if ( _timeSinceSeen > GiveUpTime ) {{ _target = null; CurrentState = StartState; }}
break;
case BrainState.Flee:
if ( !ShouldFlee() ) CurrentState = StartState;
break;
}}
}}
// ββ Action per state ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
private void Act()
{{
// Apply the desired locomotion speed (chase is faster). NavMeshAgent.MaxSpeed
// is the agent's speed cap (verified in the navmesh docs).
_agent.MaxSpeed = ( CurrentState == BrainState.Chase || CurrentState == BrainState.Flee ) ? ChaseSpeed : MoveSpeed;
switch ( CurrentState )
{{
case BrainState.Idle:
case BrainState.Ambush:
// Stand still and watch (perception still runs every tick).
_agent.Stop();
break;
case BrainState.Patrol:
PatrolStep();
break;
case BrainState.Wander:
WanderStep( WorldPosition, SearchRadius );
break;
case BrainState.Chase:
if ( _target.IsValid() )
_agent.MoveTo( _target.WorldPosition );
break;
case BrainState.Search:
if ( Vector3.DistanceBetween( WorldPosition, _lastKnownPos ) > WaypointStopDistance )
_agent.MoveTo( _lastKnownPos );
else
WanderStep( _lastKnownPos, SearchRadius );
break;
case BrainState.Flee:
FleeStep();
break;
}}
}}
private void PatrolStep()
{{
if ( Waypoints == null || Waypoints.Count == 0 ) return;
_waypointIndex = (int)MathX.Clamp( _waypointIndex, 0, Waypoints.Count - 1 );
var wp = Waypoints[_waypointIndex];
if ( !wp.IsValid() ) {{ AdvanceWaypoint(); return; }}
if ( Vector3.DistanceBetween( WorldPosition, wp.WorldPosition ) <= WaypointStopDistance )
AdvanceWaypoint();
else
_agent.MoveTo( wp.WorldPosition );
}}
private void AdvanceWaypoint()
{{
if ( Waypoints == null || Waypoints.Count <= 1 ) return;
if ( PingPong )
{{
if ( _waypointIndex + _waypointDir >= Waypoints.Count || _waypointIndex + _waypointDir < 0 )
_waypointDir = -_waypointDir;
_waypointIndex += _waypointDir;
}}
else
{{
_waypointIndex = ( _waypointIndex + 1 ) % Waypoints.Count;
}}
}}
private void WanderStep( Vector3 home, float radius )
{{
bool reached = Vector3.DistanceBetween( WorldPosition, _wanderTarget ) <= WaypointStopDistance;
if ( reached || _timeSinceWanderPick > 4f )
{{
// Pick a fresh point near home. Uses only confirmed APIs (Random.Shared
// + Vector3). The agent paths toward the nearest reachable point, so an
// occasional off-mesh pick is harmless. (For strictly-on-mesh wander,
// swap to Scene.NavMesh.GetRandomPoint(home, radius) once its return type
// is confirmed via describe_type.)
var off = new Vector3(
Random.Shared.Float( -radius, radius ),
Random.Shared.Float( -radius, radius ),
0f );
_wanderTarget = home + off;
_timeSinceWanderPick = 0f;
}}
_agent.MoveTo( _wanderTarget );
}}
private void FleeStep()
{{
// Move directly away from the last-known threat position.
var away = ( WorldPosition - _lastKnownPos ).Normal;
if ( away.Length < 0.01f ) away = WorldRotation.Forward;
_agent.MoveTo( WorldPosition + away * MathX.Clamp( SearchRadius, 100f, 2000f ) );
}}
/// <summary>Generic flee predicate. Driven by CurrentHealthFrac (the game sets
/// it 0..1). Override in a subclass/partial for game-specific logic (e.g. a
/// bomb-timer panic in RUN, or a camper-HP check in Sasquatched).</summary>
public bool ShouldFlee()
{{
return CanFlee && CurrentHealthFrac <= FleeHealthFrac;
}}
// ββ Noise hook (pure C#; the game calls this where a noise happens) βββββββββ
// Example: NpcBrain.ReportNoise(flashlightPos, 800f) when a camper clicks a
// flashlight, or a gunshot in RUN. NPCs within radius investigate (Search).
public static void ReportNoise( Scene scene, Vector3 pos, float radius )
{{
if ( scene == null ) return;
foreach ( var brain in scene.GetAllComponents<{className}>() )
brain.HearNoise( pos, radius );
}}
public void HearNoise( Vector3 pos, float radius )
{{
if ( Vector3.DistanceBetween( WorldPosition, pos ) > radius ) return;
_lastKnownPos = pos;
if ( CurrentState != BrainState.Chase )
CurrentState = BrainState.Search;
}}
}}
";
}
}
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// 2. place_patrol_route (scene-mutating)
// Create N waypoint empties (tagged), grouped under a parent route object,
// optionally snapped to the ground so they sit on the navmesh.
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
public class PlacePatrolRouteHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
if ( !p.TryGetProperty( "points", out var pts ) || pts.ValueKind != JsonValueKind.Array )
return Task.FromResult<object>( new { error = "points (Vector3[]) is required" } );
var rawPoints = new List<Vector3>();
foreach ( var e in pts.EnumerateArray() )
rawPoints.Add( ClaudeBridge.ParseVector3( e ) );
if ( rawPoints.Count < 2 )
return Task.FromResult<object>( new { error = "Provide at least 2 points for a patrol route" } );
var routeName = NpcBrainHelpers.Str( p, "name", "PatrolRoute" );
var tag = NpcBrainHelpers.Str( p, "tag", "waypoint" );
var snap = NpcBrainHelpers.Bool( p, "snapToGround", true );
try
{
// Resolve or create the route parent.
GameObject route = null;
if ( p.TryGetProperty( "parentId", out var pid ) && Guid.TryParse( pid.GetString(), out var parentGuid ) )
route = scene.Directory.FindByGuid( parentGuid );
if ( route == null )
{
route = scene.CreateObject( true );
route.Name = routeName;
// Place the parent at the centroid for a tidy hierarchy + easy framing.
var centroid = Vector3.Zero;
foreach ( var pt in rawPoints ) centroid += pt;
route.WorldPosition = centroid / rawPoints.Count;
}
var waypointIds = new List<string>( rawPoints.Count );
int i = 0;
foreach ( var pt in rawPoints )
{
var pos = pt;
if ( snap )
{
try
{
var tr = scene.Trace.Ray( pos + Vector3.Up * 2000f, pos + Vector3.Down * 20000f ).Run();
if ( tr.Hit ) pos = new Vector3( pos.x, pos.y, tr.HitPosition.z );
}
catch { /* keep the raw point on trace failure */ }
}
var wp = scene.CreateObject( true );
wp.Name = $"{routeName}_WP{i}";
wp.WorldPosition = pos;
wp.Tags.Add( tag );
wp.SetParent( route, keepWorldPosition: true );
waypointIds.Add( wp.Id.ToString() );
i++;
}
return Task.FromResult<object>( new
{
placed = true,
routeId = route.Id.ToString(),
routeName = route.Name,
waypointIds,
count = waypointIds.Count,
snappedToGround = snap,
note = "Wire these into an NpcBrain with assign_patrol_route (pass routeId or waypointIds). " +
"Validate connectivity with get_navmesh_path between consecutive waypoints (catches a point in a wall)."
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"place_patrol_route failed: {ex.Message}" } );
}
}
}
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// 3. assign_patrol_route (scene-mutating)
// Wire a placed route (or an arbitrary GUID list) into a List<GameObject>
// property (default "Waypoints") on a target NPC's component. This is the
// list-of-GameObject-refs case plain set_property can't express.
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
public class AssignPatrolRouteHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
if ( !p.TryGetProperty( "npcId", out var npcEl ) || !Guid.TryParse( npcEl.GetString(), out var npcGuid ) )
return Task.FromResult<object>( new { error = "npcId (GameObject GUID holding the NpcBrain) is required" } );
var npc = scene.Directory.FindByGuid( npcGuid );
if ( npc == null )
return Task.FromResult<object>( new { error = $"NPC GameObject not found: {npcEl.GetString()}" } );
var property = NpcBrainHelpers.Str( p, "property", "Waypoints" );
try
{
// ββ Gather the ordered waypoint GameObjects: explicit waypointIds win,
// else the children (hierarchy order) of routeId.
var waypoints = new List<GameObject>();
if ( p.TryGetProperty( "waypointIds", out var wpArr ) && wpArr.ValueKind == JsonValueKind.Array )
{
foreach ( var e in wpArr.EnumerateArray() )
if ( Guid.TryParse( e.GetString(), out var g ) )
{
var go = scene.Directory.FindByGuid( g );
if ( go != null ) waypoints.Add( go );
}
}
else if ( p.TryGetProperty( "routeId", out var routeEl ) && Guid.TryParse( routeEl.GetString(), out var routeGuid ) )
{
var route = scene.Directory.FindByGuid( routeGuid );
if ( route == null )
return Task.FromResult<object>( new { error = $"Route GameObject not found: {routeEl.GetString()}" } );
foreach ( var child in route.Children )
waypoints.Add( child );
}
else
{
return Task.FromResult<object>( new { error = "Provide waypointIds (GUID[]) or routeId (route parent GUID)" } );
}
if ( waypoints.Count == 0 )
return Task.FromResult<object>( new { error = "No valid waypoints resolved from the given ids/route" } );
// ββ Resolve the component + property and set the List<GameObject>.
// SetValue accepts a List<GameObject>; we hand it the concrete list
// (matches how the editor serializes [Property] lists of refs).
var comp = NpcBrainHelpers.SetComponentProperty( npc, property, waypoints );
if ( comp == null )
return Task.FromResult<object>( new { error = $"No component on the NPC exposes a '{property}' property (expected an NpcBrain with a List<GameObject> {property})" } );
return Task.FromResult<object>( new
{
assigned = true,
npcId = npcEl.GetString(),
component = comp.GetType().Name,
property,
count = waypoints.Count,
note = "List<GameObject> refs may read back as handles/GUIDs via get_property β trust this count, or confirm patrol in play mode."
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"assign_patrol_route failed: {ex.Message}" } );
}
}
}
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// 4. create_npc_spawner (code-gen; scene-mutating)
// Generate a spawner Component that clones an NPC prefab over time / in
// escalating waves at spawn points, capped by maxAlive. Host-authoritative
// when networked (NetworkSpawn, guarded).
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
public class CreateNpcSpawnerHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
var name = NpcBrainHelpers.Str( p, "name", "NpcSpawner" );
var directory = NpcBrainHelpers.Str( p, "directory", "Code" );
var fileName = name.EndsWith( ".cs" ) ? name : $"{name}.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}" } );
var className = ClaudeBridge.SanitizeIdentifier( Path.GetFileNameWithoutExtension( fileName ) );
var mode = NpcBrainHelpers.Str( p, "mode", "waves" ).ToLowerInvariant();
if ( mode != "continuous" && mode != "waves" && mode != "burst" ) mode = "waves";
var modeEnum = mode == "continuous" ? "Continuous" : ( mode == "burst" ? "Burst" : "Waves" );
var count = NpcBrainHelpers.Int( p, "count", 5 );
var interval = NpcBrainHelpers.Float( p, "interval", 8f );
var waveCount = NpcBrainHelpers.Int( p, "waveCount", 3 );
var waveGrowth = NpcBrainHelpers.Float( p, "waveGrowth", 1f );
var radius = NpcBrainHelpers.Float( p, "radius", 200f );
var maxAlive = NpcBrainHelpers.Int( p, "maxAlive", 12 );
var networked = NpcBrainHelpers.Bool( p, "networked", true );
var code = BuildSpawnerSource( className, modeEnum, networked,
count, interval, waveCount, waveGrowth, radius, maxAlive );
Directory.CreateDirectory( Path.GetDirectoryName( fullPath ) );
File.WriteAllText( fullPath, code );
var props = new[]
{
"NpcPrefab","SpawnPoints","Mode","Count","Interval","WaveCount",
"WaveGrowth","Radius","MaxAlive","AutoStart"
};
return Task.FromResult<object>( new
{
created = true,
path = $"{directory}/{fileName}",
className,
mode,
networked,
propertyNames = props,
note = "Set NpcPrefab via set_prefab_ref. Add spawn points by reusing place_patrol_route (a route of empties) then " +
"assign_patrol_route with property=\"SpawnPoints\", or set SpawnPoints by hand. " +
( networked
? "Networked spawns use NetworkSpawn() and are host-only (guarded) β needs a host session."
: "Solo build: plain Clone() (no NetworkSpawn)." ) +
" Verify by watching GameObject count over time in play mode (get_scene_hierarchy deltas)."
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_npc_spawner failed: {ex.Message}" } );
}
}
private static string BuildSpawnerSource(
string className, string modeEnum, bool networked,
int count, float interval, int waveCount, float waveGrowth, float radius, int maxAlive )
{
string F( float v ) => NpcBrainHelpers.F( v );
var proxyGuard = networked ? "\t\tif ( IsProxy ) return; // host spawns authoritatively\n" : "";
var headerNote = networked
? "// Host-authoritative spawner. Only the host spawns (NetworkSpawn so clients see the\n// NPCs). Needs an active network session.\n"
: "// Solo / edit-scene spawner (plain Clone, no networking).\n";
// Spawn idiom: clone the prefab, place it, and (networked) NetworkSpawn in a
// try/catch β the verified solo-safe idiom (NetworkSpawn throws with no session).
var spawnBody = networked
?
@" var go = NpcPrefab.Clone( pos );
try { go.NetworkSpawn(); } catch { /* no session β fall back to a local object */ }
_alive.Add( go );"
:
@" var go = NpcPrefab.Clone( pos );
_alive.Add( go );";
return
$@"using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
{headerNote}public sealed class {className} : Component
{{
public enum SpawnMode {{ Continuous, Waves, Burst }}
[Property] public GameObject NpcPrefab {{ get; set; }}
[Property] public List<GameObject> SpawnPoints {{ get; set; }} = new();
[Property] public SpawnMode Mode {{ get; set; }} = SpawnMode.{modeEnum};
[Property] public int Count {{ get; set; }} = {count}; // per-wave (Waves) or total (Burst/Continuous batch)
[Property] public float Interval {{ get; set; }} = {F( interval )}; // seconds between spawns (Continuous) or waves (Waves)
[Property] public int WaveCount {{ get; set; }} = {waveCount};
[Property] public float WaveGrowth {{ get; set; }} = {F( waveGrowth )}; // multiply Count each wave (>1 = escalating)
[Property] public float Radius {{ get; set; }} = {F( radius )}; // random scatter around a spawn point
[Property] public int MaxAlive {{ get; set; }} = {maxAlive}; // concurrency cap
[Property] public bool AutoStart {{ get; set; }} = true;
private readonly List<GameObject> _alive = new();
private TimeSince _timeSinceSpawn;
private int _wavesDone;
private float _currentWaveCount;
private bool _started;
protected override void OnStart()
{{
_currentWaveCount = Count;
_timeSinceSpawn = Interval; // fire promptly on the first eligible tick
if ( AutoStart ) _started = true;
}}
protected override void OnUpdate()
{{
{proxyGuard} if ( !_started || NpcPrefab == null ) return;
// Drop dead/destroyed NPCs from the live list so MaxAlive is accurate.
_alive.RemoveAll( g => !g.IsValid() );
switch ( Mode )
{{
case SpawnMode.Burst:
SpawnBatch( (int)_currentWaveCount );
_started = false; // one-shot
break;
case SpawnMode.Continuous:
if ( _timeSinceSpawn >= Interval )
{{
_timeSinceSpawn = 0f;
TrySpawnOne();
}}
break;
case SpawnMode.Waves:
if ( _wavesDone >= WaveCount ) {{ _started = false; break; }}
if ( _timeSinceSpawn >= Interval )
{{
_timeSinceSpawn = 0f;
SpawnBatch( (int)_currentWaveCount );
_wavesDone++;
_currentWaveCount = MathX.Clamp( _currentWaveCount * WaveGrowth, 1f, 9999f );
}}
break;
}}
}}
private void SpawnBatch( int n )
{{
for ( int i = 0; i < n; i++ )
if ( !TrySpawnOne() ) break;
}}
private bool TrySpawnOne()
{{
if ( _alive.Count >= MaxAlive ) return false;
var pos = PickSpawnPos();
{spawnBody}
return true;
}}
private Vector3 PickSpawnPos()
{{
var basePos = WorldPosition;
if ( SpawnPoints != null && SpawnPoints.Count > 0 )
{{
var pick = SpawnPoints[Random.Shared.Next( 0, SpawnPoints.Count )];
if ( pick.IsValid() ) basePos = pick.WorldPosition;
}}
var off = new Vector3(
Random.Shared.Float( -Radius, Radius ),
Random.Shared.Float( -Radius, Radius ),
0f );
return basePos + off;
}}
}}
";
}
}
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// 5. simulate_npc_perception (READ-ONLY β NOT scene-mutating)
// Run the EXACT LOS check an NpcBrain would, in edit mode, without play.
// FOV cone (dot vs CosFovThreshold) + range + occlusion trace. Reports the
// result AND why β the keystone edit-mode verifier for the perception layer.
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
public class SimulateNpcPerceptionHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
if ( !p.TryGetProperty( "npcId", out var npcEl ) || !Guid.TryParse( npcEl.GetString(), out var npcGuid ) )
return Task.FromResult<object>( new { error = "npcId (GameObject GUID with an NpcBrain) is required" } );
var npc = scene.Directory.FindByGuid( npcGuid );
if ( npc == null )
return Task.FromResult<object>( new { error = $"NPC GameObject not found: {npcEl.GetString()}" } );
try
{
// ββ Read perception params from the NPC's brain if present, else fall back
// to spec defaults / explicit overrides in the call. Matches the brain by
// CAPABILITY (exposes SightRange+FovDegrees) or a "...Brain" type name β NOT
// just the literal type name "NpcBrain" β so a custom-named brain
// (e.g. BigfootBrain) is read instead of silently using defaults.
var brain = NpcBrainHelpers.FindPerceptionBrain( npc );
// `var` (never name TypeDescription) β its namespace isn't guaranteed importable here.
var brainTd = brain != null ? Game.TypeLibrary.GetType( brain.GetType().Name ) : null;
float ReadBrainFloat( string name, float fallback )
{
if ( brain == null || brainTd == null ) return fallback;
var pd = brainTd.Properties.FirstOrDefault( x => x.Name == name );
if ( pd == null ) return fallback;
try
{
var v = pd.GetValue( brain );
if ( v is float f ) return f;
if ( v != null && float.TryParse( v.ToString(), out var fp ) ) return fp;
}
catch { }
return fallback;
}
string ReadBrainString( string name, string fallback )
{
if ( brain == null || brainTd == null ) return fallback;
var pd = brainTd.Properties.FirstOrDefault( x => x.Name == name );
try { return pd?.GetValue( brain )?.ToString() ?? fallback; } catch { return fallback; }
}
// Explicit overrides take precedence over brain-read values.
float sightRange = NpcBrainHelpers.Float( p, "sightRange", ReadBrainFloat( "SightRange", 1500f ) );
float fovDegrees = NpcBrainHelpers.Float( p, "fovDegrees", ReadBrainFloat( "FovDegrees", 110f ) );
float eyeHeight = NpcBrainHelpers.Float( p, "eyeHeight", ReadBrainFloat( "EyeHeight", 64f ) );
string targetTag = NpcBrainHelpers.Str( p, "targetTag", ReadBrainString( "TargetTag", "player" ) );
// Use the brain's baked CosFovThreshold if available (keeps this query in
// lockstep with the generated component); else compute it here.
float cosFov = ReadBrainFloat( "CosFovThreshold", float.NaN );
if ( float.IsNaN( cosFov ) ) cosFov = NpcBrainHelpers.CosHalfFov( fovDegrees );
// ββ Resolve the target point: explicit targetId or a raw point.
GameObject targetGo = null;
Vector3 targetPos;
if ( p.TryGetProperty( "targetId", out var tEl ) && Guid.TryParse( tEl.GetString(), out var tGuid ) )
{
targetGo = scene.Directory.FindByGuid( tGuid );
if ( targetGo == null )
return Task.FromResult<object>( new { error = $"Target GameObject not found: {tEl.GetString()}" } );
targetPos = targetGo.WorldPosition;
}
else if ( p.TryGetProperty( "point", out var ptEl ) )
{
targetPos = ClaudeBridge.ParseVector3( ptEl );
}
else
{
return Task.FromResult<object>( new { error = "Provide targetId (GameObject GUID) or point (Vector3)" } );
}
var eye = npc.WorldPosition + Vector3.Up * eyeHeight;
var to = targetPos - eye;
float distance = to.Length;
// Degenerate: target is essentially at the eye.
if ( distance < 0.01f )
{
return Task.FromResult<object>( new
{
canSee = true, inRange = true, inFov = true, losBlocked = false,
distance, angleDeg = 0.0,
eye = new { eye.x, eye.y, eye.z },
note = "Target coincides with the NPC eye position."
} );
}
var dir = to.Normal;
float dot = Vector3.Dot( npc.WorldRotation.Forward, dir );
// angle (degrees) for human-readable output. MathF is fine here (editor).
float angleDeg = MathF.Acos( Math.Clamp( dot, -1f, 1f ) ) * ( 180f / MathF.PI );
bool inRange = distance <= sightRange;
bool inFov = dot >= cosFov;
// Occlusion trace from the eye toward the target. IgnoreGameObjectHierarchy
// drops the NPC's own colliders (confirmed builder), so any hit is an
// external object. It blocks LOS only if it's clearly before the target
// (hit on the target itself, or a hit at/after the target distance, is not
// a blocker). Distance test only β no GameObject.Root needed.
bool losBlocked = false;
object blockedBy = null;
var tr = scene.Trace.Ray( eye, targetPos ).IgnoreGameObjectHierarchy( npc ).Run();
if ( tr.Hit )
{
bool hitIsTarget = ( targetGo != null && tr.GameObject == targetGo )
|| tr.Distance >= distance - 8f; // a hit at/after the target point isn't a blocker
if ( !hitIsTarget )
{
losBlocked = true;
blockedBy = new { id = tr.GameObject?.Id.ToString(), name = tr.GameObject?.Name };
}
}
bool tagMatch = targetGo == null || targetGo.Tags.Has( targetTag );
bool canSee = inRange && inFov && !losBlocked && tagMatch;
return Task.FromResult<object>( new
{
canSee,
inRange,
inFov,
losBlocked,
blockedBy,
tagMatch,
distance,
angleDeg = (double)angleDeg,
fovHalfAngleDeg = (double)( fovDegrees * 0.5f ),
sightRange,
targetTag,
eye = new { eye.x, eye.y, eye.z },
brainComponent = brain?.GetType().Name,
note = brain == null
? "No perception brain found on this GameObject β used spec defaults / call overrides for the perception params."
: $"Read perception params from the '{brain.GetType().Name}' component's own SightRange/FovDegrees/EyeHeight/TargetTag (call params override). canSee mirrors what the generated brain computes."
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"simulate_npc_perception failed: {ex.Message}" } );
}
}
}
Editor
library
using Editor;
using Sandbox;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// PLAYTEST HARNESS β playtest / playtest_status (the gameplay-verification frontier)
//
// Same assembly as MyEditorMenu.cs (reuses IBridgeHandler + ClaudeBridge helpers).
// Unsandboxed editor code β System.Math / System.Reflection are fine here.
//
// WHY AN IN-ADDON RUNNER (not TS round-trips):
// Verifying a gameplay LOOP needs input + state-reads + assertions that time-align
// with the game's frames. Two facts (proven live on the Gravehold player) force this:
// 1. The facepunch PlayerController reads Input.AnalogMove each frame and OVERWRITES
// a WishVelocity you set β UNLESS you set `UseInputControls=false` first. With it
// off, setting WishVelocity moved the player 0β526u. So a move step must flip that
// toggle, drive WishVelocity per frame, and ZERO it after (it persists otherwise).
// 2. Transient state (a jump's z-velocity) is gone by the time a SEPARATE bridge call
// lands β so assertions must be evaluated IN-FRAME, inside the editor frame loop.
// => one async job, ticked by [EditorEvent.Frame], runs a step list and records a
// pass/fail transcript. TS only starts it (playtest) and polls it (playtest_status).
//
// Step verbs: move Β· look Β· lookDelta Β· action Β· jump Β· set Β· wait Β· capture Β· assert
// { "move": {"x":1}, "frames":60 } analog move (auto UseInputControls=false)
// { "look": {"pitch":0,"yaw":90,"roll":0} } set EyeAngles
// { "lookDelta": {"yaw":2}, "frames":30 } sweep EyeAngles
// { "action": "use", "frames":20 } hold a named input action (rising-edge safe)
// { "jump": "0,0,400" } invoke the controller's Jump(velocity)
// { "set": {"component":"PlayerController","property":"UseInputControls","to":"false"} }
// { "wait": 10 } advance N frames
// { "capture": "after-jump" } screenshot the live player POV β path in transcript
// { "assert": {"read":"Displacement","op":">","value":50,"desc":"moved >50u from start"} }
//
// assert.read = "WorldPosition[.x|.y|.z]" (the controller's GameObject), "Displacement"
// (scalar distance moved from job start β the facing-independent movement proof), OR
// "<Component>.<Property>[.x|.y|.z|.Count]" (a component on the player).
// assert.op = > < >= <= == != changed (changed = differs from the value at job start)
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
internal static class PlaytestRunner
{
internal class StepSpec
{
public string Kind;
public int Frames = 1;
public Vector2 Move;
public Angles Look; public bool HasLook;
public Angles LookDelta;
public string Action;
public Vector3 JumpVel;
public string SetComponent, SetProperty, SetValue;
public string AssertRead, AssertOp, AssertValue, AssertDesc;
public string CaptureLabel;
public float MoveSpeed = 160f;
}
internal class Job
{
public Guid TargetId;
public string ComponentType;
public Component Controller; // resolved once
public GameObject Anchor; // controller.GameObject β the player object
public Vector3 StartPos; // Anchor.WorldPosition at job start (for the "Displacement" read)
public List<StepSpec> Steps;
public int Index;
public int FrameInStep;
public List<object> Transcript = new();
public int Passed, Failed;
public bool DisabledInput; // we flipped UseInputControls=false β restore at teardown
public string HeldAction; // currently-held action (release at step exit / teardown)
public Dictionary<string, string> Baselines = new(); // read-key β value at job start (for "changed")
public bool Done;
public string EndReason;
public bool Started;
}
private static Job _job;
private static readonly object _lock = new();
private static object _lastSummary;
internal static void Start( Job job ) { lock ( _lock ) { _job = job; _lastSummary = null; } }
internal static object ConsumeSummary() { lock ( _lock ) { return _lastSummary; } }
internal static bool IsActive() { lock ( _lock ) { return _job != null; } }
/// <summary>Stop the running job NOW: teardown (restore input state) + summarize as aborted.</summary>
internal static object Abort()
{
lock ( _lock )
{
if ( _job == null )
return new { aborted = false, note = "No playtest job is running. playtest_status shows the last summary." };
var j = _job;
Teardown( j );
_lastSummary = Summarize( j, "aborted via playtest_abort" );
_job = null;
return new
{
aborted = true,
stepsRun = j.Index,
passed = j.Passed,
failed = j.Failed,
note = "Job stopped, input state restored. The partial transcript is available via playtest_status."
};
}
}
internal static object LiveSnapshot()
{
lock ( _lock )
{
if ( _job == null ) return null;
return new { active = true, step = _job.Index, totalSteps = _job.Steps.Count, passed = _job.Passed, failed = _job.Failed };
}
}
[EditorEvent.Frame]
public static void OnFrame()
{
Job j;
lock ( _lock ) { j = _job; }
if ( j == null ) return;
if ( !Game.IsPlaying )
{
Teardown( j );
lock ( _lock ) { _lastSummary = Summarize( j, "play mode ended before completion" ); _job = null; }
return;
}
try
{
// Resolve the controller + anchor once.
if ( !j.Started )
{
ResolveAnchor( j );
CaptureBaselines( j );
j.Started = true;
}
if ( j.Index >= j.Steps.Count )
{
Teardown( j );
lock ( _lock ) { _lastSummary = Summarize( j, "completed" ); _job = null; }
return;
}
var step = j.Steps[j.Index];
if ( j.FrameInStep == 0 ) StepEnter( j, step );
StepTick( j, step );
j.FrameInStep++;
if ( j.FrameInStep >= System.Math.Max( 1, step.Frames ) )
{
StepExit( j, step );
j.Index++;
j.FrameInStep = 0;
}
}
catch ( Exception ex )
{
// Never let the ticker throw (it'd spam every frame). Record + stop.
j.Transcript.Add( new { step = j.Index, kind = j.Index < j.Steps.Count ? j.Steps[j.Index].Kind : "?", error = ex.Message } );
Teardown( j );
lock ( _lock ) { _lastSummary = Summarize( j, $"runner error: {ex.Message}" ); _job = null; }
}
}
// ββ Step lifecycle βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
static void StepEnter( Job j, StepSpec s )
{
switch ( s.Kind )
{
case "move":
EnsureInputDisabled( j ); // so WishVelocity isn't overwritten by the controller
break;
case "jump":
DoJump( j, s );
break;
case "set":
DoSet( j, s );
break;
case "assert":
DoAssert( j, s );
break;
case "capture":
DoCapture( j, s );
break;
}
}
static void StepTick( Job j, StepSpec s )
{
switch ( s.Kind )
{
case "move":
{
if ( j.Controller == null ) return;
var td = Game.TypeLibrary.GetType( j.Controller.GetType() );
var yaw = ( ReadAngles( j.Controller, td, "EyeAngles" ) ?? j.Controller.WorldRotation.Angles() ).yaw;
var rot = Rotation.From( 0f, yaw, 0f );
var wish = rot.Forward * s.Move.x + rot.Left * s.Move.y;
if ( wish.Length > 1f ) wish = wish.Normal;
wish *= s.MoveSpeed;
TrySetVector3( j.Controller, td, "WishVelocity", wish );
break;
}
case "look":
{
if ( j.Controller == null ) return;
var td = Game.TypeLibrary.GetType( j.Controller.GetType() );
var a = s.Look; a.pitch = System.Math.Clamp( a.pitch, -89f, 89f );
TrySetAngles( j.Controller, td, "EyeAngles", a );
break;
}
case "lookDelta":
{
if ( j.Controller == null ) return;
var td = Game.TypeLibrary.GetType( j.Controller.GetType() );
var cur = ReadAngles( j.Controller, td, "EyeAngles" ) ?? new Angles();
cur.pitch = System.Math.Clamp( cur.pitch + s.LookDelta.pitch, -89f, 89f );
cur.yaw += s.LookDelta.yaw;
cur.roll += s.LookDelta.roll;
TrySetAngles( j.Controller, td, "EyeAngles", cur );
break;
}
case "action":
try { Sandbox.Input.SetAction( s.Action, true ); } catch { }
j.HeldAction = s.Action;
break;
}
}
static void StepExit( Job j, StepSpec s )
{
switch ( s.Kind )
{
case "move":
if ( j.Controller != null )
{
var td = Game.TypeLibrary.GetType( j.Controller.GetType() );
TrySetVector3( j.Controller, td, "WishVelocity", Vector3.Zero ); // stop β WishVelocity persists otherwise
}
j.Transcript.Add( new { step = j.Index, kind = "move", frames = s.Frames, move = $"{s.Move.x},{s.Move.y}" } );
break;
case "action":
try { Sandbox.Input.SetAction( s.Action, false ); } catch { }
j.HeldAction = null;
j.Transcript.Add( new { step = j.Index, kind = "action", action = s.Action, frames = s.Frames } );
break;
case "look":
j.Transcript.Add( new { step = j.Index, kind = "look", look = $"{s.Look.pitch},{s.Look.yaw},{s.Look.roll}" } );
break;
case "lookDelta":
j.Transcript.Add( new { step = j.Index, kind = "lookDelta", frames = s.Frames } );
break;
case "wait":
j.Transcript.Add( new { step = j.Index, kind = "wait", frames = s.Frames } );
break;
// jump/set/assert already recorded their result in StepEnter.
}
}
// ββ Actions ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
static void DoJump( Job j, StepSpec s )
{
if ( j.Controller == null )
{
j.Transcript.Add( new { step = j.Index, kind = "jump", ok = false, error = "no controller" } );
j.Failed++;
return;
}
try
{
var m = j.Controller.GetType().GetMethod( "Jump", new[] { typeof( Vector3 ) } );
if ( m == null )
{
j.Transcript.Add( new { step = j.Index, kind = "jump", ok = false, error = "controller has no Jump(Vector3)" } );
j.Failed++;
return;
}
m.Invoke( j.Controller, new object[] { s.JumpVel } );
j.Transcript.Add( new { step = j.Index, kind = "jump", ok = true, velocity = $"{s.JumpVel.x},{s.JumpVel.y},{s.JumpVel.z}" } );
}
catch ( Exception ex )
{
j.Transcript.Add( new { step = j.Index, kind = "jump", ok = false, error = ex.Message } );
j.Failed++;
}
}
static void DoSet( Job j, StepSpec s )
{
try
{
var comp = FindComponent( j, s.SetComponent );
if ( comp == null )
{
j.Transcript.Add( new { step = j.Index, kind = "set", ok = false, error = $"component '{s.SetComponent}' not found" } );
j.Failed++; return;
}
var td = Game.TypeLibrary.GetType( comp.GetType() );
var pd = td?.Properties.FirstOrDefault( pp => pp.Name == s.SetProperty );
if ( pd == null )
{
j.Transcript.Add( new { step = j.Index, kind = "set", ok = false, error = $"property '{s.SetProperty}' not found" } );
j.Failed++; return;
}
object typed = CoerceTo( pd.PropertyType, s.SetValue );
pd.SetValue( comp, typed );
j.Transcript.Add( new { step = j.Index, kind = "set", ok = true, target = $"{s.SetComponent}.{s.SetProperty}", to = s.SetValue } );
}
catch ( Exception ex )
{
j.Transcript.Add( new { step = j.Index, kind = "set", ok = false, error = ex.Message } );
j.Failed++;
}
}
static void DoAssert( Job j, StepSpec s )
{
string actual = null;
bool ok = false;
string err = null;
try
{
object val = ResolveRead( j, s.AssertRead, out err );
if ( err == null )
{
actual = ValueToString( val );
ok = Compare( j, s.AssertRead, val, s.AssertOp, s.AssertValue, out err );
}
}
catch ( Exception ex ) { err = ex.Message; }
if ( ok ) j.Passed++; else j.Failed++;
j.Transcript.Add( new
{
step = j.Index,
kind = "assert",
ok,
desc = s.AssertDesc,
read = s.AssertRead,
op = s.AssertOp,
expected = s.AssertValue,
actual,
error = err,
} );
}
// ββ Capture: screenshot the live player-POV camera (diagnostic, never pass/fail) ββ
static void DoCapture( Job j, StepSpec s )
{
try
{
var scene = Game.ActiveScene;
var cam = scene != null ? VisualHelpers.FindMainCamera( scene ) : null;
if ( cam == null )
{
j.Transcript.Add( new { step = j.Index, kind = "capture", ok = false, label = s.CaptureLabel, error = "no main camera in the running scene" } );
return;
}
using var bmp = new Bitmap( 1280, 720 );
cam.RenderToBitmap( bmp, true ); // renderUI=true β the running game incl. HUD
string path = System.IO.Path.Combine( System.IO.Path.GetTempPath(), $"bridge_playtest_{System.Guid.NewGuid():N}.png" );
System.IO.File.WriteAllBytes( path, bmp.ToPng() );
j.Transcript.Add( new { step = j.Index, kind = "capture", ok = true, label = s.CaptureLabel, path } );
}
catch ( Exception ex )
{
j.Transcript.Add( new { step = j.Index, kind = "capture", ok = false, label = s.CaptureLabel, error = ex.Message } );
}
}
// ββ Read resolution: "WorldPosition.x" | "<Component>.<Prop>[.sub]" ββββββββββ
static object ResolveRead( Job j, string read, out string err )
{
err = null;
if ( string.IsNullOrEmpty( read ) ) { err = "empty read"; return null; }
var parts = read.Split( '.' );
object cur;
int sub;
var head = parts[0];
if ( head == "Displacement" )
{
if ( j.Anchor == null ) { err = "no player object resolved"; return null; }
return (object) ( j.Anchor.WorldPosition - j.StartPos ).Length; // scalar β facing-independent movement proof
}
if ( head == "WorldPosition" || head == "LocalPosition" || head == "WorldRotation" || head == "WorldScale" )
{
if ( j.Anchor == null ) { err = "no player object resolved"; return null; }
cur = head switch
{
"WorldPosition" => (object) j.Anchor.WorldPosition,
"LocalPosition" => j.Anchor.LocalPosition,
"WorldRotation" => j.Anchor.WorldRotation.Angles(),
"WorldScale" => j.Anchor.WorldScale,
_ => null,
};
sub = 1;
}
else
{
if ( parts.Length < 2 ) { err = $"read '{read}' needs <Component>.<Property>"; return null; }
var comp = FindComponent( j, head );
if ( comp == null ) { err = $"component '{head}' not found on player"; return null; }
var td = Game.TypeLibrary.GetType( comp.GetType() );
var pd = td?.Properties.FirstOrDefault( pp => pp.Name == parts[1] );
if ( pd == null ) { err = $"property '{head}.{parts[1]}' not found"; return null; }
cur = pd.GetValue( comp );
sub = 2;
}
for ( int i = sub; i < parts.Length && cur != null; i++ )
cur = SubAccess( cur, parts[i] );
return cur;
}
static object SubAccess( object v, string sub )
{
if ( v is Vector3 v3 ) return sub switch { "x" => v3.x, "y" => v3.y, "z" => v3.z, _ => null };
if ( v is Vector2 v2 ) return sub switch { "x" => v2.x, "y" => v2.y, _ => null };
if ( v is Angles an ) return sub switch { "pitch" => an.pitch, "yaw" => an.yaw, "roll" => an.roll, _ => null };
if ( sub == "Count" )
{
if ( v is ICollection col ) return col.Count;
if ( v is IEnumerable en ) return en.Cast<object>().Count();
}
// generic property fallback
try { return v.GetType().GetProperty( sub )?.GetValue( v ); } catch { return null; }
}
static bool Compare( Job j, string readKey, object actual, string op, string expected, out string err )
{
err = null;
if ( op == "changed" )
return j.Baselines.TryGetValue( readKey, out var b ) ? ValueToString( actual ) != b : true;
// numeric comparison when both sides are numbers
if ( TryNum( actual, out var an ) && float.TryParse( expected, NumberStyles.Float, CultureInfo.InvariantCulture, out var en ) )
{
return op switch
{
">" => an > en, "<" => an < en, ">=" => an >= en, "<=" => an <= en,
"==" => System.Math.Abs( an - en ) < 0.0001f, "!=" => System.Math.Abs( an - en ) >= 0.0001f,
_ => SetErr( out err, $"bad numeric op '{op}'" ),
};
}
// bool / string equality
var astr = ValueToString( actual );
return op switch
{
"==" => string.Equals( astr, expected, StringComparison.OrdinalIgnoreCase ),
"!=" => !string.Equals( astr, expected, StringComparison.OrdinalIgnoreCase ),
_ => SetErr( out err, $"op '{op}' needs numeric operands (got '{astr}' vs '{expected}')" ),
};
}
static bool SetErr( out string err, string msg ) { err = msg; return false; }
static bool TryNum( object v, out float f )
{
f = 0f;
switch ( v )
{
case float ff: f = ff; return true;
case double dd: f = (float) dd; return true;
case int ii: f = ii; return true;
case long ll: f = ll; return true;
case short ss: f = ss; return true;
case byte bb: f = bb; return true;
default: return false;
}
}
static string ValueToString( object v )
{
if ( v == null ) return "null";
if ( v is bool b ) return b ? "True" : "False";
if ( v is Vector3 v3 ) return $"{v3.x},{v3.y},{v3.z}";
if ( v is float f ) return f.ToString( CultureInfo.InvariantCulture );
return v.ToString();
}
// ββ Setup / teardown ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
static void ResolveAnchor( Job j )
{
var scene = Game.ActiveScene;
if ( scene == null ) return;
Component c = null;
if ( j.TargetId != Guid.Empty )
{
var go = ClaudeBridge.ResolveGameObject( scene, j.TargetId.ToString() );
if ( go != null ) c = FindControllerOn( go, j.ComponentType );
}
if ( c == null )
{
foreach ( var obj in scene.GetAllObjects( true ) )
{
c = FindControllerOn( obj, j.ComponentType );
if ( c != null ) break;
}
}
j.Controller = c;
j.Anchor = c?.GameObject;
}
static void CaptureBaselines( Job j )
{
// Anchor position at job start β the origin for the "Displacement" read.
if ( j.Anchor != null ) j.StartPos = j.Anchor.WorldPosition;
// Record the initial value of every "changed" read so we can diff later.
foreach ( var s in j.Steps.Where( x => x.Kind == "assert" && x.AssertOp == "changed" ) )
{
var v = ResolveRead( j, s.AssertRead, out var e );
if ( e == null ) j.Baselines[s.AssertRead] = ValueToString( v );
}
}
static void EnsureInputDisabled( Job j )
{
if ( j.DisabledInput || j.Controller == null ) return;
var td = Game.TypeLibrary.GetType( j.Controller.GetType() );
var pd = td?.Properties.FirstOrDefault( pp => pp.Name == "UseInputControls" );
if ( pd != null && pd.PropertyType == typeof( bool ) )
{
pd.SetValue( j.Controller, false );
j.DisabledInput = true;
}
}
static void Teardown( Job j )
{
try
{
if ( !string.IsNullOrEmpty( j.HeldAction ) )
try { Sandbox.Input.SetAction( j.HeldAction, false ); } catch { }
if ( j.Controller != null && j.Controller.IsValid() )
{
var td = Game.TypeLibrary.GetType( j.Controller.GetType() );
TrySetVector3( j.Controller, td, "WishVelocity", Vector3.Zero );
if ( j.DisabledInput )
{
var pd = td?.Properties.FirstOrDefault( pp => pp.Name == "UseInputControls" );
pd?.SetValue( j.Controller, true );
}
}
}
catch { }
}
static object Summarize( Job j, string reason )
{
return new
{
finished = true,
reason,
verdict = j.Failed == 0 ? "PASS" : "FAIL",
passed = j.Passed,
failed = j.Failed,
stepsRun = j.Index,
totalSteps = j.Steps.Count,
controller = j.Controller?.GetType().Name,
controllerResolved = j.Controller != null,
transcript = j.Transcript,
};
}
// ββ Reflection helpers (self-contained; mirror PlayInputDriver's idiom) ββββββ
internal static Component FindControllerOn( GameObject go, string componentType )
{
if ( go == null ) return null;
var all = go.Components.GetAll().ToList();
if ( !string.IsNullOrEmpty( componentType ) )
return all.FirstOrDefault( c => c.GetType().Name.Equals( componentType, StringComparison.OrdinalIgnoreCase ) );
var exact = all.FirstOrDefault( c => c.GetType().Name == "PlayerController" );
if ( exact != null ) return exact;
return all.FirstOrDefault( c =>
{
var n = c.GetType().Name;
if ( !n.EndsWith( "Controller", StringComparison.OrdinalIgnoreCase ) ) return false;
var td = Game.TypeLibrary.GetType( c.GetType() );
return td != null && td.Properties.Any( pp => pp.Name == "EyeAngles" || pp.Name == "WishVelocity" );
} );
}
static Component FindComponent( Job j, string typeName )
{
if ( j.Anchor == null || string.IsNullOrEmpty( typeName ) ) return null;
return j.Anchor.Components.GetAll().FirstOrDefault( c => c.GetType().Name.Equals( typeName, StringComparison.OrdinalIgnoreCase ) );
}
static Angles? ReadAngles( Component c, TypeDescription td, string member )
{
try
{
var pd = td?.Properties.FirstOrDefault( pp => pp.Name == member );
if ( pd == null ) return null;
var v = pd.GetValue( c );
if ( v is Angles a ) return a;
if ( v is Rotation r ) return r.Angles();
}
catch { }
return null;
}
static bool TrySetAngles( Component c, TypeDescription td, string member, Angles value )
{
try
{
var pd = td?.Properties.FirstOrDefault( pp => pp.Name == member );
if ( pd == null ) return false;
if ( pd.PropertyType == typeof( Angles ) ) { pd.SetValue( c, value ); return true; }
if ( pd.PropertyType == typeof( Rotation ) ) { pd.SetValue( c, Rotation.From( value ) ); return true; }
}
catch { }
return false;
}
static bool TrySetVector3( Component c, TypeDescription td, string member, Vector3 value )
{
try
{
var pd = td?.Properties.FirstOrDefault( pp => pp.Name == member );
if ( pd == null || pd.PropertyType != typeof( Vector3 ) ) return false;
pd.SetValue( c, value );
return true;
}
catch { return false; }
}
static object CoerceTo( Type t, string raw )
{
if ( t == typeof( bool ) ) return raw == "true" || raw == "True" || raw == "1";
if ( t == typeof( float ) ) return float.Parse( raw, NumberStyles.Float, CultureInfo.InvariantCulture );
if ( t == typeof( int ) ) return (int) float.Parse( raw, NumberStyles.Float, CultureInfo.InvariantCulture );
if ( t == typeof( Vector3 ) ) return ClaudeBridge.ParseVector3Flexible( ParseElement( raw ) );
return raw;
}
static JsonElement ParseElement( string raw )
{
// Wrap a bare "x,y,z" or scalar as a JSON string element for ParseVector3Flexible.
using var doc = JsonDocument.Parse( JsonSerializer.Serialize( raw ) );
return doc.RootElement.Clone();
}
}
/// <summary>
/// playtest β run a scripted gameplay-verification sequence in play mode (async, in the
/// editor frame loop) and record a pass/fail transcript. Requires start_play first.
/// </summary>
public class PlaytestHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
if ( !Game.IsPlaying )
return Task.FromResult<object>( new { error = "playtest requires play mode β call start_play first" } );
if ( PlaytestRunner.IsActive() )
return Task.FromResult<object>( new { error = "a playtest is already running β poll playtest_status until it finishes" } );
if ( !p.TryGetProperty( "steps", out var stepsEl ) || stepsEl.ValueKind != JsonValueKind.Array )
return Task.FromResult<object>( new { error = "steps (an array of step objects) is required" } );
try
{
var job = new PlaytestRunner.Job { Steps = new List<PlaytestRunner.StepSpec>() };
if ( p.TryGetProperty( "id", out var idEl ) && idEl.ValueKind == JsonValueKind.String
&& Guid.TryParse( idEl.GetString(), out var gid ) )
job.TargetId = gid;
if ( p.TryGetProperty( "component", out var compEl ) && compEl.ValueKind == JsonValueKind.String )
job.ComponentType = compEl.GetString();
int idx = 0;
foreach ( var stepEl in stepsEl.EnumerateArray() )
{
var spec = ParseStep( stepEl, idx, out var perr );
if ( spec == null )
return Task.FromResult<object>( new { error = $"step {idx}: {perr}" } );
job.Steps.Add( spec );
idx++;
}
if ( job.Steps.Count == 0 )
return Task.FromResult<object>( new { error = "steps is empty" } );
PlaytestRunner.Start( job );
return Task.FromResult<object>( new
{
started = true,
steps = job.Steps.Count,
note = "Playtest running ASYNC in the editor frame loop. Poll playtest_status until finished:true, then read the transcript (pass/fail per step).",
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"playtest failed: {ex.Message}" } );
}
}
static PlaytestRunner.StepSpec ParseStep( JsonElement e, int idx, out string err )
{
err = null;
if ( e.ValueKind != JsonValueKind.Object ) { err = "not an object"; return null; }
var s = new PlaytestRunner.StepSpec();
int? framesOverride = ( e.TryGetProperty( "frames", out var fEl ) && fEl.TryGetInt32( out var fi ) )
? System.Math.Clamp( fi, 1, 1800 ) : (int?) null;
if ( e.TryGetProperty( "moveSpeed", out var msEl ) && msEl.TryGetSingle( out var ms ) ) s.MoveSpeed = ms;
if ( e.TryGetProperty( "move", out var mEl ) )
{
s.Kind = "move"; s.Move = ParseMove( mEl ); s.Frames = framesOverride ?? 30;
}
else if ( e.TryGetProperty( "look", out var lEl ) )
{
s.Kind = "look"; s.Look = ParseAngles( lEl ); s.HasLook = true; s.Frames = framesOverride ?? 1;
}
else if ( e.TryGetProperty( "lookDelta", out var ldEl ) )
{
s.Kind = "lookDelta"; s.LookDelta = ParseAngles( ldEl ); s.Frames = framesOverride ?? 30;
}
else if ( e.TryGetProperty( "action", out var aEl ) && aEl.ValueKind == JsonValueKind.String )
{
s.Kind = "action"; s.Action = aEl.GetString(); s.Frames = framesOverride ?? 20;
}
else if ( e.TryGetProperty( "jump", out var jEl ) )
{
s.Kind = "jump"; s.JumpVel = ClaudeBridge.ParseVector3Flexible( jEl ); s.Frames = 1;
}
else if ( e.TryGetProperty( "set", out var setEl ) && setEl.ValueKind == JsonValueKind.Object )
{
s.Kind = "set"; s.Frames = 1;
s.SetComponent = GetStr( setEl, "component" );
s.SetProperty = GetStr( setEl, "property" );
s.SetValue = GetStr( setEl, "to" ) ?? GetStr( setEl, "value" );
if ( s.SetComponent == null || s.SetProperty == null ) { err = "set needs {component, property, to}"; return null; }
}
else if ( e.TryGetProperty( "wait", out var wEl ) && wEl.TryGetInt32( out var wf ) )
{
s.Kind = "wait"; s.Frames = System.Math.Clamp( wf, 1, 1800 );
}
else if ( e.TryGetProperty( "capture", out var capEl ) )
{
s.Kind = "capture"; s.Frames = 1;
s.CaptureLabel = capEl.ValueKind == JsonValueKind.String ? capEl.GetString() : null;
}
else if ( e.TryGetProperty( "assert", out var asEl ) && asEl.ValueKind == JsonValueKind.Object )
{
s.Kind = "assert"; s.Frames = 1;
s.AssertRead = GetStr( asEl, "read" );
s.AssertOp = GetStr( asEl, "op" ) ?? "==";
s.AssertDesc = GetStr( asEl, "desc" );
if ( asEl.TryGetProperty( "value", out var vEl ) )
s.AssertValue = vEl.ValueKind == JsonValueKind.String ? vEl.GetString() : vEl.GetRawText();
if ( s.AssertRead == null ) { err = "assert needs {read, op, value}"; return null; }
}
else
{
err = "unknown step (expected one of: move, look, lookDelta, action, jump, set, wait, capture, assert)";
return null;
}
return s;
}
static string GetStr( JsonElement o, string key )
=> o.TryGetProperty( key, out var v ) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
static Vector2 ParseMove( JsonElement el )
{
float x = 0f, y = 0f;
if ( el.ValueKind == JsonValueKind.Object )
{
if ( el.TryGetProperty( "x", out var xp ) && xp.TryGetSingle( out var xf ) ) x = xf;
if ( el.TryGetProperty( "y", out var yp ) && yp.TryGetSingle( out var yf ) ) y = yf;
}
else if ( el.ValueKind == JsonValueKind.String )
{
var pr = ( el.GetString() ?? "" ).Split( ',' );
if ( pr.Length > 0 ) float.TryParse( pr[0], NumberStyles.Float, CultureInfo.InvariantCulture, out x );
if ( pr.Length > 1 ) float.TryParse( pr[1], NumberStyles.Float, CultureInfo.InvariantCulture, out y );
}
var v = new Vector2( x, y );
if ( v.Length > 1f ) v = v.Normal;
return v;
}
static Angles ParseAngles( JsonElement el )
{
float pitch = 0f, yaw = 0f, roll = 0f;
if ( el.ValueKind == JsonValueKind.Object )
{
if ( el.TryGetProperty( "pitch", out var pp ) && pp.TryGetSingle( out var pf ) ) pitch = pf;
if ( el.TryGetProperty( "yaw", out var yp ) && yp.TryGetSingle( out var yf ) ) yaw = yf;
if ( el.TryGetProperty( "roll", out var rp ) && rp.TryGetSingle( out var rf ) ) roll = rf;
}
else if ( el.ValueKind == JsonValueKind.String )
{
var pr = ( el.GetString() ?? "" ).Split( ',' );
if ( pr.Length > 0 ) float.TryParse( pr[0], NumberStyles.Float, CultureInfo.InvariantCulture, out pitch );
if ( pr.Length > 1 ) float.TryParse( pr[1], NumberStyles.Float, CultureInfo.InvariantCulture, out yaw );
if ( pr.Length > 2 ) float.TryParse( pr[2], NumberStyles.Float, CultureInfo.InvariantCulture, out roll );
}
return new Angles( pitch, yaw, roll );
}
}
/// <summary>
/// playtest_status β poll the running/finished playtest: live progress while running,
/// or the full pass/fail transcript once finished.
/// </summary>
public class PlaytestStatusHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var summary = PlaytestRunner.ConsumeSummary();
if ( summary != null )
return Task.FromResult<object>( summary );
var live = PlaytestRunner.LiveSnapshot();
if ( live != null )
return Task.FromResult<object>( live );
return Task.FromResult<object>( new { active = false, finished = false, note = "No playtest has run yet." } );
}
}
/// <summary>
/// playtest_abort β stop the running playtest immediately, restoring input state.
/// The partial transcript stays available via playtest_status.
/// </summary>
public class PlaytestAbortHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
=> Task.FromResult<object>( PlaytestRunner.Abort() );
}
Editor
library
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Batch 54 β bridge_vehicle (v2 wave 4): the corpus vehicles theme.
// create_vehicle_controller β make any Rigidbody prop drivable (raycast car
// with suspension, engine, steering, grip + built-in driver seat)
// create_seat_system β standalone generic seat (enter/exit/safe-exit)
// tune_vehicle β apply arcade/drift/offroad/race presets
// create_physics_grab_tool β physgun-style spring grab + throw
// Generated code APIs verified live: Rigidbody.ApplyForceAt/GetVelocityAtPoint/
// ApplyTorque/Velocity/Mass (describe_type, 2026-07-09). Driving FEEL needs a
// human playtest β compiles+runs β fun (BRIDGE_GOTCHAS #1).
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
/// <summary>create_vehicle_controller β scaffold a drivable raycast-car component.</summary>
public class CreateVehicleControllerHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "VehicleController", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
float engine = p.TryGetProperty( "engineForce", out var ef ) && ef.TryGetSingle( out var eff ) ? eff : 900f;
float steer = p.TryGetProperty( "steerStrength", out var ss ) && ss.TryGetSingle( out var ssf ) ? ssf : 2.0f;
float grip = p.TryGetProperty( "grip", out var g ) && g.TryGetSingle( out var gf ) ? gf : 0.85f;
ScaffoldHelpers.WriteCode( fullPath, BuildCode( className, engine, steer, grip ) );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
nextSteps = new[]
{
"trigger_hotload, then check compile_status",
$"Attach {className} + a Rigidbody + a collider to your vehicle prop (batch_add_component works)",
"Enter play mode and press E on the vehicle to drive (WASD; E again to exit)",
"tune_vehicle applies arcade/drift/offroad/race presets to the attached component",
"HUMAN PLAYTEST REQUIRED for feel β tune EngineForce/SteerStrength/GripFactor from the inspector while playing"
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_vehicle_controller failed: {ex.Message}" } );
}
}
static string BuildCode( string className, float engine, float steer, float grip )
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
return $@"using Sandbox;
using System;
/// <summary>
/// {className} β makes a Rigidbody prop drivable: a 4-corner raycast car with
/// spring/damper suspension, engine force, yaw steering, and lateral grip
/// (lower grip = drift). Built-in driver seat: press E (use) to enter β the
/// driver is hidden while driving (no controller transform fights), the host
/// assigns them vehicle ownership, and a chase camera follows β E to exit.
/// Requires a Rigidbody + collider on the same GameObject. Tune from the
/// inspector while playing; tune_vehicle applies ready-made presets.
/// </summary>
public sealed class {className} : Component, Component.IPressable
{{
[Property, Group( ""Engine"" )] public float EngineForce {{ get; set; }} = {engine.ToString( ci )}f;
[Property, Group( ""Engine"" )] public float MaxSpeed {{ get; set; }} = 800f;
[Property, Group( ""Steering"" )] public float SteerStrength {{ get; set; }} = {steer.ToString( ci )}f; // yaw rate, rad/s at full speed factor
[Property, Group( ""Handling"" ), Range( 0f, 1f )] public float GripFactor {{ get; set; }} = {grip.ToString( ci )}f;
[Property, Group( ""Suspension"" )] public float SuspensionRest {{ get; set; }} = 24f;
[Property, Group( ""Suspension"" )] public float SuspensionStrength {{ get; set; }} = 90f;
[Property, Group( ""Suspension"" )] public float SuspensionDamping {{ get; set; }} = 8f;
[Property, Group( ""Seat"" )] public Vector3 ExitOffset {{ get; set; }} = new( 0, 80, 20 );
[Property, Group( ""Camera"" )] public float CameraDistance {{ get; set; }} = 260f;
[Property, Group( ""Camera"" )] public float CameraHeight {{ get; set; }} = 110f;
[Sync] public Guid DriverId {{ get; set; }}
public bool HasDriver => DriverId != Guid.Empty;
public static event Action<GameObject, bool> OnDriverChanged; // (vehicle, entered)
Rigidbody _rb;
Vector3[] _corners;
TimeSince _sinceEnter;
protected override void OnStart()
{{
_rb = GetComponent<Rigidbody>();
if ( _rb == null )
{{
Log.Warning( $""{className} needs a Rigidbody on {{GameObject.Name}}"" );
Enabled = false;
return;
}}
var bounds = GameObject.GetBounds();
var ext = ( bounds.Size * 0.4f ).WithZ( 0 );
_corners = new[]
{{
new Vector3( ext.x, ext.y, 0 ), new Vector3( ext.x, -ext.y, 0 ),
new Vector3( -ext.x, ext.y, 0 ), new Vector3( -ext.x, -ext.y, 0 ),
}};
}}
// ββ Seat (IPressable) ββββββββββββββββββββββββββββββββββββββββββββ
public bool Press( Component.IPressable.Event e )
{{
var presser = e.Source?.GameObject;
if ( presser == null ) return false;
RequestSeat( presser.Id );
return true;
}}
[Rpc.Host]
void RequestSeat( Guid pressGuid )
{{
var presser = Scene.Directory.FindByGuid( pressGuid );
if ( presser == null ) return;
if ( HasDriver && DriverId != pressGuid ) return;
if ( DriverId == pressGuid )
{{
Exit( presser );
return;
}}
// Enter: hide the player entirely while driving β parenting a live
// PlayerController to a moving vehicle makes two systems fight over the
// transform (the classic seat jitter). Hidden driver + chase camera instead.
DriverId = pressGuid;
_sinceEnter = 0;
var owner = presser.Network.Owner;
if ( owner != null ) GameObject.Network.AssignOwnership( owner );
presser.Enabled = false;
OnDriverChanged?.Invoke( GameObject, true );
}}
void Exit( GameObject driver )
{{
DriverId = Guid.Empty;
if ( driver != null )
{{
driver.WorldPosition = WorldPosition + WorldRotation * ExitOffset;
driver.Enabled = true; // their controller re-takes the camera next frame
}}
GameObject.Network.DropOwnership();
OnDriverChanged?.Invoke( GameObject, false );
}}
// Chase camera while driving (runs on the driver's client β they own the vehicle).
protected override void OnPreRender()
{{
if ( IsProxy || !HasDriver ) return;
var cam = Scene.Camera;
if ( cam == null ) return;
var targetPos = WorldPosition - WorldRotation.Forward.WithZ( 0 ).Normal * CameraDistance + Vector3.Up * CameraHeight;
cam.WorldPosition = cam.WorldPosition.LerpTo( targetPos, MathX.Clamp( Time.Delta * 6f, 0f, 1f ) );
cam.WorldRotation = Rotation.LookAt( ( WorldPosition + Vector3.Up * 30f - cam.WorldPosition ).Normal, Vector3.Up );
}}
// ββ Driving (vehicle owner only) βββββββββββββββββββββββββββββββββ
protected override void OnFixedUpdate()
{{
if ( _rb == null || IsProxy || !HasDriver ) return;
// E again to exit (edge-guarded so the entering press cannot instantly exit).
if ( _sinceEnter > 0.4f && Input.Pressed( ""use"" ) )
{{
RequestSeat( DriverId );
return;
}}
var dt = Time.Delta;
var input = Input.AnalogMove; // x = forward/back, y = left/right
int grounded = 0;
// Suspension: 4 corner rays, spring + damper applied at each corner.
foreach ( var corner in _corners )
{{
var worldCorner = WorldPosition + WorldRotation * corner;
var tr = Scene.Trace.Ray( worldCorner, worldCorner + Vector3.Down * SuspensionRest * 2f )
.IgnoreGameObjectHierarchy( GameObject )
.Run();
if ( !tr.Hit ) continue;
grounded++;
var compression = 1f - ( tr.Distance / ( SuspensionRest * 2f ) );
var pointVel = _rb.GetVelocityAtPoint( worldCorner );
var force = Vector3.Up * ( compression * SuspensionStrength - pointVel.z * SuspensionDamping ) * _rb.Mass * dt * 50f;
_rb.ApplyForceAt( worldCorner, force );
}}
if ( grounded == 0 ) return; // airborne β no engine/steer/grip
var forward = WorldRotation.Forward.WithZ( 0 ).Normal;
var speed = _rb.Velocity.WithZ( 0 ).Length;
// Engine (mass-scaled so feel survives different props).
if ( MathF.Abs( input.x ) > 0.01f && speed < MaxSpeed )
_rb.ApplyForce( forward * input.x * EngineForce * _rb.Mass );
// Steering: set yaw angular velocity directly β arcade-reliable, immune to the
// prop's moment of inertia (torque was far too weak on heavy boxes β playtested).
var steerFactor = MathX.Clamp( speed / 150f, 0.25f, 1f );
var direction = _rb.Velocity.Dot( forward ) < -10f ? -1f : 1f; // reverse steers mirrored
var yawRate = MathF.Abs( input.y ) > 0.01f
? input.y * SteerStrength * steerFactor * direction
: 0f;
_rb.AngularVelocity = _rb.AngularVelocity.WithZ( MathX.Lerp( _rb.AngularVelocity.z, yawRate, MathX.Clamp( dt * 12f, 0f, 1f ) ) );
// Lateral grip: kill a fraction of sideways velocity each tick. Low grip = drift.
var right = WorldRotation.Right.WithZ( 0 ).Normal;
var lateral = right * _rb.Velocity.Dot( right );
_rb.Velocity -= lateral * GripFactor * MathX.Clamp( dt * 10f, 0f, 1f );
}}
}}
";
}
}
/// <summary>create_seat_system β scaffold a standalone enter/exit seat component.</summary>
public class CreateSeatSystemHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "Seat", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
ScaffoldHelpers.WriteCode( fullPath, BuildCode( className ) );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
nextSteps = new[]
{
"trigger_hotload, then check compile_status",
$"Attach {className} to any prop (chair, bench, turret mount) β press E to sit, E to stand",
"SeatOffset positions the occupant; exit tries ExitOffsets in order and takes the first clear spot",
$"Subscribe to {className}.OnOccupantChanged for camera/UI logic"
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_seat_system failed: {ex.Message}" } );
}
}
static string BuildCode( string className )
{
return $@"using Sandbox;
using System;
using System.Linq;
/// <summary>
/// {className} β a networked one-occupant seat: press E (use) to sit, E again
/// to stand. Claims route through the host so two players can't share a seat;
/// the occupant is parented to the seat with their controller input disabled
/// (UseInputControls=false, restored on exit). Exit tries each ExitOffsets
/// entry and takes the first spot with clearance. Works for chairs, benches,
/// turret mounts β anything sittable.
/// </summary>
public sealed class {className} : Component, Component.IPressable
{{
[Property] public Vector3 SeatOffset {{ get; set; }} = new( 0, 0, 10 );
[Property] public System.Collections.Generic.List<Vector3> ExitOffsets {{ get; set; }} = new()
{{ new( 0, 60, 10 ), new( 0, -60, 10 ), new( 60, 0, 10 ), new( -60, 0, 10 ) }};
[Sync] public Guid OccupantId {{ get; set; }}
public bool IsOccupied => OccupantId != Guid.Empty;
public static event Action<GameObject, GameObject, bool> OnOccupantChanged; // (seat, occupant, seated)
public bool Press( Component.IPressable.Event e )
{{
var presser = e.Source?.GameObject;
if ( presser == null ) return false;
RequestSeat( presser.Id );
return true;
}}
[Rpc.Host]
void RequestSeat( Guid pressGuid )
{{
var presser = Scene.Directory.FindByGuid( pressGuid );
if ( presser == null ) return;
if ( OccupantId == pressGuid )
{{
SetControls( presser, true );
presser.SetParent( null, true );
presser.WorldPosition = FindExitSpot( presser );
OccupantId = Guid.Empty;
OnOccupantChanged?.Invoke( GameObject, presser, false );
return;
}}
if ( IsOccupied ) return;
OccupantId = pressGuid;
presser.SetParent( GameObject, true );
presser.LocalPosition = SeatOffset;
SetControls( presser, false );
OnOccupantChanged?.Invoke( GameObject, presser, true );
}}
Vector3 FindExitSpot( GameObject occupant )
{{
foreach ( var offset in ExitOffsets )
{{
var spot = WorldPosition + WorldRotation * offset;
var tr = Scene.Trace.Ray( spot + Vector3.Up * 32f, spot )
.IgnoreGameObjectHierarchy( GameObject )
.IgnoreGameObjectHierarchy( occupant )
.Run();
if ( !tr.Hit ) return spot;
}}
return WorldPosition + Vector3.Up * 48f; // all blocked β pop up top
}}
static void SetControls( GameObject occupant, bool enabled )
{{
foreach ( var comp in occupant.Components.GetAll() )
{{
if ( comp is null ) continue;
var type = Game.TypeLibrary?.GetType( comp.GetType() );
var prop = type?.Properties?.FirstOrDefault( pr => pr.Name == ""UseInputControls"" );
prop?.SetValue( comp, enabled );
}}
}}
}}
";
}
}
/// <summary>tune_vehicle β apply a handling preset to a vehicle controller component.</summary>
public class TuneVehicleHandler : IBridgeHandler
{
static readonly Dictionary<string, Dictionary<string, float>> Presets = new( StringComparer.OrdinalIgnoreCase )
{
["arcade"] = new() { ["EngineForce"] = 900f, ["MaxSpeed"] = 800f, ["SteerStrength"] = 2.0f, ["GripFactor"] = 0.85f, ["SuspensionStrength"] = 90f, ["SuspensionDamping"] = 8f },
["drift"] = new() { ["EngineForce"] = 1100f, ["MaxSpeed"] = 900f, ["SteerStrength"] = 2.8f, ["GripFactor"] = 0.35f, ["SuspensionStrength"] = 80f, ["SuspensionDamping"] = 6f },
["offroad"] = new() { ["EngineForce"] = 750f, ["MaxSpeed"] = 600f, ["SteerStrength"] = 1.5f, ["GripFactor"] = 0.7f, ["SuspensionStrength"] = 130f, ["SuspensionDamping"] = 12f },
["race"] = new() { ["EngineForce"] = 1400f, ["MaxSpeed"] = 1400f, ["SteerStrength"] = 1.7f, ["GripFactor"] = 0.95f, ["SuspensionStrength"] = 110f, ["SuspensionDamping"] = 10f },
};
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var id = p.TryGetProperty( "id", out var idEl ) ? idEl.GetString() : null;
var go = ClaudeBridge.ResolveGameObject( scene, id );
if ( go == null )
return Task.FromResult<object>( new { error = $"GameObject not found: {id}" } );
var presetName = p.TryGetProperty( "preset", out var pr ) ? pr.GetString() : null;
if ( presetName == null || !Presets.TryGetValue( presetName, out var preset ) )
return Task.FromResult<object>( new { error = $"preset must be one of: {string.Join( " | ", Presets.Keys )}" } );
var compName = p.TryGetProperty( "component", out var cn ) ? cn.GetString() : null;
var component = go.Components.GetAll().FirstOrDefault( c => c != null &&
( compName != null
? c.GetType().Name.Equals( compName, StringComparison.OrdinalIgnoreCase )
: c.GetType().Name.Contains( "Vehicle", StringComparison.OrdinalIgnoreCase ) ) );
if ( component == null )
return Task.FromResult<object>( new { error = compName != null
? $"No '{compName}' component on the object"
: "No component with 'Vehicle' in its type name found β pass component explicitly" } );
var typeDesc = Game.TypeLibrary.GetType( component.GetType().Name );
var applied = new List<object>();
var missing = new List<string>();
foreach ( var (propName, value) in preset )
{
var propDesc = typeDesc?.Properties.FirstOrDefault( pp => pp.Name == propName );
if ( propDesc == null ) { missing.Add( propName ); continue; }
try
{
propDesc.SetValue( component, value );
applied.Add( new { property = propName, value } );
}
catch ( Exception ex ) { missing.Add( $"{propName} ({ex.Message})" ); }
}
return Task.FromResult<object>( new
{
tuned = applied.Count > 0,
preset = presetName.ToLowerInvariant(),
component = component.GetType().Name,
applied,
missing,
note = missing.Count > 0
? "Some preset properties don't exist on this component β presets target create_vehicle_controller scaffolds; others tune partially."
: "Preset applied. Enter play mode and drive to feel it; fine-tune the same properties with set_property."
} );
}
}
/// <summary>create_physics_grab_tool β scaffold a physgun-style spring grab + throw.</summary>
public class CreatePhysicsGrabToolHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "PhysicsGrabTool", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
ScaffoldHelpers.WriteCode( fullPath, BuildCode( className ) );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
nextSteps = new[]
{
"trigger_hotload, then check compile_status",
$"Attach {className} to the player object (needs a camera child or PlayerController for aim)",
"Hold attack2 (right mouse) on a Rigidbody prop to grab; scroll-free: it follows at grab distance; attack1 throws",
"ensure_input_action if your project lacks attack1/attack2 bindings"
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_physics_grab_tool failed: {ex.Message}" } );
}
}
static string BuildCode( string className )
{
return $@"using Sandbox;
using System;
using System.Linq;
/// <summary>
/// {className} β a physgun-lite for the player: hold GrabAction (default
/// attack2) while looking at a Rigidbody prop to grab it; it spring-follows a
/// point in front of your view (physics stays LIVE β it collides and swings,
/// unlike a parented carry); press ThrowAction (default attack1) to launch it.
/// Grab requests route through the host, which assigns the grabber network
/// ownership of the prop. Owner-only logic; attach to the player object.
/// </summary>
public sealed class {className} : Component
{{
[Property] public float Range {{ get; set; }} = 300f;
[Property] public float SpringStrength {{ get; set; }} = 12f;
[Property] public float ThrowForce {{ get; set; }} = 600f;
[Property] public float MaxMass {{ get; set; }} = 2000f;
[Property] public string GrabAction {{ get; set; }} = ""attack2"";
[Property] public string ThrowAction {{ get; set; }} = ""attack1"";
GameObject _held;
float _holdDistance;
public bool IsHolding => _held.IsValid();
public static event Action<GameObject, GameObject, bool> OnGrabChanged; // (player, prop, grabbed)
protected override void OnFixedUpdate()
{{
if ( IsProxy ) return;
var eye = GetEye( out var dir );
if ( IsHolding && Input.Pressed( ThrowAction ) )
{{
var rb = _held.GetComponent<Rigidbody>();
rb?.ApplyImpulse( dir * ThrowForce * ( rb.Mass ) );
Release();
return;
}}
if ( Input.Down( GrabAction ) )
{{
if ( !IsHolding ) TryGrab( eye, dir );
else Hold( eye, dir );
}}
else if ( IsHolding )
{{
Release();
}}
}}
Vector3 GetEye( out Vector3 dir )
{{
var cam = Scene.Camera;
if ( cam != null )
{{
dir = cam.WorldRotation.Forward;
return cam.WorldPosition;
}}
dir = WorldRotation.Forward;
return WorldPosition + Vector3.Up * 64f;
}}
void TryGrab( Vector3 eye, Vector3 dir )
{{
var tr = Scene.Trace.Ray( eye, eye + dir * Range )
.IgnoreGameObjectHierarchy( GameObject )
.Run();
if ( !tr.Hit || tr.GameObject == null ) return;
var rb = tr.GameObject.GetComponent<Rigidbody>();
if ( rb == null || rb.Mass > MaxMass ) return;
_held = tr.GameObject;
_holdDistance = MathX.Clamp( tr.Distance, 60f, Range );
RequestGrabOwnership( _held.Id );
OnGrabChanged?.Invoke( GameObject, _held, true );
}}
void Hold( Vector3 eye, Vector3 dir )
{{
if ( !_held.IsValid() ) {{ _held = null; return; }}
var rb = _held.GetComponent<Rigidbody>();
if ( rb == null ) {{ Release(); return; }}
var target = eye + dir * _holdDistance;
// Velocity-set spring: stiff, stable, still collides with the world.
rb.Velocity = ( target - _held.WorldPosition ) * SpringStrength;
rb.AngularVelocity = rb.AngularVelocity.LerpTo( Vector3.Zero, Time.Delta * 5f );
}}
void Release()
{{
if ( _held.IsValid() )
OnGrabChanged?.Invoke( GameObject, _held, false );
_held = null;
}}
[Rpc.Host]
void RequestGrabOwnership( Guid propId )
{{
var prop = Scene.Directory.FindByGuid( propId );
var caller = Rpc.Caller;
if ( prop == null || caller is null ) return;
prop.Network.AssignOwnership( caller );
}}
}}
";
}
}
Editor
library
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;
// =============================================================================
// Game Feel pack (v1.19.0) -- three "juice" scaffolds (code-gen; scene-mutating):
//
// create_camera_shake trauma-based Perlin camera shake component
// add_flicker_light flicker/pulse animator for an existing light
// create_floating_combat_text rising/fading world-space damage popups
//
// Compiles into the SAME editor assembly as MyEditorMenu.cs / ScaffoldHandlers.cs,
// so it reuses the shared statics on ClaudeBridge (TryResolveProjectPath,
// SanitizeIdentifier, SerializeGo) and ScaffoldHelpers (PrepareCodeFile /
// WriteCode / Utf8NoBom). Handler code here is UNSANDBOXED editor code.
//
// The C# *strings these handlers WRITE TO DISK* are SANDBOXED game code and must
// obey the s&box sandbox rules:
// - MathX preferred; System.Math/MathF also compile on the current SDK.
// Array.Clone() is still whitelist-blocked (not used here).
// - only sandbox-proven APIs: Component, [Property], List<T>, TimeSince,
// Game.Random.Float (compile-verified in create_weighted_loot_table),
// Sandbox.Utility.Noise.Perlin (fully qualified to dodge a using),
// new GameObject(...) for runtime spawns.
// - all three generated components are LOCAL/visual-only -- no [Sync], no
// RPCs. Multiplayer note lands in the nextSteps (wrap the calls in an
// [Rpc.Broadcast] so every client sees the juice).
//
// Register(...) lines + the _sceneMutatingCommands additions live in
// MyEditorMenu.cs (Batch 44) to keep the files decoupled.
// =============================================================================
// -----------------------------------------------------------------------------
// create_camera_shake -- trauma-based camera shake (the corpus-standard model:
// shake magnitude = Trauma^2, Perlin-driven offsets, decays over time).
//
// Applied in OnPreRender AFTER controllers have positioned the camera. The
// un-apply guard (compare against what we last WROTE) makes it correct on both
// a static camera (no accumulation) and a controller-driven one (no fighting).
// -----------------------------------------------------------------------------
public class CreateCameraShakeHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
if ( !ScaffoldHelpers.PrepareCodeFile( p, "CameraShake", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
float maxOffset = p.TryGetProperty( "maxOffset", out var ov ) && ov.TryGetSingle( out var of ) ? of : 6f;
float maxAngle = p.TryGetProperty( "maxAngle", out var av ) && av.TryGetSingle( out var af ) ? af : 4f;
float frequency = p.TryGetProperty( "frequency", out var fv ) && fv.TryGetSingle( out var ff ) ? ff : 10f;
float decay = p.TryGetProperty( "decayPerSecond", out var dv ) && dv.TryGetSingle( out var df ) ? df : 1.5f;
// Defensive clamps so a silly value can't emit a nauseating component.
if ( maxOffset < 0f ) maxOffset = 0f;
if ( maxAngle < 0f ) maxAngle = 0f;
if ( frequency < 0.1f ) frequency = 0.1f;
if ( decay < 0.05f ) decay = 0.05f;
var code = BuildCode( className, maxOffset, maxAngle, frequency, decay, ci );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = GameFeelHelpers.PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
maxOffset,
maxAngle,
frequency,
decayPerSecond = decay,
placedOn,
note,
nextSteps = new[]
{
$"trigger_hotload to compile {className} into the game assembly.",
placedOn != null
? $"{className} was attached to the target GameObject."
: $"Attach it to the CAMERA GameObject: add_component_with_properties (component=\"{className}\") after the hotload, or re-run with targetId.",
$"Fire a shake from any game code: {className}.Shake( 0.4f ) -- explosions ~0.6-1.0, hits ~0.2-0.4, footsteps ~0.05. Trauma stacks and clamps at 1.",
"LOCAL-only: call it inside an [Rpc.Broadcast] handler if every client should feel the shake.",
"Tune MaxOffset / MaxAngle / Frequency / DecayPerSecond with set_property, then verify in play mode: playtest with a capture step, or set_runtime_property Trauma=1 and take_screenshot."
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_camera_shake failed: {ex.Message}" } );
}
}
static string BuildCode( string className, float maxOffset, float maxAngle, float frequency, float decay, System.Globalization.CultureInfo ci )
{
string mo = maxOffset.ToString( ci ) + "f";
string ma = maxAngle.ToString( ci ) + "f";
string fq = frequency.ToString( ci ) + "f";
string dc = decay.ToString( ci ) + "f";
return $@"using Sandbox;
using System;
using System.Collections.Generic;
/// <summary>
/// {className} -- trauma-based camera shake. Attach to the camera GameObject.
///
/// The standard game-feel model: an event adds Trauma (0..1), shake magnitude
/// is Trauma^2 (small hits barely register, big hits slam), offsets are smooth
/// Perlin noise (not white-noise jitter), and Trauma decays every frame.
///
/// Usage from anywhere: {className}.Shake( 0.5f );
/// LOCAL-only -- wrap the call in an [Rpc.Broadcast] if all clients should shake.
/// </summary>
public sealed class {className} : Component
{{
/// Current shake energy, 0..1. Add via Shake(); decays by DecayPerSecond.
[Property] public float Trauma {{ get; set; }}
/// Positional shake at full trauma, in world units.
[Property] public float MaxOffset {{ get; set; }} = {mo};
/// Rotational shake at full trauma, in degrees (pitch/yaw/roll).
[Property] public float MaxAngle {{ get; set; }} = {ma};
/// Noise speed -- higher = more violent rattle, lower = drunken sway.
[Property] public float Frequency {{ get; set; }} = {fq};
/// How much trauma drains per second.
[Property] public float DecayPerSecond {{ get; set; }} = {dc};
private static readonly List<{className}> _active = new List<{className}>();
private Vector3 _lastWrittenPos;
private Rotation _lastWrittenRot;
private Vector3 _appliedOffset;
private Rotation _appliedRot = Rotation.Identity;
private bool _hasApplied;
/// <summary>Add trauma to every active {className} (usually the one on the local camera).</summary>
public static void Shake( float trauma )
{{
foreach ( var s in _active )
s.Trauma = MathX.Clamp( s.Trauma + trauma, 0f, 1f );
}}
protected override void OnEnabled()
{{
_active.Add( this );
}}
protected override void OnDisabled()
{{
_active.Remove( this );
RemoveAppliedShake();
}}
protected override void OnPreRender()
{{
var go = GameObject;
// Recover the unshaken base. If a controller re-wrote the camera since our
// last write, ITS value is the new base and our old offset is already gone --
// only un-apply when the transform still equals exactly what we wrote.
var basePos = go.WorldPosition;
var baseRot = go.WorldRotation;
if ( _hasApplied && basePos == _lastWrittenPos ) basePos -= _appliedOffset;
if ( _hasApplied && baseRot == _lastWrittenRot ) baseRot = baseRot * _appliedRot.Inverse;
_hasApplied = false;
Trauma = MathX.Clamp( Trauma - DecayPerSecond * Time.Delta, 0f, 1f );
float shake = Trauma * Trauma;
if ( shake < 0.0005f )
{{
go.WorldPosition = basePos;
go.WorldRotation = baseRot;
return;
}}
// Smooth signed noise per axis (-1..1), decorrelated by row offset.
float t = Time.Now * Frequency;
float N( float row ) => (Sandbox.Utility.Noise.Perlin( t, row ) - 0.5f) * 2f;
_appliedOffset = new Vector3( N( 0f ), N( 17f ), N( 31f ) ) * (MaxOffset * shake);
_appliedRot = Rotation.From( N( 47f ) * MaxAngle * shake, N( 61f ) * MaxAngle * shake, N( 83f ) * MaxAngle * shake );
go.WorldPosition = basePos + _appliedOffset;
go.WorldRotation = baseRot * _appliedRot;
_lastWrittenPos = go.WorldPosition;
_lastWrittenRot = go.WorldRotation;
_hasApplied = true;
}}
private void RemoveAppliedShake()
{{
if ( !_hasApplied ) return;
var go = GameObject;
if ( go.WorldPosition == _lastWrittenPos ) go.WorldPosition -= _appliedOffset;
if ( go.WorldRotation == _lastWrittenRot ) go.WorldRotation = go.WorldRotation * _appliedRot.Inverse;
_hasApplied = false;
}}
}}
";
}
}
// -----------------------------------------------------------------------------
// add_flicker_light -- generate a light-flicker animator and (optionally) attach
// it to an existing light GameObject. Presets: Candle, Fluorescent, Faulty,
// Pulse, Lightning. Modulates Light.LightColor around a captured base color;
// restores the base on disable.
// -----------------------------------------------------------------------------
public class AddFlickerLightHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
if ( !ScaffoldHelpers.PrepareCodeFile( p, "FlickerLight", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
var style = p.TryGetProperty( "style", out var sv ) && !string.IsNullOrWhiteSpace( sv.GetString() )
? sv.GetString() : "Candle";
// Validate against the generated enum so a typo can't emit uncompilable code.
var validStyles = new[] { "Candle", "Fluorescent", "Faulty", "Pulse", "Lightning" };
var matched = validStyles.FirstOrDefault( s => s.Equals( style, StringComparison.OrdinalIgnoreCase ) );
if ( matched == null )
return Task.FromResult<object>( new { error = $"Unknown style '{style}'. Valid: {string.Join( ", ", validStyles )}" } );
style = matched;
float intensity = p.TryGetProperty( "intensity", out var iv ) && iv.TryGetSingle( out var iff ) ? iff : 0.5f;
float speed = p.TryGetProperty( "speed", out var spv ) && spv.TryGetSingle( out var spf ) ? spf : 1f;
intensity = intensity < 0f ? 0f : intensity > 1f ? 1f : intensity;
if ( speed < 0.05f ) speed = 0.05f;
var code = BuildCode( className, style, intensity, speed, ci );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
// `lightId` is the ergonomic param name; `targetId` also accepted (sibling convention).
string target = null;
if ( p.TryGetProperty( "lightId", out var lid ) && lid.ValueKind == JsonValueKind.String ) target = lid.GetString();
else if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String ) target = tid.GetString();
if ( target != null )
placedOn = GameFeelHelpers.PlaceOnTarget( target, className, out note );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
style,
intensity,
speed,
placedOn,
note,
nextSteps = new[]
{
$"trigger_hotload to compile {className} into the game assembly.",
placedOn != null
? $"{className} was attached to the target GameObject."
: $"Attach it to a GameObject that has a light component (PointLight / SpotLight / DirectionalLight): add_component_with_properties (component=\"{className}\") after the hotload, or re-run with lightId.",
"The animator modulates the light's LightColor around its starting color and restores it on disable -- tune Style / Intensity / Speed with set_property.",
"Verify in play mode: start_play, then take_screenshot twice ~a second apart and compare the light's brightness (or capture_view for a scene-only frame)."
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"add_flicker_light failed: {ex.Message}" } );
}
}
static string BuildCode( string className, string style, float intensity, float speed, System.Globalization.CultureInfo ci )
{
string it = intensity.ToString( ci ) + "f";
string sp = speed.ToString( ci ) + "f";
return $@"using Sandbox;
using System;
using System.Collections.Generic;
/// <summary>
/// {className} -- flickers the light on this GameObject. Attach next to a
/// PointLight / SpotLight / DirectionalLight; it modulates LightColor around
/// the color it found on enable and restores it on disable.
///
/// Styles: Candle (soft organic sway), Fluorescent (mostly steady, random
/// dips), Faulty (hard on/off cuts), Pulse (slow sine breathing), Lightning
/// (dim baseline, rare bright flashes).
/// </summary>
public sealed class {className} : Component
{{
public enum FlickerStyle {{ Candle, Fluorescent, Faulty, Pulse, Lightning }}
[Property] public FlickerStyle Style {{ get; set; }} = FlickerStyle.{style};
/// Flicker depth: 0 = steady, 1 = full blackouts / double-bright flashes.
[Property] public float Intensity {{ get; set; }} = {it};
/// Speed multiplier for the whole pattern.
[Property] public float Speed {{ get; set; }} = {sp};
private Light _light;
private Color _baseColor;
private float _seed;
private float _mult = 1f;
protected override void OnEnabled()
{{
_light = GetComponent<Light>();
if ( _light == null )
{{
Log.Warning( $""{className}: no Light component on {{GameObject.Name}} -- disabling."" );
Enabled = false;
return;
}}
_baseColor = _light.LightColor;
_seed = Game.Random.Float( 0f, 512f );
}}
protected override void OnDisabled()
{{
if ( _light != null ) _light.LightColor = _baseColor;
}}
protected override void OnUpdate()
{{
if ( _light == null ) return;
float t = (Time.Now + _seed) * Speed;
float n = Sandbox.Utility.Noise.Perlin( t * 6f, _seed ); // smooth 0..1
float target = Style switch
{{
FlickerStyle.Candle => MathX.Lerp( 1f - Intensity * 0.6f, 1f, n ),
FlickerStyle.Fluorescent => n > 0.75f ? 1f - Intensity : 1f,
FlickerStyle.Faulty => Sandbox.Utility.Noise.Perlin( t * 14f, _seed ) > 0.55f ? 1f : 1f - Intensity,
FlickerStyle.Pulse => MathX.Lerp( 1f - Intensity, 1f, 0.5f + 0.5f * MathF.Sin( t * 4f ) ),
FlickerStyle.Lightning => n > 0.92f ? 1f + Intensity * 2f : 1f - Intensity * 0.85f,
_ => 1f
}};
// Smooth toward the target so hard styles read as a light, not strobe noise.
_mult = MathX.Lerp( _mult, target, MathX.Clamp( Time.Delta * 24f, 0f, 1f ) );
_light.LightColor = _baseColor * _mult;
}}
}}
";
}
}
// -----------------------------------------------------------------------------
// create_floating_combat_text -- rising/fading world-space text popups
// (damage numbers, "+10 gold", pickup names). TextRenderer-based -- no Razor,
// no WorldPanel, works with zero UI setup. The generated class IS the popup
// behavior and carries a static Spawn() factory; nothing to place in the scene.
// -----------------------------------------------------------------------------
public class CreateFloatingCombatTextHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
if ( !ScaffoldHelpers.PrepareCodeFile( p, "FloatingCombatText", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
float riseSpeed = p.TryGetProperty( "riseSpeed", out var rv ) && rv.TryGetSingle( out var rf ) ? rf : 48f;
float lifetime = p.TryGetProperty( "lifetime", out var lv ) && lv.TryGetSingle( out var lf ) ? lf : 1.1f;
float fontSize = p.TryGetProperty( "fontSize", out var fv ) && fv.TryGetSingle( out var ff ) ? ff : 24f;
if ( riseSpeed < 0f ) riseSpeed = 0f;
if ( lifetime < 0.1f ) lifetime = 0.1f;
if ( fontSize < 1f ) fontSize = 1f;
var code = BuildCode( className, riseSpeed, lifetime, fontSize, ci );
ScaffoldHelpers.WriteCode( fullPath, code );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
riseSpeed,
lifetime,
fontSize,
nextSteps = new[]
{
$"trigger_hotload to compile {className} into the game assembly.",
$"Nothing to place -- spawn popups from any game code: {className}.Spawn( hitPosition + Vector3.Up * 32f, \"-25\", Color.Red ) (optional 4th arg scales the text).",
$"Pairs with create_health_system: call {className}.Spawn from the damage path so every hit prints its number.",
"LOCAL-only: spawn inside an [Rpc.Broadcast] handler if every client should see the popup.",
"Verify in play mode: execute a spawn (e.g. via invoke_method on a test component), then take_screenshot -- the text rises and fades over Lifetime seconds."
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_floating_combat_text failed: {ex.Message}" } );
}
}
static string BuildCode( string className, float riseSpeed, float lifetime, float fontSize, System.Globalization.CultureInfo ci )
{
string rs = riseSpeed.ToString( ci ) + "f";
string lt = lifetime.ToString( ci ) + "f";
string fs = fontSize.ToString( ci ) + "f";
return $@"using Sandbox;
using System;
using System.Collections.Generic;
/// <summary>
/// {className} -- a rising, fading world-space text popup (damage numbers,
/// ""+10 gold"", pickup names). TextRenderer-based: no Razor, no panels.
///
/// Spawn from anywhere:
/// {className}.Spawn( position, ""-25"", Color.Red );
/// {className}.Spawn( position, ""+10 gold"", Color.Yellow, 1.5f );
///
/// The popup billboards to the camera, rises, fades out, and destroys itself.
/// LOCAL-only -- spawn inside an [Rpc.Broadcast] if all clients should see it.
/// </summary>
public sealed class {className} : Component
{{
/// World units risen per second.
[Property] public float RiseSpeed {{ get; set; }} = {rs};
/// Seconds until fully faded and destroyed.
[Property] public float Lifetime {{ get; set; }} = {lt};
private TextRenderer _text;
private Color _startColor;
private TimeSince _age;
/// <summary>Spawn a popup at a world position. Returns the popup GameObject.</summary>
public static GameObject Spawn( Vector3 position, string text, Color color, float size = 1f )
{{
var go = new GameObject( true, ""FloatingText"" );
go.WorldPosition = position;
var tr = go.AddComponent<TextRenderer>();
tr.Text = text;
tr.Color = color;
tr.FontSize = {fs} * size;
go.AddComponent<{className}>();
return go;
}}
protected override void OnStart()
{{
_age = 0f;
_text = GetComponent<TextRenderer>();
if ( _text != null ) _startColor = _text.Color;
}}
protected override void OnUpdate()
{{
var go = GameObject;
go.WorldPosition += Vector3.Up * (RiseSpeed * Time.Delta);
// Billboard: face the same way the camera faces, mirrored toward it.
var cam = Scene?.Camera;
if ( cam != null )
go.WorldRotation = Rotation.LookAt( -cam.WorldRotation.Forward );
if ( _text != null )
_text.Color = _startColor.WithAlpha( _startColor.a * MathX.Clamp( 1f - _age / Lifetime, 0f, 1f ) );
if ( _age >= Lifetime )
go.Destroy();
}}
}}
";
}
}
/// <summary>
/// Shared placement helper for the game-feel handlers -- mirrors the standard
/// scaffold placement (create_weighted_loot_table / create_event_director).
/// </summary>
internal static class GameFeelHelpers
{
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; }
}
}
Editor
library
// AUTO-GENERATED by scripts/emit-mcp-wrappers.mjs β DO NOT EDIT.
// Regenerate: node scripts/extract-manifest.mjs && node scripts/emit-mcp-wrappers.mjs
// Source of truth: sbox-mcp-server/src/tools/ (zod schemas) β scripts/tools-manifest.json
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Editor.Mcp;
/// <summary>
/// GameObject lifecycle, hierarchy, transforms, tags, selection, and bulk layout (align,
/// distribute, scatter, snap-to-ground, grid duplicate) in the open scene. Objects are referenced
/// by GUID from get_scene_hierarchy or find_objects.
/// </summary>
[McpToolset( "bridge_gameobject", "GameObject lifecycle, hierarchy, transforms, tags, selection, and bulk layout (align, distribute, scatter, snap-to-ground, grid duplicate) in the open scene. Objects are referenced by GUID from get_scene_hierarchy or find_objects." )]
public static class BridgeGameObjectTools
{
/// <summary>
/// Align several GameObjects on one axis so they share a coordinate. mode = first (match the first
/// object), min, max, or average; defaults to first. Returns { aligned, axis, mode, target } β
/// aligned is the object count and target the shared coordinate; verify positions with
/// get_scene_hierarchy or a screenshot.
/// </summary>
/// <param name="ids">GUIDs of the GameObjects to align (>= 2).</param>
/// <param name="axis">Axis to align on. One of: x | y | z.</param>
/// <param name="mode">Target coordinate to align to (default first). One of: first | min | max | average.</param>
[McpTool( "align_objects" )]
public static Task<object> AlignObjects( string[] ids, string axis, string mode = null )
=> McpGate.Run( "align_objects", McpGate.Args( ( "ids", ids ), ( "axis", axis ), ( "mode", mode ) ) );
/// <summary>
/// Commit a dry-run plan returned by place_along_path, grid_duplicate, or scatter_props. Plans are
/// scene-scoped, capped, and expire after 10 minutes. Success consumes the plan; a complete
/// rollback restores it for retry. The stored transforms are applied without rerolling randomness
/// or repeating ground traces. Creation rolls back on failure; grid commits reject a
/// changed/missing source before creating anything. Returns slot-to-GUID receipts.
/// </summary>
/// <param name="planId">Plan id returned by a placement tool with dryRun:true.</param>
[McpTool( "commit_placement_plan" )]
public static Task<object> CommitPlacementPlan( string planId )
=> McpGate.Run( "commit_placement_plan", McpGate.Args( ( "planId", planId ) ) );
/// <summary>
/// Create a new GameObject in the active scene. Returns its GUID for future reference.
/// </summary>
/// <param name="name">Display name (e.g. 'Player', 'Enemy Spawn Point'). Defaults to 'New Object'.</param>
/// <param name="position">World position. As "x,y,z" (or JSON {x,y,z}).</param>
/// <param name="rotation">World rotation. As "pitch,yaw,roll" degrees.</param>
/// <param name="scale">Uniform scale (number) or per-axis scale β object {x,y,z} or comma string "x,y,z". As "x,y,z" (or JSON {x,y,z}).</param>
/// <param name="parent">GUID of parent GameObject. Omit for scene root.</param>
[McpTool( "create_gameobject" )]
public static Task<object> CreateGameobject( string name = null, string position = null, string rotation = null, string scale = null, string parent = null )
=> McpGate.Run( "create_gameobject", McpGate.Args( ( "name", name ), ( "position", position ), ( "rotation", rotation ), ( "scale", scale ), ( "parent", parent ) ) );
/// <summary>
/// Delete a GameObject from the active scene by its GUID.
/// </summary>
/// <param name="id">GUID of the GameObject to delete.</param>
[McpTool( "delete_gameobject" )]
public static Task<object> DeleteGameobject( string id )
=> McpGate.Run( "delete_gameobject", McpGate.Args( ( "id", id ) ) );
/// <summary>
/// Evenly space GameObjects along an axis between the lowest and highest (keeps the two ends fixed,
/// spreads the rest evenly). Returns { distributed, axis, from, to } β the object count and the
/// fixed end coordinates the rest were spread between.
/// </summary>
/// <param name="ids">GUIDs of the GameObjects to distribute (>= 3).</param>
/// <param name="axis">Axis to distribute along. One of: x | y | z.</param>
[McpTool( "distribute_objects" )]
public static Task<object> DistributeObjects( string[] ids, string axis )
=> McpGate.Run( "distribute_objects", McpGate.Args( ( "ids", ids ), ( "axis", axis ) ) );
/// <summary>
/// Clone a GameObject with all its components. Returns { duplicated, original, gameObject } β
/// gameObject.id is the clone's new GUID; pass it to set_transform / add_component_with_properties.
/// If offset is omitted the clone lands exactly on top of the original.
/// </summary>
/// <param name="id">GUID of the GameObject to duplicate.</param>
/// <param name="name">New name for the clone.</param>
/// <param name="offset">Position offset from original so the clone doesn't overlap. As "x,y,z" (or JSON {x,y,z}).</param>
[McpTool( "duplicate_gameobject" )]
public static Task<object> DuplicateGameobject( string id, string name = null, string offset = null )
=> McpGate.Run( "duplicate_gameobject", McpGate.Args( ( "id", id ), ( "name", name ), ( "offset", offset ) ) );
/// <summary>
/// Query the scene for GameObjects by name (case-insensitive substring), component type name,
/// and/or tag β combine filters (AND). Returns {id,name} for matches (limit default 50, max 500).
/// Read-only; works during play. Use it to get GUIDs to feed into
/// align/distribute/set_tint/group/delete/etc.
/// </summary>
/// <param name="name">Name substring (case-insensitive).</param>
/// <param name="component">Component type name, e.g. 'PointLight', 'SkinnedModelRenderer'.</param>
/// <param name="tag">Tag the object must have.</param>
/// <param name="limit">Max results (default 50, max 500).</param>
[McpTool.ReadOnly( "find_objects" )]
public static Task<object> FindObjects( string name = null, string component = null, string tag = null, int? limit = null )
=> McpGate.Run( "find_objects", McpGate.Args( ( "name", name ), ( "component", component ), ( "tag", tag ), ( "limit", limit ) ) );
/// <summary>
/// Find GameObjects within a world-space radius of exactly one explicit position or originId,
/// sorted nearest first. Optional name/component/tag filters are applied before the capped result,
/// and the Scene root is excluded. Returns pivot-distance results plus
/// requestedRadius/radiusClamped and total/showing/truncated/scanned; it deliberately does not
/// pretend render or collider overlap is pivot distance. Read-only and play-aware.
/// </summary>
/// <param name="position">World-space search center; use instead of originId. As "x,y,z" (or JSON {x,y,z}).</param>
/// <param name="originId">GameObject GUID whose world position is the center.</param>
/// <param name="radius">Search radius in world units (default 256, max 1,000,000).</param>
/// <param name="limit">Maximum results (default 50).</param>
/// <param name="name">Case-insensitive GameObject name substring.</param>
/// <param name="component">Required component type name.</param>
/// <param name="tag">Required GameObject tag.</param>
/// <param name="includeOrigin">Include originId itself (default false).</param>
[McpTool.ReadOnly( "find_objects_near" )]
public static Task<object> FindObjectsNear( string position = null, string originId = null, double? radius = null, int? limit = null, string name = null, string component = null, string tag = null, bool? includeOrigin = null )
=> McpGate.Run( "find_objects_near", McpGate.Args( ( "position", position ), ( "originId", originId ), ( "radius", radius ), ( "limit", limit ), ( "name", name ), ( "component", component ), ( "tag", tag ), ( "includeOrigin", includeOrigin ) ) );
/// <summary>
/// Highlight a GameObject by selecting it in the editor. NOTE: s&box exposes no dedicated focus
/// API, so this only sets the selection β it does NOT move any camera (returns { focused, id, note
/// } saying so). To actually point the viewport at an object use frame_camera; to aim a screenshot
/// use screenshot_from.
/// </summary>
/// <param name="id">GUID of the GameObject to focus.</param>
[McpTool( "focus_object" )]
public static Task<object> FocusObject( string id )
=> McpGate.Run( "focus_object", McpGate.Args( ( "id", id ) ) );
/// <summary>
/// Get provenance-rich world bounds for a GameObject. Preserves legacy top-level
/// center/size/extents/mins/maxs/radius/position/empty for compatibility, and adds render plus
/// independent physics and solidPhysics aggregates. Collider outputs include trigger policy, capped
/// contributor GameObject IDs and component type names, unsupported counts, and the exact API
/// source. Read-only and play-aware.
/// </summary>
/// <param name="id">GUID of the GameObject to measure.</param>
[McpTool.ReadOnly( "get_bounds" )]
public static Task<object> GetBounds( string id )
=> McpGate.Run( "get_bounds", McpGate.Args( ( "id", id ) ) );
/// <summary>
/// Get the scene tree β GameObjects with their names, GUIDs, components, and parent/child
/// relationships. Pair maxDepth with rootId to drill into a subtree without paying for the whole
/// scene.
/// </summary>
/// <param name="maxDepth">Maximum recursion depth. Defaults to 10. Use 1 or 2 for cheap top-level overviews.</param>
/// <param name="rootId">Optional GUID of a GameObject to start traversal from. Omit to walk from the scene roots.</param>
[McpTool.ReadOnly( "get_scene_hierarchy" )]
public static Task<object> GetSceneHierarchy( int? maxDepth = null, string rootId = null )
=> McpGate.Run( "get_scene_hierarchy", McpGate.Args( ( "maxDepth", maxDepth ), ( "rootId", rootId ) ) );
/// <summary>
/// Get the GameObjects currently selected by the user in the s&box editor. Returns { count,
/// selected } where each entry is a serialized GameObject (id, name, enabled, position, rotation,
/// scale, components, childCount) β use the ids with set_transform, add_component_with_properties,
/// etc. Handy for 'do X to what I have selected' requests.
/// </summary>
[McpTool.ReadOnly( "get_selected_objects" )]
public static Task<object> GetSelectedObjects()
=> McpGate.Run( "get_selected_objects", McpGate.Args() );
/// <summary>
/// Read the tags currently on a GameObject. (Pair with set_tags to add/remove/clear, and
/// find_objects to query by tag.).
/// </summary>
/// <param name="id">GUID of the GameObject.</param>
[McpTool.ReadOnly( "get_tags" )]
public static Task<object> GetTags( string id )
=> McpGate.Run( "get_tags", McpGate.Args( ( "id", id ) ) );
/// <summary>
/// Clone a GameObject into an X/Y/Z grid. Existing calls mutate immediately and keep the legacy
/// result. Use dryRun:true to preview exact capped transforms and receive a planId;
/// commit_placement_plan then clones atomically and rejects the commit if the source transform or
/// parent changed after preview.
/// </summary>
/// <param name="id">GUID of the GameObject to clone.</param>
/// <param name="countX">Copies along X (default 1).</param>
/// <param name="countY">Copies along Y (default 1).</param>
/// <param name="countZ">Copies along Z (default 1).</param>
/// <param name="spacing">Spacing between copies per axis (default 100,100,100). As "x,y,z" (or JSON {x,y,z}).</param>
/// <param name="dryRun">Preview only: return deterministic transforms and planId without cloning.</param>
[McpTool( "grid_duplicate" )]
public static Task<object> GridDuplicate( string id, int? countX = null, int? countY = null, int? countZ = null, string spacing = null, bool? dryRun = null )
=> McpGate.Run( "grid_duplicate", McpGate.Args( ( "id", id ), ( "countX", countX ), ( "countY", countY ), ( "countZ", countZ ), ( "spacing", spacing ), ( "dryRun", dryRun ) ) );
/// <summary>
/// Parent a set of GameObjects under a new empty group object (placed at their centroid) β tidies
/// the hierarchy and lets you move/rotate them together.
/// </summary>
/// <param name="ids">GUIDs of the GameObjects to group.</param>
/// <param name="name">Name for the group object (default 'Group').</param>
[McpTool( "group_objects" )]
public static Task<object> GroupObjects( string[] ids, string name = null )
=> McpGate.Run( "group_objects", McpGate.Args( ( "ids", ids ), ( "name", name ) ) );
/// <summary>
/// Measure the distance between two points or two GameObjects. Provide a/b as {x,y,z} or idA/idB as
/// GUIDs. Returns straight-line distance, horizontal (ground) distance, and the delta vector.
/// Read-only (works during play).
/// </summary>
/// <param name="a">First point {x,y,z}. As "x,y,z" (or JSON {x,y,z}).</param>
/// <param name="b">Second point {x,y,z}. As "x,y,z" (or JSON {x,y,z}).</param>
/// <param name="idA">First GameObject GUID (overrides a).</param>
/// <param name="idB">Second GameObject GUID (overrides b).</param>
[McpTool.ReadOnly( "measure_distance" )]
public static Task<object> MeasureDistance( string a = null, string b = null, string idA = null, string idB = null )
=> McpGate.Run( "measure_distance", McpGate.Args( ( "a", a ), ( "b", b ), ( "idA", idA ), ( "idB", idB ) ) );
/// <summary>
/// Add natural variation to existing objects: random yaw and/or random uniform scale within a
/// range. Great for breaking up repetition in placed foliage/rocks/crates. Seeded β the same seed
/// reproduces the same layout. Returns { randomized, seed } (the count of objects changed); scale
/// only varies when scaleMax > scaleMin.
/// </summary>
/// <param name="ids">GUIDs of the GameObjects to randomize.</param>
/// <param name="randomYaw">Randomize Z rotation (default true).</param>
/// <param name="scaleMin">Min uniform scale (default 1).</param>
/// <param name="scaleMax">Max uniform scale (default 1; set >min to vary).</param>
/// <param name="seed">PRNG seed (default 1).</param>
[McpTool( "randomize_transforms" )]
public static Task<object> RandomizeTransforms( string[] ids, bool? randomYaw = null, double? scaleMin = null, double? scaleMax = null, int? seed = null )
=> McpGate.Run( "randomize_transforms", McpGate.Args( ( "ids", ids ), ( "randomYaw", randomYaw ), ( "scaleMin", scaleMin ), ( "scaleMax", scaleMax ), ( "seed", seed ) ) );
/// <summary>
/// Change the display name of a GameObject identified by its GUID (the GUID itself never changes,
/// so existing references stay valid). Returns { renamed, id, oldName, newName }. Name-based
/// lookups (e.g. find_objects) will see the new name immediately.
/// </summary>
/// <param name="id">GUID of the GameObject.</param>
/// <param name="name">New display name.</param>
[McpTool( "rename_gameobject" )]
public static Task<object> RenameGameobject( string id, string name )
=> McpGate.Run( "rename_gameobject", McpGate.Args( ( "id", id ), ( "name", name ) ) );
/// <summary>
/// Swap the model on one object (id) or many (ids) β e.g. retheme a row of props in one call.
/// </summary>
/// <param name="model">New model path, e.g. 'models/dev/sphere.vmdl'.</param>
/// <param name="id">Single GameObject GUID.</param>
/// <param name="ids">Multiple GameObject GUIDs.</param>
[McpTool( "replace_model" )]
public static Task<object> ReplaceModel( string model, string id = null, string[] ids = null )
=> McpGate.Run( "replace_model", McpGate.Args( ( "model", model ), ( "id", id ), ( "ids", ids ) ) );
/// <summary>
/// Scatter seeded model copies inside a radius. Existing calls mutate immediately and keep the
/// legacy { scattered, groupId, seed } result. Use dryRun:true to resolve random transforms and
/// ground traces once, returning per-slot transforms, ground status, warnings, model bounds, and
/// planId; commit_placement_plan creates exactly that preview with rollback on failure.
/// </summary>
/// <param name="model">Model path to scatter, e.g. 'models/dev/box.vmdl'.</param>
/// <param name="center">Centre of the scatter area (default origin). As "x,y,z" (or JSON {x,y,z}).</param>
/// <param name="radius">Scatter radius in units (default 256).</param>
/// <param name="count">How many to place (default 10, max 300).</param>
/// <param name="randomYaw">Randomly rotate each around Z (default true).</param>
/// <param name="snapToGround">Raycast each onto the surface below (default true).</param>
/// <param name="scaleMin">Min uniform scale (default 1).</param>
/// <param name="scaleMax">Max uniform scale (default 1; set >min for size variation).</param>
/// <param name="tint">Tint applied to every copy β object {r,g,b,a} or comma string "r,g,b,a". As "r,g,b[,a]" (0-1 floats).</param>
/// <param name="seed">PRNG seed for a reproducible layout (default 1).</param>
/// <param name="group">Parent all copies under one group object (default true).</param>
/// <param name="name">Base name for the props/group (default 'Prop').</param>
/// <param name="dryRun">Preview only: return deterministic transforms and planId without creating props.</param>
[McpTool( "scatter_props" )]
public static Task<object> ScatterProps( string model, string center = null, double? radius = null, int? count = null, bool? randomYaw = null, bool? snapToGround = null, double? scaleMin = null, double? scaleMax = null, string tint = null, int? seed = null, bool? group = null, string name = null, bool? dryRun = null )
=> McpGate.Run( "scatter_props", McpGate.Args( ( "model", model ), ( "center", center ), ( "radius", radius ), ( "count", count ), ( "randomYaw", randomYaw ), ( "snapToGround", snapToGround ), ( "scaleMin", scaleMin ), ( "scaleMax", scaleMax ), ( "tint", tint ), ( "seed", seed ), ( "group", group ), ( "name", name ), ( "dryRun", dryRun ) ) );
/// <summary>
/// Select a GameObject in the editor (highlights it in the hierarchy and scene view). Replaces the
/// current selection unless addToSelection=true. Returns { selected, id }; confirm the result with
/// get_selected_objects.
/// </summary>
/// <param name="id">GUID of the GameObject to select.</param>
/// <param name="addToSelection">If true, adds to current selection instead of replacing it.</param>
[McpTool( "select_object" )]
public static Task<object> SelectObject( string id, bool? addToSelection = null )
=> McpGate.Run( "select_object", McpGate.Args( ( "id", id ), ( "addToSelection", addToSelection ) ) );
/// <summary>
/// Enable or disable a GameObject (disabled objects are invisible and inactive, including their
/// components and children). Returns { id, enabled } confirming the new state.
/// </summary>
/// <param name="id">GUID of the GameObject.</param>
/// <param name="enabled">true to enable, false to disable.</param>
[McpTool( "set_enabled" )]
public static Task<object> SetEnabled( string id, bool enabled )
=> McpGate.Run( "set_enabled", McpGate.Args( ( "id", id ), ( "enabled", enabled ) ) );
/// <summary>
/// Reparent a GameObject. Set parentId to null or omit to move to scene root.
/// </summary>
/// <param name="id">GUID of the GameObject to reparent.</param>
/// <param name="parentId">GUID of the new parent. Null or omitted = scene root.</param>
[McpTool( "set_parent" )]
public static Task<object> SetParent( string id, string parentId = null )
=> McpGate.Run( "set_parent", McpGate.Args( ( "id", id ), ( "parentId", parentId ) ) );
/// <summary>
/// Add, remove, and/or clear gameplay tags on one object (id) or many (ids). Tags drive collision
/// groups, queries, and triggers.
/// </summary>
/// <param name="id">Single GameObject GUID.</param>
/// <param name="ids">Multiple GameObject GUIDs.</param>
/// <param name="add">Tags to add.</param>
/// <param name="remove">Tags to remove.</param>
/// <param name="clear">Remove all existing tags first.</param>
[McpTool( "set_tags" )]
public static Task<object> SetTags( string id = null, string[] ids = null, string[] add = null, string[] remove = null, bool? clear = null )
=> McpGate.Run( "set_tags", McpGate.Args( ( "id", id ), ( "ids", ids ), ( "add", add ), ( "remove", remove ), ( "clear", clear ) ) );
/// <summary>
/// Set the renderer tint colour on one object (id) or many (ids) at once. Works on any
/// ModelRenderer/SkinnedModelRenderer. Pass the colour as "tint" (or its alias "color"); each
/// accepts an object {r,g,b,a} OR a comma string "r,g,b,a".
/// </summary>
/// <param name="id">Single GameObject GUID.</param>
/// <param name="ids">Multiple GameObject GUIDs.</param>
/// <param name="tint">Tint colour to apply (object or comma string). As "r,g,b[,a]" (0-1 floats).</param>
/// <param name="color">Alias for "tint" (object or comma string). As "r,g,b[,a]" (0-1 floats).</param>
[McpTool( "set_tint" )]
public static Task<object> SetTint( string id = null, string[] ids = null, string tint = null, string color = null )
=> McpGate.Run( "set_tint", McpGate.Args( ( "id", id ), ( "ids", ids ), ( "tint", tint ), ( "color", color ) ) );
/// <summary>
/// Atomically set position, rotation, and/or scale on a GameObject. All supplied values are parsed
/// before mutation; values apply in world space by default. Prefer space='local' or space='world';
/// local remains a legacy alias. Returns legacy { transformed, gameObject } plus before/after
/// transform and bounds receipts.
/// </summary>
/// <param name="id">GUID of the GameObject.</param>
/// <param name="position">New position. As "x,y,z" (or JSON {x,y,z}).</param>
/// <param name="rotation">New rotation. As "pitch,yaw,roll" degrees.</param>
/// <param name="scale">New scale β uniform number, per-axis object {x,y,z}, or comma string "x,y,z". As "x,y,z" (or JSON {x,y,z}).</param>
/// <param name="local">Legacy alias: true selects local space and false selects world space.</param>
/// <param name="space">Explicit transform space. If supplied with local, both values must agree. One of: world | local.</param>
[McpTool( "set_transform" )]
public static Task<object> SetTransform( string id, string position = null, string rotation = null, string scale = null, bool? local = null, string space = null )
=> McpGate.Run( "set_transform", McpGate.Args( ( "id", id ), ( "position", position ), ( "rotation", rotation ), ( "scale", scale ), ( "local", local ), ( "space", space ) ) );
/// <summary>
/// Drop a GameObject straight down onto the surface below it (physics raycast). Works best on
/// collider-less props (an object with its own collider may self-hit). Optional offset lifts it off
/// the surface. Returns { snapped, groundZ, gameObject } with the object's updated transform β or {
/// snapped: false, reason } (not an error) when no ground was hit below.
/// </summary>
/// <param name="id">GUID of the GameObject to snap.</param>
/// <param name="offset">Height above the surface to place it (default 0).</param>
/// <param name="startHeight">How far above the object to start the trace (default 2000).</param>
/// <param name="maxDistance">Max trace distance downward (default 20000).</param>
[McpTool( "snap_to_ground" )]
public static Task<object> SnapToGround( string id, double? offset = null, double? startHeight = null, double? maxDistance = null )
=> McpGate.Run( "snap_to_ground", McpGate.Args( ( "id", id ), ( "offset", offset ), ( "startHeight", startHeight ), ( "maxDistance", maxDistance ) ) );
}
Editor
library
// AUTO-GENERATED by scripts/emit-mcp-wrappers.mjs β DO NOT EDIT.
// Regenerate: node scripts/extract-manifest.mjs && node scripts/emit-mcp-wrappers.mjs
// Source of truth: sbox-mcp-server/src/tools/ (zod schemas) β scripts/tools-manifest.json
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Editor.Mcp;
/// <summary>
/// Wire and control Sandbox.MovieMaker cutscene playback: list .movie clips, add MoviePlayer
/// components, play and stop clips.
/// </summary>
[McpToolset( "bridge_moviemaker", "Wire and control Sandbox.MovieMaker cutscene playback: list .movie clips, add MoviePlayer components, play and stop clips." )]
public static class BridgeMovieMakerTools
{
/// <summary>
/// Add a Sandbox.MovieMaker.MoviePlayer component and optionally wire a .movie resource into it β
/// the cutscene playback primitive. Creates a new 'Movie Player' GameObject when no id is given.
/// Set playOnStart to begin playback the moment play mode starts (intro cinematics), or leave it
/// and trigger via play_movie (scripted cutscenes β call it from a trigger zone or dialogue beat).
/// isLooping + timeScale map straight onto the component. Movies must already exist as .movie
/// assets (list_movies; author in the Movie Maker dock). Scene-mutating β refused during play mode.
/// </summary>
/// <param name="id">GameObject GUID to attach to. Omit to create a new 'Movie Player' object.</param>
/// <param name="moviePath">Asset-relative path of the .movie resource to wire (see list_movies).</param>
/// <param name="isLooping">Loop playback.</param>
/// <param name="timeScale">Playback speed multiplier (1 = normal).</param>
/// <param name="createTargets">Let the player create missing track-target objects on play.</param>
/// <param name="playOnStart">Begin playing as soon as play mode starts (intro cinematic).</param>
[McpTool( "add_movie_player" )]
public static Task<object> AddMoviePlayer( string id = null, string moviePath = null, bool? isLooping = null, double? timeScale = null, bool? createTargets = null, bool? playOnStart = null )
=> McpGate.Run( "add_movie_player", McpGate.Args( ( "id", id ), ( "moviePath", moviePath ), ( "isLooping", isLooping ), ( "timeScale", timeScale ), ( "createTargets", createTargets ), ( "playOnStart", playOnStart ) ) );
/// <summary>
/// Author a MovieMaker .movie cutscene clip from a declarative shot list β EDIT MODE ONLY, no Movie
/// Maker dock, no play mode, no real-time waiting (a 30s clip bakes in one call, typically <1s).
/// Builds a hold+blend keyframe timeline from the shots (smoothstep ease by default), steps a
/// camera through it, and hand-pumps MovieRecorder Advance/Capture per synthetic frame, then saves
/// Assets/<folder>/<clipName>.movie (registered + compiled; errors if the file exists β
/// the scene itself is NOT saved). Returns { authored, path, name, durationSeconds, frames,
/// sampleRate, shots, tracks, bakeMs, compiled, loadable, camera, nextSteps }. Camera: omit
/// cameraId for a temp camera (destroyed after the bake β play back with add_movie_player
/// createTargets:true so the missing target is recreated), or pass cameraId of an existing camera
/// GameObject (transform + FOV restored EXACTLY afterwards; the clip then animates THAT object on
/// playback). fovDegrees is baked for real (the clip carries a FieldOfView track). Authored clips
/// animate ONLY the camera the bake moves β other scene objects don't move in edit mode (that's
/// what record_gameplay_clip is for). Total timeline capped at 120s, max 32 shots. Errors during
/// play mode (stop_play first). Verify with list_movies; play via add_movie_player + play_movie in
/// play mode.
/// </summary>
/// <param name="shots">The shot list in order (1-32 shots). Timeline = holdβ, then blendα΅’ + holdα΅’ per following shot. JSON array.</param>
/// <param name="clipName">Asset name without extension (default authored_<UTC timestamp>; sanitized to [A-Za-z0-9_-]).</param>
/// <param name="folder">Assets subfolder to save into (default "movies").</param>
/// <param name="sampleRate">Clip samples per second (default 30, clamped 1-120).</param>
/// <param name="cameraId">GUID of an existing camera GameObject (must have a CameraComponent) to bake through β restored EXACTLY afterwards, and playback then animates that object. Omit for a temp camera that is destroyed after the bake.</param>
[McpTool( "author_movie_clip" )]
public static Task<object> AuthorMovieClip( JsonNode shots, string clipName = null, string folder = null, int? sampleRate = null, string cameraId = null )
=> McpGate.Run( "author_movie_clip", McpGate.Args( ( "shots", shots ), ( "clipName", clipName ), ( "folder", folder ), ( "sampleRate", sampleRate ), ( "cameraId", cameraId ) ) );
/// <summary>
/// Generate a sealed killcam Component: a rolling-buffer MovieRecorder keeps ONLY the last
/// MaxBufferSeconds of a target's gameplay (BufferDuration verified live: the compiled clip's
/// Duration equals the buffer, re-based to 0), and TriggerReplay() plays that history back through
/// a MoviePlayer while the main camera chase-follows the target (Scene.Camera takeover in
/// OnPreRender, restored exactly afterwards; static OnReplayFinished event + IsReplaying flag).
/// Sandbox-safe: live-verified that GAME code can construct and drive MovieRecorder/MoviePlayer at
/// runtime, and killcams/replays are the official recording-api use case β this is the real
/// MovieMaker path, not a transform-history approximation. The replay REWINDS THE LIVE TARGET
/// through its recorded past (classic killcam β the target is dead/inactive when it runs; disable a
/// still-alive controller for the duration). wholeScene:true makes the generated component default
/// to MovieRecorderOptions.Default (all renderers/cameras/sound points/particles β the replay
/// rewinds everything, killer included; heavy in dense scenes), and it stays toggleable
/// per-instance via the RecordWholeScene property. Returns { created, path, className,
/// bufferSeconds, sampleRate, cameraDistance, cameraHeight, nextSteps }. Then: trigger_hotload β
/// attach to a MANAGER object β set_component_reference Target to the player β arm via WatchOnStart
/// or StartWatching() from spawn code β call TriggerReplay() from death code (pairs with
/// create_health_system). LOCAL/visual-only β wrap in an [Rpc.Broadcast] for all clients. Refuses
/// if the file already exists.
/// </summary>
/// <param name="name">Component class/file name (default "Killcam").</param>
/// <param name="directory">Project folder for the .cs file (default "Code").</param>
/// <param name="bufferSeconds">Rolling-buffer length in seconds β the replay shows at most this much history (default 10, clamped 2-120).</param>
/// <param name="sampleRate">Recorder samples per second (default 30, clamped 1-120).</param>
/// <param name="cameraDistance">Replay chase-camera distance behind the target (default 150, clamped 10-2000).</param>
/// <param name="cameraHeight">Replay chase-camera height above the target (default 60, clamped 0-2000).</param>
/// <param name="wholeScene">Generated default for RecordWholeScene: true = buffer the WHOLE scene via MovieRecorderOptions.Default (replay rewinds everything; heavy in dense scenes), false = only the Target hierarchy (default).</param>
[McpTool( "create_killcam" )]
public static Task<object> CreateKillcam( string name = null, string directory = null, double? bufferSeconds = null, int? sampleRate = null, double? cameraDistance = null, double? cameraHeight = null, bool? wholeScene = null )
=> McpGate.Run( "create_killcam", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "bufferSeconds", bufferSeconds ), ( "sampleRate", sampleRate ), ( "cameraDistance", cameraDistance ), ( "cameraHeight", cameraHeight ), ( "wholeScene", wholeScene ) ) );
/// <summary>
/// Poll the gameplay recording job. While recording returns { recording:true, jobId, elapsedSeconds
/// (clip-timeline seconds), framesWithData, maxSeconds, sampleRate, capture, trackedObjectCount }
/// (trackedObjectCount is -1 for whole-scene capture). After an auto-stop (maxSeconds cap / play
/// mode ended) returns { stopped:true, pendingSave:true, reason } β the clip is in memory awaiting
/// stop_gameplay_recording. After a save/discard returns that last summary (assetPath etc.).
/// Read-only; works during play. No params.
/// </summary>
[McpTool( "gameplay_recording_status" )]
public static Task<object> GameplayRecordingStatus()
=> McpGate.Run( "gameplay_recording_status", McpGate.Args() );
/// <summary>
/// List the project's .movie resources (Sandbox.MovieMaker clips authored in the editor's Movie
/// Maker dock: Window β Movie Maker). Scans the ENTIRE Assets folder recursively and returns every
/// .movie found β no limit or paging. Returns { count, movies, note } where each movie has { path
/// (asset-relative β the form add_movie_player/play_movie expect), name, loadable (resolves via
/// ResourceLibrary), hasCompiledClip }. Start here before add_movie_player / play_movie β if the
/// list is empty, the movie has to be authored in the dock first (the bridge plays movies; it
/// doesn't author keyframes).
/// </summary>
[McpTool.ReadOnly( "list_movies" )]
public static Task<object> ListMovies()
=> McpGate.Run( "list_movies", McpGate.Args() );
/// <summary>
/// Start MoviePlayer playback. Targets the MoviePlayer on the given GameObject, or the first
/// MoviePlayer in the scene when id is omitted. Pass moviePath to load-and-play a different .movie
/// on the same player; positionSeconds seeks before playing; isLooping/timeScale apply immediately.
/// Clips genuinely advance in PLAY MODE (start_play first, then verify with capture_view) β in edit
/// mode this only sets state, which the response calls out. NOT scene-mutating, so it works during
/// play mode.
/// </summary>
/// <param name="id">GameObject GUID holding the MoviePlayer. Omit to use the first MoviePlayer in the scene.</param>
/// <param name="moviePath">Asset-relative .movie path to load and play (otherwise plays the wired Resource).</param>
/// <param name="positionSeconds">Seek to this time (seconds) before playing.</param>
/// <param name="timeScale">Playback speed multiplier (1 = normal).</param>
/// <param name="isLooping">Loop playback.</param>
[McpTool( "play_movie" )]
public static Task<object> PlayMovie( string id = null, string moviePath = null, double? positionSeconds = null, double? timeScale = null, bool? isLooping = null )
=> McpGate.Run( "play_movie", McpGate.Args( ( "id", id ), ( "moviePath", moviePath ), ( "positionSeconds", positionSeconds ), ( "timeScale", timeScale ), ( "isLooping", isLooping ) ) );
/// <summary>
/// Start recording live play-mode gameplay into a Sandbox.MovieMaker clip β REQUIRES play mode
/// (start_play first; errors otherwise). Captures the given GameObjects (ids β recommended: small
/// focused clips) or, when ids is omitted, the WHOLE scene (heavy: every object becomes tracks).
/// Returns { started, jobId, sampleRate, maxSeconds, capture, discarded, note } immediately;
/// recording runs ASYNC in the editor frame loop until stop_gameplay_recording or the maxSeconds
/// safety cap (default 60s of clip time, max 600s). Only one recording at a time (a second call
/// errors while active; a stopped-but-unsaved clip is discarded by a new start, reported in
/// 'discarded'). Combine with playtest or drive_player to record a SCRIPTED run, then
/// stop_gameplay_recording to save the .movie and play_movie to replay it.
/// </summary>
/// <param name="ids">GameObject GUIDs to capture (from get_scene_hierarchy WHILE PLAYING β play-mode ids can differ from editor ids). Omit to capture the whole scene (heavy).</param>
/// <param name="sampleRate">Samples per second (default 30, clamped 1-120).</param>
/// <param name="maxSeconds">Safety cap β auto-stops the recording once the clip timeline reaches this many seconds (default 60, clamped 1-600). The clip stays in memory until stop_gameplay_recording saves it.</param>
[McpTool( "record_gameplay_clip" )]
public static Task<object> RecordGameplayClip( string[] ids = null, int? sampleRate = null, double? maxSeconds = null )
=> McpGate.Run( "record_gameplay_clip", McpGate.Args( ( "ids", ids ), ( "sampleRate", sampleRate ), ( "maxSeconds", maxSeconds ) ) );
/// <summary>
/// Run a scripted playtest AND record the same run to a .movie clip in ONE call β automated
/// regression footage: a failing playtest comes with a replayable clip of exactly what happened.
/// REQUIRES play mode (start_play first). steps uses the EXACT playtest schema (one verb per step:
/// move / look / lookDelta / action / jump / set / wait / capture / assert β see the playtest tool
/// for the full verb reference). The recording defaults to the playtest's resolved player
/// hierarchy; pass ids to record other objects, or nothing resolvable falls back to whole-scene
/// capture (heavy). Returns { started, steps, recordingJobId, capture, sampleRate, clipName,
/// folder, recorderCapSeconds, note } immediately; both jobs run ASYNC in the editor frame loop and
/// the clip AUTO-SAVES the moment the playtest finishes (a failing or aborted run still saves its
/// footage; play mode ending early is also saved). THE POLL CHAIN: 1) playtest_status until
/// finished:true β the per-step pass/fail transcript. 2) gameplay_recording_status β the saved clip
/// summary { saved, assetPath, durationSeconds, trackCount } (if it still says pendingSave, the
/// save is a frame away β poll again; a save error there means name collision: call
/// stop_gameplay_recording yourself with a new name). Replay the footage with add_movie_player +
/// play_movie. Errors if a playtest or gameplay recording is already active. Only one at a time.
/// </summary>
/// <param name="steps">Ordered playtest step objects β identical schema to the playtest tool (move/look/lookDelta/action/jump/set/wait/capture/assert). Runs top-to-bottom in the frame loop. JSON array.</param>
/// <param name="id">GUID of the player/controller GameObject the playtest drives. Omit to auto-resolve the first PlayerController.</param>
/// <param name="component">Controller component type to target (e.g. 'PlayerController'). Omit to auto-detect.</param>
/// <param name="ids">GameObject GUIDs to RECORD (from get_scene_hierarchy WHILE PLAYING). Omit to record the playtest's player hierarchy (the default and usually what you want).</param>
/// <param name="sampleRate">Recording samples per second (default 30, clamped 1-120).</param>
/// <param name="clipName">Saved .movie asset name without extension (default playtest_<UTC timestamp>; sanitized to [A-Za-z0-9_-]).</param>
/// <param name="folder">Assets subfolder to save the clip into (default "recordings").</param>
[McpTool( "record_playtest" )]
public static Task<object> RecordPlaytest( JsonNode steps, string id = null, string component = null, string[] ids = null, int? sampleRate = null, string clipName = null, string folder = null )
=> McpGate.Run( "record_playtest", McpGate.Args( ( "steps", steps ), ( "id", id ), ( "component", component ), ( "ids", ids ), ( "sampleRate", sampleRate ), ( "clipName", clipName ), ( "folder", folder ) ) );
/// <summary>
/// Stop the active gameplay recording and persist it as a project .movie asset the editor can load
/// (written to Assets/<folder>/<name>.movie, registered + compiled β list_movies then
/// shows it with hasCompiledClip). Also saves a job that already auto-stopped (maxSeconds cap, or
/// play mode ended). Returns { saved, assetPath, durationSeconds, trackCount, sampleRate, compiled,
/// stopReason, wired, note } β a trackCount of 0 means nothing was captured and the response warns
/// about it. Pass wireToId to auto-wire a MoviePlayer on that GameObject pointed at the new clip
/// (during play mode that wiring is RUNTIME-ONLY and discarded on stop_play; the .movie asset
/// itself always persists). Errors if the target file already exists (the clip stays in memory β
/// retry with another name). discard:true throws the recording away instead. Replay:
/// add_movie_player + play_movie in play mode.
/// </summary>
/// <param name="name">Asset name without extension (default recording_<UTC timestamp>; sanitized to [A-Za-z0-9_-]).</param>
/// <param name="folder">Assets subfolder to save into (default "recordings").</param>
/// <param name="wireToId">GameObject GUID to auto-wire a MoviePlayer at the new clip (runtime-only if done during play mode).</param>
/// <param name="discard">Throw the recording away instead of saving it.</param>
[McpTool( "stop_gameplay_recording" )]
public static Task<object> StopGameplayRecording( string name = null, string folder = null, string wireToId = null, bool? discard = null )
=> McpGate.Run( "stop_gameplay_recording", McpGate.Args( ( "name", name ), ( "folder", folder ), ( "wireToId", wireToId ), ( "discard", discard ) ) );
/// <summary>
/// Stop MoviePlayer playback (the counterpart to play_movie). Targets the MoviePlayer on the given
/// GameObject, or the first MoviePlayer in the scene when id is omitted. Pass rewind to also reset
/// the playhead to 0 so the next play_movie starts from the top. Works during play mode.
/// </summary>
/// <param name="id">GameObject GUID holding the MoviePlayer. Omit to use the first MoviePlayer in the scene.</param>
/// <param name="rewind">Also reset the playhead to 0.</param>
[McpTool( "stop_movie" )]
public static Task<object> StopMovie( string id = null, bool? rewind = null )
=> McpGate.Run( "stop_movie", McpGate.Args( ( "id", id ), ( "rewind", rewind ) ) );
}
Editor
library
// AUTO-GENERATED by scripts/emit-mcp-wrappers.mjs β DO NOT EDIT.
// Regenerate: node scripts/extract-manifest.mjs && node scripts/emit-mcp-wrappers.mjs
// Source of truth: sbox-mcp-server/src/tools/ (zod schemas) β scripts/tools-manifest.json
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Editor.Mcp;
/// <summary>
/// NPC brains (state machines), spawners, patrol routes, and perception simulation.
/// </summary>
[McpToolset( "bridge_npc", "NPC brains (state machines), spawners, patrol routes, and perception simulation." )]
public static class BridgeNpcTools
{
/// <summary>
/// Wire a placed route (or an arbitrary ordered GUID list) into an NpcBrain's Waypoints list on a
/// target NPC. This is the list-of-GameObject-references case that plain set_property can't
/// express. Pass either waypointIds (explicit order) or routeId (a route parent whose children
/// become the waypoints in hierarchy order). The list count is returned; List<GameObject>
/// refs may read back as handles/GUIDs via get_property, so trust the count or confirm patrol in
/// play mode.
/// </summary>
/// <param name="npcId">GUID of the GameObject holding the NpcBrain (or any component with a List<GameObject> waypoint property).</param>
/// <param name="waypointIds">Ordered waypoint GameObject GUIDs (e.g. from place_patrol_route). Takes precedence over routeId.</param>
/// <param name="routeId">A route parent GUID whose children (in hierarchy order) become the waypoints.</param>
/// <param name="property">The List<GameObject> property name to set. Defaults to 'Waypoints'. (Use 'SpawnPoints' to wire spawn points on a spawner.).</param>
[McpTool( "assign_patrol_route" )]
public static Task<object> AssignPatrolRoute( string npcId, string[] waypointIds = null, string routeId = null, string property = null )
=> McpGate.Run( "assign_patrol_route", McpGate.Args( ( "npcId", npcId ), ( "waypointIds", waypointIds ), ( "routeId", routeId ), ( "property", property ) ) );
/// <summary>
/// Generate an NpcBrain Component: a behavior state machine
/// (Idle/Patrol/Wander/Chase/Search/Flee/Ambush) driven by occlusion-aware perception β FOV cone +
/// sight range + a line-of-sight trace (respects walls/trees) + proximity hearing β with
/// last-known-position memory (lose-LOS -> search -> give up -> resume). This is the
/// decision layer on top of bake_navmesh / NavMeshAgent movement. Pick a behavior preset, then tune
/// via the generated [Property] fields with set_property. After generating: trigger_hotload +
/// get_compile_errors, place a route with place_patrol_route + assign_patrol_route, bake_navmesh,
/// and verify perception in EDIT mode with simulate_npc_perception (chase/search behavior needs
/// play mode). The component is added to a GameObject like any other; it auto-adds a NavMeshAgent
/// in OnStart.
/// </summary>
/// <param name="name">Class/file name. Defaults to 'NpcBrain'. Sanitized to a valid C# identifier.</param>
/// <param name="directory">Subdirectory under the project root for the .cs file. Defaults to 'Code'.</param>
/// <param name="behavior">Preset (sets StartState + flee toggle): 'patrol' (walk waypoints), 'guard' (Ambush near spawn until a target enters range), 'hunter' (patrol->chase->search, the Sasquatch), 'swarm' (wander/idle->chase nearest, RUN mobs), 'skittish' (chase but flee on low health). The generated file is the same shape; the preset just changes defaults. Defaults to 'hunter'. One of: patrol | guard | hunter | swarm | skittish.</param>
/// <param name="targetTag">Tag the NPC hunts (its candidates are GameObjects with this tag). Defaults to 'player'.</param>
/// <param name="moveSpeed">Patrol/wander speed (NavMeshAgent MaxSpeed). Default 130.</param>
/// <param name="chaseSpeed">Chase/flee speed. Default 200.</param>
/// <param name="sightRange">Max sight distance. Default 1500.</param>
/// <param name="fovDegrees">Full field-of-view cone angle in degrees. Default 110. (Baked into a cosine threshold for cheap, trig-free checks.).</param>
/// <param name="eyeHeight">Trace origin height above the NPC's feet. Default 64.</param>
/// <param name="hearingRadius">Proximity-hearing radius β a target within it is investigated (sets last-known-pos) but NOT instantly aggroed. Default 600.</param>
/// <param name="giveUpTime">Seconds to search after losing line-of-sight before giving up and resuming the start state. Default 6.</param>
/// <param name="searchRadius">Wander radius around the last-known position while searching. Default 400.</param>
/// <param name="waypointStopDistance">How close the NPC must get to a waypoint/target before it counts as reached. Default 80.</param>
/// <param name="canFlee">Enable the Flee state (else the NPC never flees). Defaults from the preset.</param>
/// <param name="fleeHealthFrac">Flee when CurrentHealthFrac drops to/below this (the game sets CurrentHealthFrac 0..1). Default 0.25.</param>
/// <param name="networked">When true (default), emit a host-authoritative brain: 'if (IsProxy) return;' + [Sync] CurrentState. NOTE: a no-session solo playtest makes everything a proxy, so a networked brain won't think until a host session exists β pass false to iterate solo in the edit scene.</param>
[McpTool( "create_npc_brain" )]
public static Task<object> CreateNpcBrain( string name = null, string directory = null, string behavior = null, string targetTag = null, double? moveSpeed = null, double? chaseSpeed = null, double? sightRange = null, double? fovDegrees = null, double? eyeHeight = null, double? hearingRadius = null, double? giveUpTime = null, double? searchRadius = null, double? waypointStopDistance = null, bool? canFlee = null, double? fleeHealthFrac = null, bool? networked = null )
=> McpGate.Run( "create_npc_brain", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "behavior", behavior ), ( "targetTag", targetTag ), ( "moveSpeed", moveSpeed ), ( "chaseSpeed", chaseSpeed ), ( "sightRange", sightRange ), ( "fovDegrees", fovDegrees ), ( "eyeHeight", eyeHeight ), ( "hearingRadius", hearingRadius ), ( "giveUpTime", giveUpTime ), ( "searchRadius", searchRadius ), ( "waypointStopDistance", waypointStopDistance ), ( "canFlee", canFlee ), ( "fleeHealthFrac", fleeHealthFrac ), ( "networked", networked ) ) );
/// <summary>
/// Generate a daily-routine NPC brain: a [Property] list of schedule entries (startHour/endHour
/// 0..24, taskName, target = named scene GameObject or fixed position), the hour read from any
/// create_day_night_clock component (capability match: a float TimeOfDay property, same GameObject
/// first then scene-wide) with an HONEST fallback to its own internal clock when none exists (check
/// the generated UsingClockComponent bool), walking the NPC to the active entry's target and idling
/// outside the schedule, plus a static OnTaskChanged(brain, taskName) event and [Sync(FromHost)]
/// CurrentTask. Entries with endHour < startHour wrap past midnight. Returns {created, path,
/// className, tasks[], propertyNames[], note}. Next: trigger_hotload + get_compile_errors, attach
/// (targetId or add_component_with_properties), create the named target GameObjects (e.g.
/// 'WorkSpot'), pair with create_day_night_clock for shared time, verify via get_runtime_property
/// CurrentTask in play mode. Limits: default movement is a direct transform walk (walks through
/// walls) β pass useNavMeshAgent:true for pathfinding (then bake_navmesh is REQUIRED); a clock with
/// a different shape (e.g. 0..1 DayProgress) will NOT bind; networked default true won't tick in a
/// no-session solo playtest (networked:false to iterate). Refused during play mode; refuses to
/// overwrite an existing file.
/// </summary>
/// <param name="name">Class/file name. Defaults to 'NpcScheduleBrain'. Sanitized to a valid C# identifier.</param>
/// <param name="directory">Subdirectory under the project root for the .cs file. Defaults to 'Code'.</param>
/// <param name="schedule">Schedule entries baked as inspector-editable defaults. Defaults to Work 8-17 @ 'WorkSpot', Relax 17-22 @ 'HomeSpot' (idles/sleeps otherwise). JSON array.</param>
/// <param name="moveSpeed">Walk speed in world units/s. Defaults to 100.</param>
/// <param name="arriveDistance">Distance at which the NPC counts as arrived and idles at the spot. Defaults to 32.</param>
/// <param name="useNavMeshAgent">true: move via NavMeshAgent.MoveTo (real pathfinding β REQUIRES bake_navmesh or the NPC won't move). Defaults to false (direct transform walk, no navmesh needed, walks through walls).</param>
/// <param name="fallbackDayLengthSeconds">Internal fallback clock only: real seconds per 24 in-game hours when NO TimeOfDay clock component exists. Defaults to 600.</param>
/// <param name="fallbackStartHour">Internal fallback clock only: starting hour 0..24. Defaults to 8.</param>
/// <param name="networked">true (default): host-authoritative (IsProxy guard) + [Sync(FromHost)] CurrentTask. false: local build for solo iteration.</param>
/// <param name="targetId">GUID of the NPC GameObject to attach to (only attaches if the type is already in the TypeLibrary β hotload first).</param>
[McpTool( "create_npc_schedule_brain" )]
public static Task<object> CreateNpcScheduleBrain( string name = null, string directory = null, JsonNode schedule = null, double? moveSpeed = null, double? arriveDistance = null, bool? useNavMeshAgent = null, double? fallbackDayLengthSeconds = null, double? fallbackStartHour = null, bool? networked = null, string targetId = null )
=> McpGate.Run( "create_npc_schedule_brain", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "schedule", schedule ), ( "moveSpeed", moveSpeed ), ( "arriveDistance", arriveDistance ), ( "useNavMeshAgent", useNavMeshAgent ), ( "fallbackDayLengthSeconds", fallbackDayLengthSeconds ), ( "fallbackStartHour", fallbackStartHour ), ( "networked", networked ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate a spawner Component that instantiates an NPC prefab over time / in escalating waves at
/// spawn points, capped by maxAlive. RUN's swarm backbone and Sasquatched's round-start spawn.
/// After generating: set NpcPrefab via set_prefab_ref, set SpawnPoints (reuse place_patrol_route to
/// make a set of empties, then assign_patrol_route with property='SpawnPoints'), trigger_hotload +
/// get_compile_errors. Verify by watching the GameObject count over time in play mode. Networked
/// spawns use NetworkSpawn() and are host-only.
/// </summary>
/// <param name="name">Class/file name. Defaults to 'NpcSpawner'.</param>
/// <param name="directory">Subdirectory under the project root. Defaults to 'Code'.</param>
/// <param name="mode">'continuous' (one every interval), 'waves' (a batch every interval, waveCount times), 'burst' (one batch then stop). Default 'waves'. One of: continuous | waves | burst.</param>
/// <param name="count">NPCs per wave (waves) or per batch (burst/continuous batch). Default 5.</param>
/// <param name="interval">Seconds between spawns (continuous) or between waves (waves). Default 8.</param>
/// <param name="waveCount">Number of waves (waves mode). Default 3.</param>
/// <param name="waveGrowth">Multiply count each wave (>1 = escalating). Default 1.0.</param>
/// <param name="radius">Random scatter radius around a spawn point. Default 200.</param>
/// <param name="maxAlive">Cap on concurrent live NPCs (important so swarms don't melt the frame rate). Default 12.</param>
/// <param name="networked">When true (default), spawn via NetworkSpawn() (host-only, try/catch solo-safe) so clients see the NPCs; false = a plain local Clone for solo/edit testing.</param>
[McpTool( "create_npc_spawner" )]
public static Task<object> CreateNpcSpawner( string name = null, string directory = null, string mode = null, double? count = null, double? interval = null, double? waveCount = null, double? waveGrowth = null, double? radius = null, double? maxAlive = null, bool? networked = null )
=> McpGate.Run( "create_npc_spawner", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "mode", mode ), ( "count", count ), ( "interval", interval ), ( "waveCount", waveCount ), ( "waveGrowth", waveGrowth ), ( "radius", radius ), ( "maxAlive", maxAlive ), ( "networked", networked ) ) );
/// <summary>
/// Generate a utility-AI (scored-action) brain: one file with an abstract {name}Action : Component
/// base (Score() 0..1 + Begin/Tick/End lifecycle), a sealed {name}Brain that every EvaluateInterval
/// picks the highest-scoring sibling action (score Γ ScoreWeight, current action gets
/// +HysteresisBonus so near-ties don't flip-flop), and two example actions β {name}IdleAction
/// (constant fallback score) and {name}WanderAction (desire builds while idle, walks to random
/// points by direct transform movement, no navmesh). How it differs from create_npc_brain: the FSM
/// has a FIXED transition table; here behavior EMERGES from per-frame scores β add behaviors by
/// subclassing the base on the same GameObject, no transition wiring. Returns {created, path,
/// classNames[4], propertyNames[], note}. Next: trigger_hotload + get_compile_errors, attach the
/// brain AND example actions to one GameObject (targetId attaches only the brain), verify in play
/// mode via get_runtime_property CurrentActionName. Limits: networked default true =
/// host-authoritative (won't tick in a no-session solo playtest β use networked:false); actions
/// Tick on the simulating machine only. Refused during play mode; refuses to overwrite an existing
/// file.
/// </summary>
/// <param name="name">System prefix β generates {name}Action / {name}Brain / {name}IdleAction / {name}WanderAction in {name}Ai.cs. Defaults to 'Utility'. Sanitized to a valid C# identifier.</param>
/// <param name="directory">Subdirectory under the project root for the .cs file. Defaults to 'Code'.</param>
/// <param name="evaluateInterval">Seconds between score evaluations (the active action still Ticks every frame). Defaults to 0.25.</param>
/// <param name="hysteresisBonus">Score bonus the current action gets during evaluation β stickiness that prevents flip-flopping between near-tied actions. Defaults to 0.15.</param>
/// <param name="moveSpeed">Example WanderAction walk speed in world units/s. Defaults to 80.</param>
/// <param name="wanderRadius">Example WanderAction roam radius around its start position. Defaults to 300.</param>
/// <param name="networked">true (default): host-authoritative brain (IsProxy guard) + [Sync(FromHost)] CurrentActionName. false: local build for solo iteration.</param>
/// <param name="targetId">GUID of a GameObject to attach the BRAIN to (actions must be added separately; only attaches if the type is already in the TypeLibrary β hotload first).</param>
[McpTool( "create_utility_ai" )]
public static Task<object> CreateUtilityAi( string name = null, string directory = null, double? evaluateInterval = null, double? hysteresisBonus = null, double? moveSpeed = null, double? wanderRadius = null, bool? networked = null, string targetId = null )
=> McpGate.Run( "create_utility_ai", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "evaluateInterval", evaluateInterval ), ( "hysteresisBonus", hysteresisBonus ), ( "moveSpeed", moveSpeed ), ( "wanderRadius", wanderRadius ), ( "networked", networked ), ( "targetId", targetId ) ) );
/// <summary>
/// Place a set of waypoint GameObjects (tagged empties) for a patrol route and group them under a
/// parent route object β authorable in one call. Optionally snaps each point to the ground (raycast
/// down) so waypoints sit on the navmesh, not floating. Returns the route parent GUID + ordered
/// waypoint GUIDs to feed into assign_patrol_route. Validate connectivity afterward with
/// get_navmesh_path between consecutive waypoints (catches a 'point in a wall').
/// </summary>
/// <param name="points">Ordered world positions for the route (at least 2). JSON array.</param>
/// <param name="name">Route name. Defaults to 'PatrolRoute'. Waypoints are named <route>_WP0, _WP1, ...</param>
/// <param name="tag">Tag applied to each waypoint. Defaults to 'waypoint'.</param>
/// <param name="snapToGround">Drop each point onto the surface below via a downward raycast. Default true.</param>
/// <param name="parentId">Existing parent GameObject GUID to nest the waypoints under; otherwise a new route empty is created at the points' centroid.</param>
[McpTool( "place_patrol_route" )]
public static Task<object> PlacePatrolRoute( JsonNode points, string name = null, string tag = null, bool? snapToGround = null, string parentId = null )
=> McpGate.Run( "place_patrol_route", McpGate.Args( ( "points", points ), ( "name", name ), ( "tag", tag ), ( "snapToGround", snapToGround ), ( "parentId", parentId ) ) );
/// <summary>
/// READ-ONLY edit-mode verifier: evaluate the NPC's perception math RIGHT NOW without entering play
/// mode. Given an NPC (reads its NpcBrain SightRange/FovDegrees/EyeHeight/TargetTag + transform)
/// and either a targetId or a point, it runs the SAME line-of-sight check the brain uses β FOV cone
/// (dot vs the baked cosine), sight-range gate, and an occlusion trace from the eye to the target β
/// and reports the result AND why. This is the keystone verifier: it makes the perception layer
/// checkable in edit mode (no flaky screenshot timing) β e.g. place the Sasquatch, place a camper
/// behind a tree, and confirm the tree blocks LOS. Call params override the brain's values, so it
/// also works before/without an NpcBrain (uses defaults). Safe in play mode too (read-only, like
/// raycast).
/// </summary>
/// <param name="npcId">GUID of the NPC GameObject (ideally with an NpcBrain; its perception [Property] values are read).</param>
/// <param name="targetId">GUID of the target GameObject to test visibility to (e.g. a player). Provide this OR point.</param>
/// <param name="point">A raw world point to test visibility to. Provide this OR targetId. As "x,y,z" (or JSON {x,y,z}).</param>
/// <param name="sightRange">Override the sight range for this check (else read from the NpcBrain / default 1500).</param>
/// <param name="fovDegrees">Override the FOV cone angle for this check (else read from the NpcBrain / default 110).</param>
/// <param name="eyeHeight">Override the eye height for this check (else read from the NpcBrain / default 64).</param>
/// <param name="targetTag">Override the target tag (canSee also requires the target to carry this tag; else read from the NpcBrain / default 'player').</param>
[McpTool( "simulate_npc_perception" )]
public static Task<object> SimulateNpcPerception( string npcId, string targetId = null, string point = null, double? sightRange = null, double? fovDegrees = null, double? eyeHeight = null, string targetTag = null )
=> McpGate.Run( "simulate_npc_perception", McpGate.Args( ( "npcId", npcId ), ( "targetId", targetId ), ( "point", point ), ( "sightRange", sightRange ), ( "fovDegrees", fovDegrees ), ( "eyeHeight", eyeHeight ), ( "targetTag", targetTag ) ) );
}
Game
library
using Sandbox;
/// <summary>
/// This is a component - in your library!
/// </summary>
[Title( "claude bridge - My Component" )]
public class MyLibraryComponent : Component
{
}
Editor
library
// AUTO-GENERATED by scripts/emit-mcp-wrappers.mjs β DO NOT EDIT.
// Regenerate: node scripts/extract-manifest.mjs && node scripts/emit-mcp-wrappers.mjs
// Source of truth: sbox-mcp-server/src/tools/ (zod schemas) β scripts/tools-manifest.json
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Editor.Mcp;
/// <summary>
/// Create prefabs from scene objects, instantiate them, list and inspect them, and wire prefab
/// references into component properties.
/// </summary>
[McpToolset( "bridge_prefab", "Create prefabs from scene objects, instantiate them, list and inspect them, and wire prefab references into component properties." )]
public static class BridgePrefabTools
{
/// <summary>
/// Save an existing GameObject as a real .prefab file β FULL engine serialization: every component
/// with its property values, and all children, in the same JSON format the editor writes. Returns {
/// created, path, sourceId, components, children } β pass path to instantiate_prefab to spawn
/// copies or get_prefab_info to inspect. Errors if the source GameObject is missing; overwrites an
/// existing file at path.
/// </summary>
/// <param name="id">GUID of the GameObject to save as prefab.</param>
/// <param name="path">Path for the prefab file relative to project root (e.g. 'prefabs/enemies/grunt.prefab').</param>
[McpTool( "create_prefab" )]
public static Task<object> CreatePrefab( string id, string path )
=> McpGate.Run( "create_prefab", McpGate.Args( ( "id", id ), ( "path", path ) ) );
/// <summary>
/// Inspect a prefab file as a structured summary: { path, name, size, modified, totalObjects,
/// maxDepth, referencedPrefabs, tree } β tree is the object hierarchy with per-node component type
/// lists (children capped at 8 per node with a truncation count). referencedPrefabs lists other
/// .prefab files this one links to. Use before instantiate_prefab; find prefabs with list_prefabs;
/// raw JSON via read_file if needed.
/// </summary>
/// <param name="path">Path to the .prefab file (e.g. 'prefabs/enemies/grunt.prefab').</param>
[McpTool.ReadOnly( "get_prefab_info" )]
public static Task<object> GetPrefabInfo( string path )
=> McpGate.Run( "get_prefab_info", McpGate.Args( ( "path", path ) ) );
/// <summary>
/// Spawn a FULL prefab instance into the active scene β components and children recreated. Uses the
/// engine's GameObject.Clone for registered prefabs, with a guid-remapped deserialize fallback for
/// freshly-written files (repeat instantiations never collide). Returns { instantiated, prefab,
/// method, gameObject, components, childCount } β gameObject.id is the new GUID for
/// set_transform/set_property follow-ups. Optional name/position/rotation override the spawned
/// root.
/// </summary>
/// <param name="path">Path to the .prefab file (e.g. 'prefabs/enemies/grunt.prefab').</param>
/// <param name="name">Rename the spawned root (defaults to the prefab's root name).</param>
/// <param name="position">World position to spawn at β object {x,y,z} or comma string "x,y,z". Defaults to origin. As "x,y,z" (or JSON {x,y,z}).</param>
/// <param name="rotation">Rotation as euler angles. Defaults to identity. As "pitch,yaw,roll" degrees.</param>
/// <param name="scale">Uniform scale multiplier. Defaults to 1.0.</param>
/// <param name="parent">GUID of parent GameObject to attach to.</param>
[McpTool( "instantiate_prefab" )]
public static Task<object> InstantiatePrefab( string path, string name = null, string position = null, string rotation = null, double? scale = null, string parent = null )
=> McpGate.Run( "instantiate_prefab", McpGate.Args( ( "path", path ), ( "name", name ), ( "position", position ), ( "rotation", rotation ), ( "scale", scale ), ( "parent", parent ) ) );
/// <summary>
/// List all .prefab files in the project. Filter by name or path.
/// </summary>
/// <param name="filter">Search filter for prefab name or path.</param>
/// <param name="maxResults">Maximum results to return. Defaults to 100.</param>
[McpTool.ReadOnly( "list_prefabs" )]
public static Task<object> ListPrefabs( string filter = null, double? maxResults = null )
=> McpGate.Run( "list_prefabs", McpGate.Args( ( "filter", filter ), ( "maxResults", maxResults ) ) );
/// <summary>
/// Set a GameObject-typed property on a component to a loaded prefab. Use this when set_property
/// can't handle prefab references (which it can't, because prefabs are GameObjects not primitives).
/// </summary>
/// <param name="id">GUID of the GameObject holding the component.</param>
/// <param name="component">Component type name.</param>
/// <param name="property">Property name to set (must be GameObject-typed).</param>
/// <param name="prefabPath">Prefab asset path (e.g. 'prefabs/player.prefab').</param>
[McpTool( "set_prefab_ref" )]
public static Task<object> SetPrefabRef( string id, string component, string property, string prefabPath )
=> McpGate.Run( "set_prefab_ref", McpGate.Args( ( "id", id ), ( "component", component ), ( "property", property ), ( "prefabPath", prefabPath ) ) );
}
Editor
library
// AUTO-GENERATED by scripts/emit-mcp-wrappers.mjs β DO NOT EDIT.
// Regenerate: node scripts/extract-manifest.mjs && node scripts/emit-mcp-wrappers.mjs
// Source of truth: sbox-mcp-server/src/tools/ (zod schemas) β scripts/tools-manifest.json
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Editor.Mcp;
/// <summary>
/// Generate complete, compile-verified gameplay C# components: player/NPC controllers, game
/// managers, health, pickups, inventory, save systems, economy, loot tables, round/phase machines,
/// interaction systems, placement mode, and more. Each tool writes a .cs file into the project;
/// follow with trigger_hotload + compile_status.
/// </summary>
[McpToolset( "bridge_scaffold_gameplay", "Generate complete, compile-verified gameplay C# components: player/NPC controllers, game managers, health, pickups, inventory, save systems, economy, loot tables, round/phase machines, interaction systems, placement mode, and more. Each tool writes a .cs file into the project; follow with trigger_hotload + compile_status." )]
public static class BridgeScaffoldGameplayTools
{
/// <summary>
/// SCENE-MUTATING: generate a data-driven achievement trigger-zone component AND create its
/// GameObject now (named zone with a sized BoxCollider, IsTrigger=true, at the given position).
/// When an object tagged triggerTag enters, the component calls
/// <achievementSetClass>.Instance.Progress(achievementId, amount) β or Unlock() when
/// unlock=true β with a once-only latch and optional destroy-after-fire. Returns { created, path,
/// className, achievementSetClass, achievementId, gameObject, attached, note, nextSteps }. The
/// generated component only attaches to the zone after trigger_hotload β until then `attached` is
/// false and nextSteps carries the exact add_component_with_properties follow-up. The generated
/// code references the set class BY NAME: run create_achievement_set first or the project will not
/// compile (the result warns via `note`). Re-running with the same name fails unless
/// reuseClass=true, which skips codegen and just places another zone (attaching + configuring
/// immediately since the class is already compiled). Refused during play mode.
/// </summary>
/// <param name="achievementId">Id of the achievement to progress/unlock (sanitized to [a-z0-9_-]).</param>
/// <param name="name">Class name for the generated trigger component. Defaults to 'AchievementTrigger'.</param>
/// <param name="directory">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>
/// <param name="achievementSetClass">Class name of the achievement set the zone reports to (from create_achievement_set). Defaults to 'AchievementSet'.</param>
/// <param name="amount">Progress amount added per fire (ignored when unlock=true). Defaults to 1.</param>
/// <param name="unlock">Call Unlock() instead of Progress(). Defaults to false.</param>
/// <param name="triggerTag">Tag the entering object must carry (put it on the player via set_tags). Defaults to 'player'.</param>
/// <param name="onceOnly">Only the first tagged entry fires. Defaults to true.</param>
/// <param name="destroyAfterFire">Destroy the zone GameObject after firing. Defaults to false.</param>
/// <param name="createObject">Create the zone GameObject now (with BoxCollider). Defaults to true; false = code-gen only.</param>
/// <param name="objectName">Name for the zone GameObject. Defaults to '<name>Zone'.</param>
/// <param name="position">World position of the zone GameObject. As "x,y,z" (or JSON {x,y,z}).</param>
/// <param name="scale">BoxCollider size β uniform number, object {x,y,z}, or comma string "x,y,z". Defaults to 100,100,100. As "x,y,z" (or JSON {x,y,z}).</param>
/// <param name="reuseClass">If the .cs already exists, skip codegen and just place another zone with the existing class. Defaults to false.</param>
[McpTool( "add_achievement_trigger" )]
public static Task<object> AddAchievementTrigger( string achievementId, string name = null, string directory = null, string achievementSetClass = null, double? amount = null, bool? unlock = null, string triggerTag = null, bool? onceOnly = null, bool? destroyAfterFire = null, bool? createObject = null, string objectName = null, string position = null, string scale = null, bool? reuseClass = null )
=> McpGate.Run( "add_achievement_trigger", McpGate.Args( ( "achievementId", achievementId ), ( "name", name ), ( "directory", directory ), ( "achievementSetClass", achievementSetClass ), ( "amount", amount ), ( "unlock", unlock ), ( "triggerTag", triggerTag ), ( "onceOnly", onceOnly ), ( "destroyAfterFire", destroyAfterFire ), ( "createObject", createObject ), ( "objectName", objectName ), ( "position", position ), ( "scale", scale ), ( "reuseClass", reuseClass ) ) );
/// <summary>
/// Generate an eye-traced interaction-prompt HUD β a PanelComponent (.razor + .razor.scss pair,
/// like create_leaderboard_panel) that every frame traces a ray from the scene camera
/// (Scene.Trace.Ray, out to [Property] float Range) and, when the crosshair is on a component
/// implementing Component.IPressable, shows a centered "Press E"-style pill. The prompt text comes
/// from the target's IPressable.GetTooltip() when it overrides it (most don't), else a [Property]
/// DefaultPrompt built from the action. This is the visible half of the interaction loop: it PAIRS
/// with create_interactable / add_interaction_station (which implement IPressable) β this tool
/// tells the player they CAN press, those tools handle the press. Host it under a ScreenPanel
/// (add_screen_panel), then add the component to that panel object. The generated Razor is
/// razor_lint-safe by construction: PanelComponent + BuildHash override folding the visible state,
/// no switch-expressions and no non-ASCII in @code, and a class root selector in the SCSS.
/// LOCAL/visual-only (no [Sync]).
/// </summary>
/// <param name="name">Class/file name for the generated .razor. Defaults to 'InteractionPrompt'.</param>
/// <param name="directory">Subdirectory for the generated .razor + .razor.scss. Defaults to 'Code/UI'.</param>
/// <param name="action">Verb woven into the default prompt text ('Press E to <action>'). Defaults to 'use'.</param>
/// <param name="range">Eye-trace reach in world units β how close the crosshair must be to a pressable to show the prompt. Defaults to 120.</param>
[McpTool( "add_interaction_prompt" )]
public static Task<object> AddInteractionPrompt( string name = null, string directory = null, string action = null, double? range = null )
=> McpGate.Run( "add_interaction_prompt", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "action", action ), ( "range", range ) ) );
/// <summary>
/// Generate a Component.IPressable 'station' prop (crafting bench / shop till / arcade cabinet)
/// that ONE user occupies at a time. Occupancy is host-authoritative: the occupant is a
/// [Sync(SyncFlags.FromHost)] Guid (GameObject/Connection aren't [Sync]-able) and Press() routes
/// the claim to the host via an [Rpc.Host] Occupy(). Includes a reservation grace window (the
/// station stays reserved for its last user for graceSeconds after they leave, so a brief walk-away
/// can't jump the queue), an optional unlock-level gate (users below requiredLevel can't use it β
/// wire the static ResolveUserLevel hook to your progression system to activate it), and an
/// overlay-open hook (a static OnStationOpened(GameObject) event to open your UI, plus an opt-in
/// [Rpc.Broadcast] mirror). Single-player safe. Optionally attached to an existing GameObject by
/// GUID (only after a trigger_hotload). Give the prop a Collider so the player's use key can
/// raycast it. Mined from interaction-station patterns across shipped s&box games.
/// </summary>
/// <param name="name">Class name for the generated component. Defaults to 'InteractionStation'.</param>
/// <param name="directory">Subdirectory for the .cs file (path override). Defaults to 'Code'.</param>
/// <param name="graceSeconds">Seconds the station stays reserved for its last user after they leave, before anyone else can claim it. 0 = no grace window. Defaults to 5.</param>
/// <param name="requiredLevel">Unlock-level gate: users below this level can't use the station. 0 = no gate. The gate only bites once you wire the static ResolveUserLevel hook to your progression system. Defaults to 0.</param>
/// <param name="targetId">GUID of an existing GameObject to attach the station component to (only attaches if the type is already loaded β generate, trigger_hotload, then it places; otherwise add it after the hotload).</param>
[McpTool( "add_interaction_station" )]
public static Task<object> AddInteractionStation( string name = null, string directory = null, double? graceSeconds = null, int? requiredLevel = null, string targetId = null )
=> McpGate.Run( "add_interaction_station", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "graceSeconds", graceSeconds ), ( "requiredLevel", requiredLevel ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate a batched write-side stat reporter component for Sandbox.Services.Stats β the write
/// partner of create_leaderboard_panel. Gameplay code calls the static <Name>.Report("kills",
/// 1) from anywhere; amounts accumulate locally and flush as Stats.Increment deltas on a timer
/// (default every 12 s, also on disable/destroy). Baseline-delta bookkeeping means a partial flush
/// retries the un-sent remainder instead of double-counting, and deltas larger than maxChunk are
/// sent in chunks. Returns { created, path, className, placedOn, note, nextSteps }. Place ONE in
/// the scene after trigger_hotload (add_component_to_new_object), or pass targetId to attach
/// immediately when the type is already compiled. Stats are PER LOCAL PLAYER (each client reports
/// its own) and only exist on leaderboards once the stat is registered for the project ident on
/// sbox.game. Fails if the file already exists.
/// </summary>
/// <param name="name">Class name for the generated component. Defaults to 'StatReporter'.</param>
/// <param name="directory">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>
/// <param name="flushIntervalSeconds">Seconds between batched flushes to the backend. Defaults to 12, clamped to >= 1.</param>
/// <param name="maxChunk">Largest amount sent in a single Stats.Increment call; bigger deltas are chunked. Defaults to 1000, clamped to >= 1.</param>
/// <param name="targetId">GUID of a GameObject to attach to (only attaches if the type is already loaded β hotload first).</param>
[McpTool( "add_leaderboard_stat" )]
public static Task<object> AddLeaderboardStat( string name = null, string directory = null, double? flushIntervalSeconds = null, double? maxChunk = null, string targetId = null )
=> McpGate.Run( "add_leaderboard_stat", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "flushIntervalSeconds", flushIntervalSeconds ), ( "maxChunk", maxChunk ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate a currency component (sealed) persisted over Sandbox.Services.Stats β Steam-cloud
/// persistence, per Steam account, per package ident, with NO local save file. The stat stores the
/// ABSOLUTE balance: every Add(double)/TrySpend(double) pushes Stats.SetValue(statName, balance);
/// Flush() (and OnDestroy) pushes the buffered writes. On start it reads the balance back
/// asynchronously via Stats.GetLocalPlayerStats(ident) -> Refresh() -> Get(statName).Value
/// and fires the static OnBalanceLoaded(double); wait for IsLoaded before showing the balance.
/// CLOUD SEMANTICS (surprising): stat writes are buffered/rate-limited by the backend and apply
/// ONLY to the LOCAL Steam user β calling this for another player silently does nothing, so attach
/// it to the LOCAL player's GameObject (IsProxy guards keep remote copies inert); read-back is
/// eventually consistent and can lag minutes behind writes β the in-session Balance property is the
/// runtime truth. Dev sessions without a real published package ident may read back nothing
/// (balance starts 0 with a log line). packageIdent defaults to the running package (Game.Ident).
/// Returns { created, path, className, statName, packageIdent, flushEveryChange, placedOn, note,
/// nextSteps }. Next: trigger_hotload, attach to the local player, bind OnBalanceChanged for the
/// HUD. Refused during play mode. Use create_economy_wallet/create_currency_account for in-run
/// networked money, create_signed_save for offline local persistence; pair with
/// create_leaderboard_panel (the same stat can back a leaderboard).
/// </summary>
/// <param name="name">Class name for the generated component. Defaults to 'SteamStatCurrency'.</param>
/// <param name="directory">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>
/// <param name="statName">Sandbox.Services stat that stores the balance (the stat-name string is the contract between write and read-back). Defaults to 'currency'.</param>
/// <param name="packageIdent">Package ident to read stats from. Omit/empty = the running package (Game.Ident).</param>
/// <param name="flushEveryChange">Call Stats.Flush() after every balance change instead of relying on the buffered flush + OnDestroy flush (the backend rate-limits flushes). Defaults to false.</param>
/// <param name="targetId">GUID of the LOCAL player's GameObject to attach to (only attaches if the type is already loaded β hotload first).</param>
[McpTool( "add_steam_stat_currency" )]
public static Task<object> AddSteamStatCurrency( string name = null, string directory = null, string statName = null, string packageIdent = null, bool? flushEveryChange = null, string targetId = null )
=> McpGate.Run( "add_steam_stat_currency", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "statName", statName ), ( "packageIdent", packageIdent ), ( "flushEveryChange", flushEveryChange ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate an achievement engine: a component with an AchievementDef list
/// (id/title/description/target), per-achievement progress persisted via FileSystem.Data JSON
/// (survives restarts), Progress(id, amount) / Unlock(id) API on a static Instance, a static
/// OnAchievementUnlocked event, and an optional Stats.Increment mirror ('ach-<id>' += 1) on
/// unlock. Also emits a Razor unlock-toast HUD (<Name>Toast.razor + .razor.scss, razor_lint
/// clean) unless makeToast=false. Returns { created, path, className, toastRazorPath,
/// toastScssPath, toastClassName, achievements, placedOn, note, nextSteps }. Ids are sanitized to
/// [a-z0-9_-]; omitting achievements bakes 3 editable samples. After trigger_hotload: place ONE set
/// in the scene, and host the toast under a ScreenPanel (add_screen_panel). Pair with
/// add_achievement_trigger for world-trigger unlocks. LOCAL-only: achievements belong to each
/// client's local player. Fails if the .cs or toast .razor already exists.
/// </summary>
/// <param name="name">Class name for the generated engine component (toast panel becomes <name>Toast). Defaults to 'AchievementSet'.</param>
/// <param name="directory">Subdirectory for all generated files. Defaults to 'Code'.</param>
/// <param name="achievements">Achievement definitions baked into the component. Omit for 3 editable samples (first_steps, collector, veteran). JSON array.</param>
/// <param name="fileName">Save file name inside FileSystem.Data. Defaults to 'achievements.json'.</param>
/// <param name="mirrorToStats">Mirror each unlock into Sandbox.Services.Stats as 'ach-<id>' += 1. Defaults to true.</param>
/// <param name="makeToast">Also emit the <name>Toast.razor + .razor.scss unlock toast HUD. Defaults to true.</param>
/// <param name="toastSeconds">Seconds each unlock toast stays on screen. Defaults to 4, clamped to >= 0.5.</param>
/// <param name="targetId">GUID of a GameObject to attach the engine to (only attaches if the type is already loaded β hotload first).</param>
[McpTool( "create_achievement_set" )]
public static Task<object> CreateAchievementSet( string name = null, string directory = null, JsonNode achievements = null, string fileName = null, bool? mirrorToStats = null, bool? makeToast = null, double? toastSeconds = null, string targetId = null )
=> McpGate.Run( "create_achievement_set", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "achievements", achievements ), ( "fileName", fileName ), ( "mirrorToStats", mirrorToStats ), ( "makeToast", makeToast ), ( "toastSeconds", toastSeconds ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate a first-person pickup / carry / throw component (sealed Component) for physics props.
/// Attach it to the PLAYER (the object that owns the camera). It eye-traces from Scene.Camera for a
/// Rigidbody-bearing GameObject tagged [Property] CarryTag (default 'carryable') within [Property]
/// Range; grabbing routes a host-authoritative [Rpc.Host] request that re-validates the target and
/// caller, hands the object's network ownership to the carrier
/// (GameObject.Network.AssignOwnership), and disables the rigidbody's MotionEnabled while held. The
/// held object follows a hold point ([Property] Vector3 HoldOffset in front of the camera) each
/// FixedUpdate; dropping restores physics, throwing applies an impulse ([Property] float
/// ThrowForce). The held-object id is [Sync(SyncFlags.FromHost)] so proxies see the carrying state,
/// and static OnPickedUp / OnDropped events fire uniformly for SFX/VFX. PAIRS with physics props β
/// give each carryable a Rigidbody + Collider and the CarryTag (set_tags); network-spawn them for
/// multiplayer so ownership + transform replicate. Single-player safe (IsProxy is false and RPCs
/// run locally with no session). Inputs: GrabAction (default 'use') grabs/drops, ThrowAction
/// (default 'attack1') throws. Optionally attach to an existing player GameObject by GUID after a
/// hotload.
/// </summary>
/// <param name="name">Class name for the generated component. Defaults to 'CarrySystem'.</param>
/// <param name="directory">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>
/// <param name="range">Eye-trace reach for grabbing a carryable, in world units. Defaults to 130.</param>
/// <param name="throwForce">Impulse magnitude applied on throw (scales with the prop's mass β tune per game). Defaults to 20000.</param>
/// <param name="carryTag">Only objects with this tag (and a Rigidbody) can be picked up; lower-cased/underscored to match s&box tag convention. Defaults to 'carryable'.</param>
/// <param name="targetId">GUID of the PLAYER GameObject (the one with the camera) to attach to (only attaches if the type is already loaded β hotload first).</param>
[McpTool( "create_carry_system" )]
public static Task<object> CreateCarrySystem( string name = null, string directory = null, double? range = null, double? throwForce = null, string carryTag = null, string targetId = null )
=> McpGate.Run( "create_carry_system", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "range", range ), ( "throwForce", throwForce ), ( "carryTag", carryTag ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate a host-authoritative currency ACCOUNT component (sealed) β the audited sibling of
/// create_economy_wallet (wallet = simple money, account = money + a ledger). Balance is
/// [Sync(SyncFlags.FromHost)] so clients can't author their own money; host-guarded Deposit(amount,
/// reason), Withdraw(amount, reason) -> bool, and TryTransfer(otherAccount, amount, reason)
/// -> bool each record a Transaction { Time (Time.Now), signed Amount, Reason, BalanceAfter }
/// into a fixed-size ring buffer (historySize, default 32; oldest entries overwritten SILENTLY).
/// GetRecentTransactions(max) returns them NEWEST FIRST β the ledger is HOST-SIDE ONLY and does not
/// replicate (Balance does); proxies get an empty list. Bind the instance OnBalanceChanged(long)
/// for HUD labels. Single-player safe. Returns { created, path, className, startingBalance,
/// historySize, placedOn, note, nextSteps }. Next: trigger_hotload, then attach via targetId re-run
/// or add_component_to_new_object. Refuses if the file already exists; refused during play mode.
/// Use create_economy_wallet when you don't need the audit trail; pair with create_idle_economy (it
/// auto-wires this account's Money/TrySpend).
/// </summary>
/// <param name="name">Class name for the generated component. Defaults to 'CurrencyAccount'.</param>
/// <param name="directory">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>
/// <param name="startingBalance">Balance the account opens with (host seeds it in OnStart). Defaults to 0.</param>
/// <param name="historySize">Transaction ring-buffer capacity (clamped 1..4096); fixed once the first transaction is recorded, oldest overwritten silently after that. Defaults to 32.</param>
/// <param name="targetId">GUID of a per-player/bank GameObject to attach to (only attaches if the type is already loaded β hotload first).</param>
[McpTool( "create_currency_account" )]
public static Task<object> CreateCurrencyAccount( string name = null, string directory = null, int? startingBalance = null, int? historySize = null, string targetId = null )
=> McpGate.Run( "create_currency_account", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "startingBalance", startingBalance ), ( "historySize", historySize ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate a networked coin / currency pickup component (sealed, Component.ITriggerListener).
/// Host-spawned; when a GameObject carrying PlayerTag ('player') enters its trigger the HOST
/// validates and grants Value (default 1) into a wallet on the player, then destroys the pickup
/// network-wide (the host Destroy() replicates β there is no NetworkDestroy on this SDK). Optional
/// magnet: while MagnetRadius (default 0 = off) is > 0 the coin accelerates toward the nearest
/// player each FixedUpdate (host-side, capped by MaxMagnetSpeed). IsProxy guards keep the grant +
/// despawn host-only in multiplayer (NetworkSpawn the coin on the host); single-player works with
/// no networking. The deposit is reflection-free and dependency-free: a static Grant seam is wired
/// ONCE to the direct typed call β player.Components.Get<EconomyWallet>()?.AddMoney(amount) β
/// so the component compiles with NO hard reference to a specific wallet class (rename the wallet
/// type if yours differs; mirrors create_pickup's self-contained convention). WalletComponentName
/// (default 'EconomyWallet') is used to locate the wallet and name the fix if Grant is left unwired
/// (never silent). Pairs with create_economy_wallet (AddMoney/TrySpend/CanAfford) and
/// create_floating_combat_text (spawn a '+N' popup from OnCollected).
/// </summary>
/// <param name="name">Class name for the generated component. Defaults to 'CurrencyPickup'.</param>
/// <param name="directory">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>
/// <param name="value">How much currency the pickup grants into the wallet. Defaults to 1.</param>
/// <param name="magnetRadius">Magnet range in world units β within it the coin flies to the nearest player each FixedUpdate. 0 = magnet off. Defaults to 0.</param>
/// <param name="walletComponentName">Type name of the wallet component to deposit into (used to locate it and to name the fix if the Grant seam is left unwired). Defaults to 'EconomyWallet'.</param>
/// <param name="targetId">GUID of a coin GameObject to attach to β give it a trigger Collider (SphereCollider, IsTrigger=true). Only attaches if the type is already loaded β hotload first.</param>
[McpTool( "create_currency_pickup" )]
public static Task<object> CreateCurrencyPickup( string name = null, string directory = null, int? value = null, double? magnetRadius = null, string walletComponentName = null, string targetId = null )
=> McpGate.Run( "create_currency_pickup", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "value", value ), ( "magnetRadius", magnetRadius ), ( "walletComponentName", walletComponentName ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate a host-authoritative time-of-day clock: [Sync(SyncFlags.FromHost)] TimeOfDay (0β24) +
/// Day advancing by Time.Delta, IsDay/IsNight from sunrise/sunset hours, and static OnNewDay /
/// OnDayNightChanged events to drive lighting, NPC schedules, or spawns. Single-player safe. Pairs
/// with create_round_phase_machine. Optionally attached to a GameObject by GUID (after a hotload).
/// </summary>
/// <param name="name">Class name. Defaults to 'DayNightClock'.</param>
/// <param name="directory">Subdirectory for the .cs file. Defaults to 'Code'.</param>
/// <param name="dayLengthSeconds">Real seconds per in-game day. Defaults to 600 (10 min).</param>
/// <param name="startHour">Hour the clock starts at (0β24). Defaults to 8.</param>
/// <param name="sunriseHour">Hour day begins. Defaults to 6.</param>
/// <param name="sunsetHour">Hour night begins. Defaults to 20.</param>
/// <param name="targetId">GUID of an existing GameObject to attach to (hotload first).</param>
[McpTool( "create_day_night_clock" )]
public static Task<object> CreateDayNightClock( string name = null, string directory = null, double? dayLengthSeconds = null, double? startHour = null, double? sunriseHour = null, double? sunsetHour = null, string targetId = null )
=> McpGate.Run( "create_day_night_clock", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "dayLengthSeconds", dayLengthSeconds ), ( "startHour", startHour ), ( "sunriseHour", sunriseHour ), ( "sunsetHour", sunsetHour ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate a host-authoritative currency Wallet component: a [Sync(SyncFlags.FromHost)] Money
/// balance (only the host can write it β plain [Sync] money is the classic economy exploit) with
/// AddMoney / TrySpend / SetMoney / CanAfford and an OnMoneyChanged event. Single-player safe.
/// Optionally attached to an existing GameObject by GUID (after a hotload). Pairs with a save
/// system for persistence. Mined from the most-requested currency pattern across 51 games.
/// </summary>
/// <param name="name">Class name. Defaults to 'Wallet'.</param>
/// <param name="directory">Subdirectory for the .cs file. Defaults to 'Code'.</param>
/// <param name="startingMoney">Initial balance the host seeds on start. Defaults to 0.</param>
/// <param name="targetId">GUID of an existing GameObject to attach the Wallet to (only attaches if the type is already loaded β hotload first).</param>
[McpTool( "create_economy_wallet" )]
public static Task<object> CreateEconomyWallet( string name = null, string directory = null, int? startingMoney = null, string targetId = null )
=> McpGate.Run( "create_economy_wallet", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "startingMoney", startingMoney ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate a self-contained, host-authoritative elo rating component: standard elo math (expected
/// = 1/(1+10^((Rb-Ra)/400)), delta = K * (score - expected)) with a [Property] K-factor, ratings in
/// a [Sync(SyncFlags.FromHost)] NetDictionary<long,float> keyed by SteamId, and host-side
/// persistence via FileSystem.Data JSON. API on a static Instance: ReportMatch(winnerSteamId,
/// loserSteamId) for 1v1 and ReportTeamMatch(winnerIds, loserIds) for teams (team-average elo,
/// uniform delta per member) β both are IsProxy-guarded no-ops on clients; GetRating(steamId) works
/// anywhere (unknown players = defaultRating); the static OnRatingChanged(steamId, newRating) fires
/// on EVERY machine via an [Rpc.Broadcast]. Returns { created, path, className, kFactor,
/// defaultRating, placedOn, note, nextSteps }. After trigger_hotload: place ONE in the scene and
/// network its GameObject (network_spawn) or the [Sync] never replicates. Only the HOST's disk
/// holds the ratings ledger. Fails if the file already exists.
/// </summary>
/// <param name="name">Class name for the generated component. Defaults to 'EloRatingSystem'.</param>
/// <param name="directory">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>
/// <param name="kFactor">Elo K-factor β how far one result moves ratings (32 = fast, 16 = stable). Defaults to 32, clamped to >= 1.</param>
/// <param name="defaultRating">Rating assigned to players with no recorded matches. Defaults to 1000.</param>
/// <param name="fileName">Save file name inside FileSystem.Data (host-side ledger). Defaults to 'elo_ratings.json'.</param>
/// <param name="targetId">GUID of a GameObject to attach to (only attaches if the type is already loaded β hotload first).</param>
[McpTool( "create_elo_rating_system" )]
public static Task<object> CreateEloRatingSystem( string name = null, string directory = null, double? kFactor = null, double? defaultRating = null, string fileName = null, string targetId = null )
=> McpGate.Run( "create_elo_rating_system", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "kFactor", kFactor ), ( "defaultRating", defaultRating ), ( "fileName", fileName ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate a typed LOCAL pub/sub event bus: a pure STATIC class (NOT a Component β nothing to
/// place in the scene) with Subscribe<T>(owner, Action<T>), Unsubscribe(owner) (removes
/// all of that owner's handlers across every event type), Publish<T>(evt) (synchronous,
/// exact-type-T subscribers only, snapshot-iterated so handlers may subscribe/unsubscribe
/// mid-publish), Count<T>() and Clear(), keyed by a plain Dictionary<Type,
/// List<(object, Delegate)>> β plus a tiny example event record ({name}Ping). Decouples
/// game systems: the quest system publishes 'EnemyDied', UI and achievements subscribe, neither
/// knows the other. Returns {created, path, className, exampleEvent, api[], note}. Next:
/// trigger_hotload + get_compile_errors, then Subscribe in components' OnStart and β REQUIRED β
/// Unsubscribe(this) in OnDestroy: handler lists hold PLAIN references (no weak refs), so a
/// component that never unsubscribes leaks itself for the scene's life; call Clear() on scene
/// teardown. Limits: LOCAL only β Publish reaches the calling machine's subscribers, NOT other
/// clients; for networked events pair with [Rpc.Broadcast]/[Rpc.Host] methods that Publish on
/// arrival. No base-type dispatch (Publish<Base> won't reach Subscribe<Derived>).
/// Refuses to overwrite an existing file; refused during play mode.
/// </summary>
/// <param name="name">Static class/file name. Defaults to 'EventBus'. The example event record is named {name}Ping. Sanitized to a valid C# identifier.</param>
/// <param name="directory">Subdirectory under the project root for the .cs file. Defaults to 'Code'.</param>
[McpTool( "create_event_bus" )]
public static Task<object> CreateEventBus( string name = null, string directory = null )
=> McpGate.Run( "create_event_bus", McpGate.Args( ( "name", name ), ( "directory", directory ) ) );
/// <summary>
/// Generate a generalized L4D-style AI/pacing director component (host-authoritative). On a
/// configurable interval the host rolls a weighted pick over a [Property] List<GameObject>
/// EventPrefabs (with a parallel List<float> Weights), skips any event already active
/// (dedupe) and anything past a MaxActive concurrency cap, clones the chosen prefab, NetworkSpawns
/// it, and attaches a generated {name}TimedEvent companion so each spawned event self-destructs
/// after EventLifetime seconds. Great for ambient events, waves, and world events. Single-player
/// safe (IsProxy guard; NetworkSpawn falls back to a local clone). Fill EventPrefabs/Weights in the
/// inspector or via the bridge after a hotload; edit the RollInterval() stub to make pacing
/// adaptive (player-count/inactivity/time-pressure factors) per the ai-director cookbook.
/// Optionally attached to an existing GameObject by GUID (after a hotload). NOTE: emits ONE .cs
/// file containing two classes ({name} + {name}TimedEvent); the type only resolves after
/// trigger_hotload.
/// </summary>
/// <param name="name">Class name for the director (a {name}TimedEvent companion is generated alongside it). Defaults to 'EventDirector'.</param>
/// <param name="path">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>
/// <param name="intervalSeconds">Base seconds between director rolls. Defaults to 30 (clamped to >= 0.1).</param>
/// <param name="maxActive">Maximum number of concurrently-live events. Defaults to 3 (clamped to >= 1).</param>
/// <param name="eventLifetime">Seconds before each spawned event self-destructs. Defaults to 60 (clamped to >= 0.1).</param>
/// <param name="targetId">GUID of an existing GameObject to attach the director to (only attaches if the type is already loaded β hotload first).</param>
[McpTool( "create_event_director" )]
public static Task<object> CreateEventDirector( string name = null, string path = null, double? intervalSeconds = null, int? maxActive = null, double? eventLifetime = null, string targetId = null )
=> McpGate.Run( "create_event_director", McpGate.Args( ( "name", name ), ( "path", path ), ( "intervalSeconds", intervalSeconds ), ( "maxActive", maxActive ), ( "eventLifetime", eventLifetime ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate a host-authoritative gacha / loot-box roller component. Two-level pick: parallel
/// [Property] lists RarityNames + RarityWeights select a RARITY by cumulative weight (the
/// create_weighted_loot_table shape), then a flat 'Rarity:Item' [Property] list (e.g.
/// 'Legendary:Dragon Fang') picks an ITEM uniformly within that rarity β simple and
/// inspector-editable. A pity counter (PityAfter, default 50) guarantees the rarest tier (the LAST
/// entry in RarityNames) after N rolls without it and resets on a hit. Duplicate detection against
/// an owned-items set fires a host-side OnDuplicate hook (marked TODO: convert dupes to
/// shards/currency). Roll() routes to the host via an [Rpc.Host] RequestRoll (Rpc.Caller
/// re-validated β NetFlags is not security) and the result fans out via [Rpc.Broadcast] so every
/// machine fires the static OnRolled(rarity, item, isDuplicate) event; single-player safe (RPCs run
/// locally). Use create_weighted_loot_table instead for a simpler single-tier weighted pick with no
/// pity/dupe/networking. Pairs with create_economy_wallet (spend currency to roll) and
/// create_inventory (store the pulls).
/// </summary>
/// <param name="name">Class name for the generated component. Defaults to 'GachaDropTable'.</param>
/// <param name="directory">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>
/// <param name="pityAfter">Rolls without a rarest-tier hit before the next roll is guaranteed rarest. 0 disables pity. Defaults to 50.</param>
/// <param name="targetId">GUID of a per-player/manager GameObject to attach to (only attaches if the type is already loaded β hotload first).</param>
[McpTool( "create_gacha_drop_table" )]
public static Task<object> CreateGachaDropTable( string name = null, string directory = null, int? pityAfter = null, string targetId = null )
=> McpGate.Run( "create_gacha_drop_table", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "pityAfter", pityAfter ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate a minimal game-manager Component: a static Instance singleton, [Property] MaxPlayers /
/// GameState, and a Component.INetworkListener OnActive hook that logs player connects. Writes
/// <name>.cs and returns { created, path, className }. NOTE: the
/// includeScore/includeTimer/includeSpawning params are not currently applied β the same minimal
/// manager is always generated (for richer game-loop scaffolds see create_round_phase_machine /
/// create_objective_system / create_economy_wallet). Follow with trigger_hotload, then
/// get_compile_errors, then place via add_component_to_new_object.
/// </summary>
/// <param name="name">Class name. Defaults to 'GameManager'.</param>
/// <param name="directory">Subdirectory under code/ for the file.</param>
/// <param name="includeScore">Include score tracking (currently not applied by the handler).</param>
/// <param name="includeTimer">Include round timer with countdown (currently not applied by the handler).</param>
/// <param name="includeSpawning">Include player spawning from prefab at spawn point (currently not applied by the handler).</param>
[McpTool( "create_game_manager" )]
public static Task<object> CreateGameManager( string name = null, string directory = null, bool? includeScore = null, bool? includeTimer = null, bool? includeSpawning = null )
=> McpGate.Run( "create_game_manager", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "includeScore", includeScore ), ( "includeTimer", includeTimer ), ( "includeSpawning", includeSpawning ) ) );
/// <summary>
/// Generate a Health component: MaxHealth, [Sync] CurrentHealth, TakeDamage/Heal, an OnDeath event,
/// optional regen and respawn. Host-authoritative damage when networked, single-player safe.
/// Optionally attached to an existing GameObject by GUID.
/// </summary>
/// <param name="name">Class name. Defaults to 'Health'.</param>
/// <param name="directory">Subdirectory for the .cs file. Defaults to 'Code'.</param>
/// <param name="maxHealth">Starting/maximum health. Defaults to 100.</param>
/// <param name="regen">Include passive health regeneration after a delay. Defaults to false.</param>
/// <param name="respawn">On death, respawn at a RespawnPoint (wire it with set_component_reference) instead of disabling. Defaults to false.</param>
/// <param name="targetId">GUID of an existing GameObject to attach the Health component to (only attaches if the type is already loaded β hotload first).</param>
[McpTool( "create_health_system" )]
public static Task<object> CreateHealthSystem( string name = null, string directory = null, double? maxHealth = null, bool? regen = null, bool? respawn = null, string targetId = null )
=> McpGate.Run( "create_health_system", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "maxHealth", maxHealth ), ( "regen", regen ), ( "respawn", respawn ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate a hold-to-confirm action component (sealed Component). While a named input action is
/// held (Input.Down), a public Progress value fills 0β1 over [Property] float HoldSeconds;
/// releasing early snaps back to 0, or drains down if [Property] bool DecayOnRelease. Reaching 1
/// fires the static OnConfirmed(GameObject) event, then a short CooldownSeconds blocks
/// re-triggering. The classic 'hold E to disarm / open / revive' interaction. No UI is generated β
/// read the public Progress (0..1) from your own HUD to draw a radial or bar; a #region Feedback
/// hook marks where to tie in a sound/effect. LOCAL/owner-only: input is IsProxy-guarded so it
/// never fires on proxies and is single-player safe. For a host-authoritative outcome, call an
/// [Rpc.Host] from inside the OnConfirmed subscriber. Attach to the player (or any owned object
/// that reads input); optionally attach to an existing GameObject by GUID after a hotload.
/// </summary>
/// <param name="name">Class name for the generated component. Defaults to 'HoldToConfirm'.</param>
/// <param name="directory">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>
/// <param name="action">Input action name that must be held (must exist in the project's Input settings β see ensure_input_action). Defaults to 'use'.</param>
/// <param name="holdSeconds">Seconds of continuous hold required to confirm. Defaults to 1.5.</param>
/// <param name="decayOnRelease">Baked default for DecayOnRelease: if true, releasing early drains Progress back down instead of snapping to 0 (editable per-instance). Defaults to false.</param>
/// <param name="targetId">GUID of a GameObject to attach the component to (only attaches if the type is already loaded β hotload first).</param>
[McpTool( "create_hold_to_confirm" )]
public static Task<object> CreateHoldToConfirm( string name = null, string directory = null, string action = null, double? holdSeconds = null, bool? decayOnRelease = null, string targetId = null )
=> McpGate.Run( "create_hold_to_confirm", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "action", action ), ( "holdSeconds", holdSeconds ), ( "decayOnRelease", decayOnRelease ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate a geometric idle-economy component (sealed): generators on the classic BaseCost *
/// Growth^Owned cost curve with Buy 1 / Buy N / Buy Max β CostOf(index, count),
/// MaxAffordable(index), TryBuy(index, count) and BuyMax(index) all use the CLOSED-FORM geometric
/// series (cost = c0*(g^n-1)/(g-1), buyMax = floor(log_g(funds*(g-1)/c0+1))) β no per-copy loops,
/// Buy 1000 is the same math as Buy 1. Wallet wiring is TypeLibrary reflection with NO compile-time
/// wallet dependency (the shipped create_idle_income pattern): each income tick invokes
/// AddMoney(long|int) on the first sibling component that has one, purchases invoke
/// TrySpend(long|int), Buy Max reads the sibling's Money (or Balance) property β works out of the
/// box next to create_economy_wallet or create_currency_account; with NO wallet sibling, purchases
/// are refused with a Log.Warning (never silent) while TotalEarned still accumulates.
/// Host-authoritative: mutations IsProxy-guarded; owned counts are HOST-SIDE state (not
/// replicated); TotalEarned is [Sync(FromHost)]. Static events OnPurchased(index, count, cost) and
/// OnIncomeTick(amount, total). BuyMax steps down once past a whole-currency rounding edge rather
/// than failing. Returns { created, path, className, generators, tickSeconds, placedOn, note,
/// nextSteps }. Next: trigger_hotload, place it NEXT TO a wallet on the same GameObject, tune the
/// parallel GeneratorNames/BaseCosts/Growths/IncomesPerSecond lists with set_property. Refused
/// during play mode. Pair with create_offline_progress for away-time earnings; use
/// create_idle_income for a bare income ticker with no purchasing.
/// </summary>
/// <param name="name">Class name for the generated component. Defaults to 'IdleEconomy'.</param>
/// <param name="directory">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>
/// <param name="tickSeconds">Seconds between income grants (floored at 0.1). Defaults to 1.</param>
/// <param name="generators">Baked-in generator defaults (inspector-tunable after generation). Omit for a starter trio: Cursor 15/1.15/0.5, Farm 200/1.15/4, Factory 3000/1.12/30. JSON array.</param>
/// <param name="targetId">GUID of the GameObject to attach to β put it on the SAME GameObject as the wallet so the reflection wiring finds it (only attaches if the type is already loaded β hotload first).</param>
[McpTool( "create_idle_economy" )]
public static Task<object> CreateIdleEconomy( string name = null, string directory = null, double? tickSeconds = null, JsonNode generators = null, string targetId = null )
=> McpGate.Run( "create_idle_economy", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "tickSeconds", tickSeconds ), ( "generators", generators ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate a host-authoritative passive income component: every tickSeconds the host grants
/// incomePerTick Γ Multiplier, auto-wiring the first sibling component with an AddMoney(int) method
/// (a create_economy_wallet scaffold plugs in with zero code) or an overridable Grant() seam;
/// TotalEarned is [Sync(FromHost)] and static OnIncomeTick fires per grant. The idle-game kit:
/// wallet (create_economy_wallet) + this + create_offline_progress. Writes a .cs file and returns {
/// created, path, className, nextSteps } β follow with trigger_hotload + compile_status.
/// </summary>
/// <param name="name">Class/file name (default 'IdleIncome' -> Code/IdleIncome.cs). Errors if the file exists.</param>
/// <param name="directory">Directory for the .cs file. Default 'Code'.</param>
/// <param name="incomePerTick">Amount granted per tick. Default 1.</param>
/// <param name="tickSeconds">Seconds between grants. Default 1.</param>
[McpTool( "create_idle_income" )]
public static Task<object> CreateIdleIncome( string name = null, string directory = null, double? incomePerTick = null, double? tickSeconds = null )
=> McpGate.Run( "create_idle_income", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "incomePerTick", incomePerTick ), ( "tickSeconds", tickSeconds ) ) );
/// <summary>
/// Generate a Component.IPressable interactable: the built-in PlayerController 'use' key drives
/// Press()/Hover()/Blur() with no custom player code. Includes a static OnPressed event, an
/// optional cooldown (TimeUntil), and a private OnPress() extensionpoint for effects. For
/// host-authoritative side-effects call an [Rpc.Host] from OnPress(). The Prompt property is left
/// to your game's HUD. Optionally attached to an existing GameObject by GUID (after a hotload).
/// </summary>
/// <param name="name">Class name. Defaults to 'Interactable'.</param>
/// <param name="directory">Subdirectory for the .cs file. Defaults to 'Code'.</param>
/// <param name="prompt">Prompt string shown by the game's HUD when hovering. Defaults to 'Press'.</param>
/// <param name="cooldownSeconds">Seconds before the interactable can be pressed again. 0 = no cooldown. Defaults to 0.</param>
/// <param name="targetId">GUID of an existing GameObject to attach the component to (only attaches if the type is already loaded β hotload first).</param>
[McpTool( "create_interactable" )]
public static Task<object> CreateInteractable( string name = null, string directory = null, string prompt = null, double? cooldownSeconds = null, string targetId = null )
=> McpGate.Run( "create_interactable", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "prompt", prompt ), ( "cooldownSeconds", cooldownSeconds ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate a slot-based inventory component using parallel List<string> ItemIds /
/// List<int> Counts (serialization-safe, inspector-editable). Includes TryAdd (stack-first,
/// partial-add rejected), TryRemove, CountOf, Move (swap or merge same-id slots), and Clear. Static
/// OnChanged event fires after every successful mutation. Host-authoritative usage note: mutate on
/// the host in multiplayer, replicate via your own [Sync]/RPC. Pairs with create_pickup.
/// </summary>
/// <param name="name">Class name. Defaults to 'Inventory'.</param>
/// <param name="directory">Subdirectory for the .cs file. Defaults to 'Code'.</param>
/// <param name="capacity">Total slot count. Defaults to 24.</param>
/// <param name="maxStack">Maximum items per slot (stack cap). Defaults to 99.</param>
/// <param name="targetId">GUID of an existing GameObject to attach to (hotload first).</param>
[McpTool( "create_inventory" )]
public static Task<object> CreateInventory( string name = null, string directory = null, int? capacity = null, int? maxStack = null, string targetId = null )
=> McpGate.Run( "create_inventory", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "capacity", capacity ), ( "maxStack", maxStack ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate a Razor PanelComponent that fetches and displays a Sandbox.Services leaderboard derived
/// from a stat name. Produces TWO files: {name}.razor and {name}.razor.scss. The panel
/// auto-refreshes every 30 s, shows rank/displayName/value rows, handles loading state, and
/// includes a BuildHash() override (razor-lint clean). Must be hosted under a ScreenPanel or
/// WorldPanel. Stats must be configured for the project ident on sbox.game. Uses
/// Leaderboards.Get(statName) + board.Refresh() -- the exact API from ServicesQueryHandler. Returns
/// { created, razorPath, scssPath, className, note }. Follow with trigger_hotload, then
/// get_compile_errors, then host it via add_screen_panel (panelComponent=className).
/// </summary>
/// <param name="name">Class name for the panel component. Defaults to 'LeaderboardPanel'.</param>
/// <param name="directory">Subdirectory for the generated files. Defaults to 'Code/UI'.</param>
/// <param name="statName">Sandbox.Services stat name the leaderboard is derived from. Defaults to 'score'.</param>
/// <param name="title">Display title shown at the top of the panel. Defaults to 'Leaderboard'.</param>
/// <param name="maxRows">Maximum leaderboard rows to fetch and display. Defaults to 10.</param>
[McpTool( "create_leaderboard_panel" )]
public static Task<object> CreateLeaderboardPanel( string name = null, string directory = null, string statName = null, string title = null, int? maxRows = null )
=> McpGate.Run( "create_leaderboard_panel", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "statName", statName ), ( "title", title ), ( "maxRows", maxRows ) ) );
/// <summary>
/// Generate GameResource-based loot tables β the data-asset sibling of create_weighted_loot_table.
/// One .cs file containing THREE types: an entry POCO { Name, Weight, optional NestedTable
/// reference }, a [AssetType]-registered GameResource loot-table class (designers author '.loot'
/// files in the editor asset browser β New > Loot Table β after the hotload; NOTE:
/// [AssetType(Name=..., Extension=..., Category=...)] is used because GameResourceAttribute is
/// [Obsolete] on this SDK), and a '<name>Resolver' Component that rolls an assigned table by
/// cumulative weight. Nested tables: an entry with a NestedTable rolls INTO that table instead of
/// dropping its Name, capped at maxDepth (default 4) with a self-reference guard so cycles
/// terminate (at the cap the deepest entry's Name is returned). Resolver.Roll() returns the item
/// name (null + warning when no Table is assigned or the table is empty; entries with weight <=
/// 0 never win; all-zero weights fall back to the first entry) and fires the static
/// OnLoot(GameObject, item) event; roll HOST-SIDE and replicate the result yourself. targetId
/// attaches the RESOLVER (the resource is an asset type, not a component). SURPRISING: pick an
/// extension that is NOT a suffix of a built-in one (e.g. avoid 'cfg') or ResourceLibrary picks up
/// engine files as phantom instances. Returns { created, path, className, resolverClass, extension,
/// maxDepth, placedOn, note, nextSteps }. Next: trigger_hotload -> author .loot assets in the
/// editor -> assign the resolver's Table (set_property with the asset path). Refused during play
/// mode. Use create_weighted_loot_table for a single inline component with no asset files;
/// create_gacha_drop_table for pity + duplicate mechanics.
/// </summary>
/// <param name="name">Class name for the generated GameResource (the resolver becomes '<name>Resolver'). Defaults to 'LootTableResource'.</param>
/// <param name="directory">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>
/// <param name="extension">Asset file extension (lowercase alphanumerics; avoid suffixes of built-in extensions like 'cfg'). Defaults to 'loot'.</param>
/// <param name="title">Display name of the asset type in the editor's New-asset menu. Defaults to 'Loot Table'.</param>
/// <param name="maxDepth">Default nested-table resolve depth cap baked into the resolver (clamped 0..16; also a [Property]). Defaults to 4.</param>
/// <param name="targetId">GUID of a GameObject to attach the RESOLVER component to (only attaches if the type is already loaded β hotload first).</param>
[McpTool( "create_loot_table_resource" )]
public static Task<object> CreateLootTableResource( string name = null, string directory = null, string extension = null, string title = null, int? maxDepth = null, string targetId = null )
=> McpGate.Run( "create_loot_table_resource", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "extension", extension ), ( "title", title ), ( "maxDepth", maxDepth ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate a between-runs roguelite meta-progression component (sealed, owner-only): persistent
/// meta-currency + an unlock-flag dictionary saved to FileSystem.Data JSON (dirty-flag autosave +
/// OnDestroy, the create_save_system shape). API: Grant(long), TrySpend(long) -> bool,
/// Unlock(key) (idempotent β the static OnUnlocked(key) event fires only on the FIRST unlock, and
/// unlocks write through to disk immediately), IsUnlocked(key) -> bool, and the run-end seam
/// BankRun(int earned) which converts a finished run's earnings into meta-currency, bumps
/// RunsBanked, and saves immediately β call it from your round machine's end-of-run transition
/// (create_round_state_machine / create_round_phase_machine). Instance OnCurrencyChanged(long)
/// drives meta-shop balance labels. Versioned payload: old-version files start fresh.
/// IsProxy-guarded β in multiplayer each machine banks only its own local meta file (this is
/// per-machine persistence, not a server economy). Returns { created, path, className, fileName,
/// version, placedOn, note, nextSteps }. Next: trigger_hotload, attach to a persistent
/// hub/menu-scene manager GameObject, gate content with IsUnlocked when building the player.
/// Refused during play mode. Pair with create_currency_account (in-run money) and
/// create_signed_save (if the meta file needs tamper evidence β this one is unsigned).
/// </summary>
/// <param name="name">Class name for the generated component. Defaults to 'MetaProgression'.</param>
/// <param name="directory">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>
/// <param name="fileName">FileSystem.Data path the meta state is written to. Defaults to 'meta.json'.</param>
/// <param name="version">Payload version; mismatched files start fresh. Defaults to 1.</param>
/// <param name="autosaveSeconds">Dirty-flag autosave cadence in seconds; 0 disables the heartbeat (unlocks and BankRun still write through immediately). Defaults to 10.</param>
/// <param name="targetId">GUID of a persistent manager GameObject to attach to (only attaches if the type is already loaded β hotload first).</param>
[McpTool( "create_meta_progression" )]
public static Task<object> CreateMetaProgression( string name = null, string directory = null, string fileName = null, int? version = null, double? autosaveSeconds = null, string targetId = null )
=> McpGate.Run( "create_meta_progression", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "fileName", fileName ), ( "version", version ), ( "autosaveSeconds", autosaveSeconds ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate a sim/tycoon needs engine component: a [Property] list of need definitions (name, decay
/// rate/s, critical threshold, weight) with per-need 0..100 values that decay over Time.Delta,
/// Satisfy(name, amount) to restore, an aggregate Happiness (weighted mean, [Sync(FromHost)] when
/// networked), and static OnNeedCritical (edge-triggered: fires once crossing below threshold,
/// re-arms above) + OnHappinessChanged (>0.25-point moves) events. Returns {created, path,
/// className, needs[], propertyNames[], note}. Next: trigger_hotload, get_compile_errors, then
/// attach via targetId re-call or add_component_with_properties; drive from game code (e.g. a
/// create_interactable that calls Satisfy). Limits: per-need values live on the simulating machine
/// only (host) β sync per-need UI yourself via RPCs; events fire on the simulating machine only;
/// networked default true means a no-session solo playtest won't tick (everything is a proxy) β
/// pass networked:false to iterate solo. Refused during play mode; refuses to overwrite an existing
/// file.
/// </summary>
/// <param name="name">Class/file name. Defaults to 'NeedsSystem'. Sanitized to a valid C# identifier.</param>
/// <param name="directory">Subdirectory under the project root for the .cs file. Defaults to 'Code'.</param>
/// <param name="needs">Need definitions baked as inspector-editable defaults. Defaults to the classic sim trio: Hunger(0.8/s), Energy(0.5/s), Fun(0.3/s). JSON array.</param>
/// <param name="networked">true (default): host-authoritative (IsProxy guard) + [Sync(FromHost)] Happiness β needs a host session. false: local build that ticks in a solo playtest.</param>
/// <param name="targetId">GUID of a GameObject to attach the component to (only attaches if the type is already in the TypeLibrary β hotload first, then re-call or use add_component_with_properties).</param>
[McpTool( "create_needs_system" )]
public static Task<object> CreateNeedsSystem( string name = null, string directory = null, JsonNode needs = null, bool? networked = null, string targetId = null )
=> McpGate.Run( "create_needs_system", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "needs", needs ), ( "networked", networked ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate an NPC controller script with NavMeshAgent pathfinding. Supports patrol, chase, and
/// patrol-chase behaviors.
/// </summary>
/// <param name="name">Class name. Defaults to 'NpcController'.</param>
/// <param name="directory">Subdirectory under code/ for the file.</param>
/// <param name="behavior">AI behavior: 'patrol' (follow waypoints), 'chase' (follow player), 'patrol_chase' (patrol until player nearby). Defaults to 'patrol'. One of: patrol | chase | patrol_chase.</param>
/// <param name="moveSpeed">Movement speed. Defaults to 150.</param>
/// <param name="chaseRange">Detection range for chase behavior. Defaults to 500.</param>
[McpTool( "create_npc_controller" )]
public static Task<object> CreateNpcController( string name = null, string directory = null, string behavior = null, double? moveSpeed = null, double? chaseRange = null )
=> McpGate.Run( "create_npc_controller", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "behavior", behavior ), ( "moveSpeed", moveSpeed ), ( "chaseRange", chaseRange ) ) );
/// <summary>
/// Generate an ObjectiveManager component β the win/lose brain of a game. Tracks an objective
/// (collect_all / reach_goal / survive_time / eliminate_all), fires a win, and handles a lose
/// condition (fall below kill-Z / timer / out of lives). Self-contained C#; other systems call
/// ObjectiveManager.Instance. Optionally placed as a scene singleton. Returns { created, path,
/// className, gameObject, note } β gameObject is the placed singleton, or null with a note when the
/// fresh type isn't in the TypeLibrary yet. Follow with trigger_hotload, then get_compile_errors;
/// if placement was skipped, place with add_component_to_new_object after the hotload.
/// </summary>
/// <param name="name">Class name. Defaults to 'ObjectiveManager'.</param>
/// <param name="directory">Subdirectory for the .cs file. Defaults to 'Code'.</param>
/// <param name="objective">Win condition. Defaults to 'reach_goal'. One of: collect_all | reach_goal | survive_time | eliminate_all.</param>
/// <param name="targetCount">How many to collect/eliminate (for collect_all / eliminate_all). Defaults to 3.</param>
/// <param name="timeLimit">Seconds β survive this long to win (survive_time) or before losing (loseOn=timer). Defaults to 60.</param>
/// <param name="loseOn">Lose condition. 'fall' = player drops below killZ. Defaults to 'fall'. One of: fall | timer | lives | none.</param>
/// <param name="killZ">World Z below which the player is considered fallen out of the world. Defaults to -1000.</param>
/// <param name="lives">Lives before game over (loseOn=lives). Defaults to 1.</param>
/// <param name="placeInScene">Place the manager as a scene singleton. Defaults to true. (Only attaches if the type is already loaded β generate, hotload, then it places; otherwise add it after hotload.).</param>
[McpTool( "create_objective_system" )]
public static Task<object> CreateObjectiveSystem( string name = null, string directory = null, string objective = null, int? targetCount = null, double? timeLimit = null, string loseOn = null, double? killZ = null, int? lives = null, bool? placeInScene = null )
=> McpGate.Run( "create_objective_system", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "objective", objective ), ( "targetCount", targetCount ), ( "timeLimit", timeLimit ), ( "loseOn", loseOn ), ( "killZ", killZ ), ( "lives", lives ), ( "placeInScene", placeInScene ) ) );
/// <summary>
/// Generate an offline / idle-progress component (sealed, owner/host-only) β the idle-game staple.
/// Persists LastSeenUtc (DateTime) to FileSystem.Data JSON on a dirty-flag autosave heartbeat
/// (AutosaveSeconds) and on OnDisabled, copying create_save_system's persistence patterns. On
/// enable it computes elapsed = now β LastSeenUtc, guards a clock rollback (negative β 0), clamps
/// to MaxOfflineHours (default 8), then replays that time through a SimulateOffline(double seconds)
/// TODO hook in fixed TickSeconds chunks (default 1) so idle accumulation is deterministic
/// (frame-rate independent), and fires the static OnOfflineProgressApplied(seconds) event (drive a
/// 'welcome back, you earned X' screen). IsProxy-guarded so a client can't author their own offline
/// earnings. Fill in the SimulateOffline hook with your idle math (e.g.
/// wallet.AddMoney(rate*seconds)). Pairs with create_economy_wallet / create_save_system /
/// create_stat_modifier_system.
/// </summary>
/// <param name="name">Class name for the generated component. Defaults to 'OfflineProgress'.</param>
/// <param name="directory">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>
/// <param name="maxOfflineHours">Offline time is clamped to this many hours (stops a week-away paying out a week). Defaults to 8.</param>
/// <param name="tickSeconds">SimulateOffline chunk size in seconds β smaller = finer-grained deterministic replay (floored at 0.1). Defaults to 1.</param>
/// <param name="targetId">GUID of an idle/save-manager GameObject to attach to (only attaches if the type is already loaded β hotload first).</param>
[McpTool( "create_offline_progress" )]
public static Task<object> CreateOfflineProgress( string name = null, string directory = null, double? maxOfflineHours = null, double? tickSeconds = null, string targetId = null )
=> McpGate.Run( "create_offline_progress", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "maxOfflineHours", maxOfflineHours ), ( "tickSeconds", tickSeconds ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate a trigger-based collectible component. On enter by a tagged object it raises
/// OnCollected (wire it to your objective/score system) and despawns. Optionally builds a visible
/// pickup GameObject with a trigger SphereCollider (+ a model) in one call. Returns { created,
/// path, className, gameObject, note } β gameObject is the placed pickup (null unless
/// placeInScene=true); a note flags when the component couldn't attach because the fresh type needs
/// a hotload. Follow with trigger_hotload, then get_compile_errors.
/// </summary>
/// <param name="name">Class name. Defaults to 'Pickup'.</param>
/// <param name="directory">Subdirectory for the .cs file. Defaults to 'Code'.</param>
/// <param name="action">Effect flavour (all self-contained; the heal/item branches show the typed call to a companion system in comments). Defaults to 'score'. One of: score | heal | item | custom.</param>
/// <param name="amount">Magnitude of the effect (score points, heal amount). Defaults to 1.</param>
/// <param name="filterTag">Only collect for objects with this tag. Defaults to 'player'.</param>
/// <param name="placeInScene">Also build a pickup GameObject (trigger SphereCollider + optional model). Defaults to false.</param>
/// <param name="position">World position when placeInScene is true. As "x,y,z" (or JSON {x,y,z}).</param>
/// <param name="radius">Trigger sphere radius when placed. Defaults to 24.</param>
/// <param name="model">Optional model path for a visible pickup (e.g. 'models/dev/box.vmdl'). Cloud assets must be installed first.</param>
[McpTool( "create_pickup" )]
public static Task<object> CreatePickup( string name = null, string directory = null, string action = null, double? amount = null, string filterTag = null, bool? placeInScene = null, string position = null, double? radius = null, string model = null )
=> McpGate.Run( "create_pickup", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "action", action ), ( "amount", amount ), ( "filterTag", filterTag ), ( "placeInScene", placeInScene ), ( "position", position ), ( "radius", radius ), ( "model", model ) ) );
/// <summary>
/// Generate a ghost-preview + commit placement component (single class). StartPlacing() clones
/// GhostPrefab as a NetworkMode.Never preview with colliders disabled and ModelRenderers tinted
/// semi-transparent. Each frame while placing: ray from Scene.Camera.GetMouseRay(),
/// IgnoreGameObjectHierarchy(ghost), snap hit position to GridSize (0 = freeform), move ghost. On
/// Input.Pressed('attack1') TryPlace() re-validates distance and commits a real clone.
/// StopPlacing() destroys the ghost. Static OnPlaced(GameObject, Vector3) event. Includes a
/// multiplayer RPC note. API grounded in building-placement cookbook (enifun.shop_manager pattern).
/// </summary>
/// <param name="name">Class name. Defaults to 'PlacementMode'.</param>
/// <param name="directory">Subdirectory for the .cs file. Defaults to 'Code'.</param>
/// <param name="gridSize">Snap grid size in world units (0 = freeform placement). Defaults to 0.</param>
/// <param name="targetId">GUID of an existing GameObject to attach to (hotload first).</param>
[McpTool( "create_placement_mode" )]
public static Task<object> CreatePlacementMode( string name = null, string directory = null, double? gridSize = null, string targetId = null )
=> McpGate.Run( "create_placement_mode", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "gridSize", gridSize ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate a player controller script with WASD movement, mouse look, jumping, and sprint.
/// Supports first-person, third-person, and top-down movement modes. Optionally places a player rig
/// (GameObject + CharacterController + Camera) in the scene β note the generated component is
/// attached AFTER a trigger_hotload (it isn't in the TypeLibrary until a recompile).
/// </summary>
/// <param name="name">Class name. Defaults to 'PlayerController'.</param>
/// <param name="directory">Subdirectory under code/ for the file.</param>
/// <param name="type">Movement mode: 'first_person' (mouse-look body+camera, WASD relative to facing), 'third_person' (mouse yaw, WASD relative to facing, boom camera), or 'top_down' (screen-relative WASD, fixed overhead camera, no jump). Defaults to 'first_person'. One of: first_person | third_person | top_down.</param>
/// <param name="moveSpeed">Movement speed in units/sec. Defaults to 300.</param>
/// <param name="jumpForce">Jump force (ignored for top_down). Defaults to 350.</param>
/// <param name="sprintMultiplier">Sprint speed multiplier (held 'run' action). Defaults to 1.5.</param>
/// <param name="placeInScene">If true, build a player rig in the scene: a GameObject (tagged 'player') with a CharacterController and (unless createCamera=false) a Camera. The generated controller component is NOT attached in this call β trigger_hotload then add_component_with_properties on the returned GameObject. Defaults to false (file-only).</param>
/// <param name="createCamera">When placeInScene is true, also create a Camera (FP/TP: child at eye/boom offset; top_down: fixed overhead). Defaults to true.</param>
/// <param name="spawnPosition">When placeInScene is true, the world position to spawn the player rig at β object {x,y,z} or comma string "x,y,z". Defaults to the origin. As "x,y,z" (or JSON {x,y,z}).</param>
[McpTool( "create_player_controller" )]
public static Task<object> CreatePlayerController( string name = null, string directory = null, string type = null, double? moveSpeed = null, double? jumpForce = null, double? sprintMultiplier = null, bool? placeInScene = null, bool? createCamera = null, string spawnPosition = null )
=> McpGate.Run( "create_player_controller", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "type", type ), ( "moveSpeed", moveSpeed ), ( "jumpForce", jumpForce ), ( "sprintMultiplier", sprintMultiplier ), ( "placeInScene", placeInScene ), ( "createCamera", createCamera ), ( "spawnPosition", spawnPosition ) ) );
/// <summary>
/// Generate a host-authoritative round/phase machine: a [Sync(SyncFlags.FromHost)] CurrentPhase
/// cycled through your named phases on a per-phase timer (host-only), with a static OnPhaseChanged
/// event that fires on every machine. Great for round/match flow, match phases, or a day/night
/// cycle. Single-player safe. Optionally attached to an existing GameObject by GUID (after a
/// hotload). Mined from the round-flow pattern across the 51 games.
/// </summary>
/// <param name="name">Class name. Defaults to 'GameDirector'.</param>
/// <param name="directory">Subdirectory for the .cs file. Defaults to 'Code'.</param>
/// <param name="phases">Ordered phase names (become an enum), e.g. ["Lobby","Day","Night","Payout"]. Defaults to ["Lobby","Active","Ended"].</param>
/// <param name="duration">Default seconds per phase (each phase also gets its own tunable [Property]). Defaults to 60.</param>
/// <param name="loop">Loop back to the first phase after the last (true) or hold on the last phase (false). Defaults to true.</param>
/// <param name="targetId">GUID of an existing GameObject to attach to (only if the type is already loaded β hotload first).</param>
[McpTool( "create_round_phase_machine" )]
public static Task<object> CreateRoundPhaseMachine( string name = null, string directory = null, string[] phases = null, double? duration = null, bool? loop = null, string targetId = null )
=> McpGate.Run( "create_round_phase_machine", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "phases", phases ), ( "duration", duration ), ( "loop", loop ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate a host-authoritative MULTI-STATE round machine (the complex variant of
/// create_round_phase_machine). Produces one .cs file: a RoundManager singleton component + an
/// abstract RoundState base (Begin/Tick/OnTimeUp/Finish lifecycle with a per-state
/// [Sync(SyncFlags.FromHost)] TimeUntil timer) + one sealed stub class per named state. The manager
/// auto-attaches the state components on start (you only place the manager), ticks ONLY the active
/// state on the host, Advance()s on timeout with index-wrap, SKIPS any state whose CanEnter()
/// returns false, and announces every transition via a static OnStateChanged event plus an
/// [Rpc.Broadcast] mirror so the host fires immediately and proxies converge without waiting a
/// snapshot (the [Sync] index reconciles late joiners). Single-player safe. USE THIS (not
/// create_round_phase_machine) when each phase needs its OWN behaviour β entry side-effects,
/// per-frame Tick logic, a skip condition, or copy-data-out-on-exit; use the phase machine for 3β5
/// light phases that differ only in duration. Optionally attached to an existing GameObject by GUID
/// (after a hotload).
/// </summary>
/// <param name="name">Manager class name. Defaults to 'RoundManager'. The abstract base is derived from it (RoundManager β RoundState).</param>
/// <param name="directory">Subdirectory for the .cs file (path override). Defaults to 'Code'.</param>
/// <param name="states">Ordered state names β each becomes a sealed {Name}State stub class. Defaults to ["Waiting","Active","PostRound"].</param>
/// <param name="duration">Default seconds each state lasts (each state also gets its own tunable [Property] Duration). 0 = no auto-advance for a state. Defaults to 30.</param>
/// <param name="durations">Optional per-state duration override: an array aligned to `states` ([10,120,8]) OR an object keyed by state name ({"Waiting":10,"Active":120}). Any state not covered falls back to `duration`. JSON value.</param>
/// <param name="loop">Loop back to the first state after the last (true) or hold on the last state (false). Defaults to true.</param>
/// <param name="targetId">GUID of an existing GameObject to attach the manager to (only if the type is already loaded β trigger_hotload first).</param>
[McpTool( "create_round_state_machine" )]
public static Task<object> CreateRoundStateMachine( string name = null, string directory = null, string[] states = null, double? duration = null, JsonNode durations = null, bool? loop = null, string targetId = null )
=> McpGate.Run( "create_round_state_machine", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "states", states ), ( "duration", duration ), ( "durations", durations ), ( "loop", loop ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate a multi-slot save MANAGER component (the slot-picker sibling of create_save_system).
/// Use this when the game needs SEVERAL named save slots the player chooses between (New Game /
/// Load Game menu, per-character or per-run saves) β not one silent autosave. Use
/// create_save_system instead when a single implicit save file is enough. Emits one sealed
/// Component that lists / creates / loads / saves / deletes N slots: a lightweight manifest file
/// (saveslots.json) holds per-slot metadata for the picker (Used flag + Name + SavedAtUnix
/// timestamp + PlaytimeSeconds) so listing never loads a heavy payload, and each slot's game state
/// lives in its own saveslot_<i>.json. Versioned SlotData POCO with clamp-on-load Sanitize()
/// and delete-on-version-mismatch; runs only on the owning machine (IsProxy guard). Static
/// OnSlotLoaded / OnSlotSaved / OnSlotDeleted hooks for HUD. Storage stays within the verified
/// FileSystem.Data.ReadJsonOrDefault / WriteJson / DeleteFile surface (index-file pattern, no
/// directory enumeration). Set sceneReconciliation:true to also reconcile scene objects by
/// GameObject.Id on load β records the save marks destroyed are destroyed, survivors repositioned,
/// missing skipped (good for a placeable-world tycoon). Optionally attached to an existing
/// GameObject by GUID (after a hotload).
/// </summary>
/// <param name="name">Class name for the generated component. Defaults to 'SaveSlotManager'.</param>
/// <param name="directory">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>
/// <param name="maxSlots">How many save slots the manager manages (manifest is normalized to exactly this many, indexed 0..N-1). Clamped to 1..100. Defaults to 3.</param>
/// <param name="sceneReconciliation">If true, saved records carry each object's GameObject.Id GUID and load reconciles the live scene against them (destroy the save's destroyed records via Scene.Directory.FindByGuid, reposition survivors, skip missing) β call RecordObject(go) to track a placeable. If false (default), the slot save is a plain payload with no scene reconciliation. Defaults to false.</param>
/// <param name="targetId">GUID of an existing GameObject to attach the manager to (only attaches if the type is already loaded β hotload first).</param>
[McpTool( "create_save_slots" )]
public static Task<object> CreateSaveSlots( string name = null, string directory = null, int? maxSlots = null, bool? sceneReconciliation = null, string targetId = null )
=> McpGate.Run( "create_save_slots", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "maxSlots", maxSlots ), ( "sceneReconciliation", sceneReconciliation ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate a versioned save-system component: a SaveData POCO with Version bump on schema change,
/// dirty-flag autosave on a TimeUntil timer, clamp-on-load Sanitize() for corrupt/hand-edited
/// saves, and delete-on-version-mismatch to start fresh instead of crashing. Runs only on the
/// owning machine (IsProxy guard). Fires static OnLoaded/OnSaved hooks for HUD and analytics.
/// FileSystem.Data.ReadJsonOrDefault/WriteJson verified live on the current SDK. Optionally
/// attached to an existing GameObject by GUID (after a hotload).
/// </summary>
/// <param name="name">Class name. Defaults to 'SaveSystem'.</param>
/// <param name="directory">Subdirectory for the .cs file. Defaults to 'Code'.</param>
/// <param name="fileName">Save file name under FileSystem.Data (e.g. 'save.json'). Defaults to 'save.json'.</param>
/// <param name="version">Schema version embedded in SaveData. Old saves with a different version start fresh. Defaults to 1.</param>
/// <param name="autosaveSeconds">Seconds between autosave ticks (0 disables autosave). Defaults to 10.</param>
/// <param name="targetId">GUID of an existing GameObject to attach to (only if the type is already loaded -- hotload first).</param>
[McpTool( "create_save_system" )]
public static Task<object> CreateSaveSystem( string name = null, string directory = null, string fileName = null, int? version = null, double? autosaveSeconds = null, string targetId = null )
=> McpGate.Run( "create_save_system", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "fileName", fileName ), ( "version", version ), ( "autosaveSeconds", autosaveSeconds ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate a tamper-evident, versioned save-system component (sealed, owner-only). The SaveData
/// payload POCO is serialized to JSON (Sandbox.Json), FNV-1a-64 hashed over payload + version +
/// salt, and written as a signed envelope { Version, Payload, Signature } to FileSystem.Data.
/// Load() re-verifies: a signature mismatch (hand-edited/corrupt file) triggers a FORCED RESET β
/// the save file is DELETED, defaults are used, and the static OnTampered(reason) event fires
/// (destructive and deliberate; tell the player). A version mismatch starts fresh without the
/// tamper event (add migrations in Load). Loaded values pass a Sanitize() clamp hook so even a
/// re-signed save can't smuggle absurd values. Dirty-flag autosave (autosaveSeconds, default 10;
/// MarkDirty() to arm) + a final save in OnDestroy. HONEST LIMIT: the salt ships inside the game
/// assembly, so this is tamper-EVIDENT (stops notepad edits), NOT cryptographically secure. If you
/// omit salt, a unique random one is baked into the generated file β changing it later invalidates
/// existing saves. Returns { created, path, className, fileName, version, autosaveSeconds,
/// placedOn, note, nextSteps }. Next: trigger_hotload, attach, add your fields to SaveData + clamps
/// to Sanitize(), bump version on shape changes. Refused during play mode. Use create_save_system
/// for a plain unsigned save, create_save_slots for multi-slot UI flows, create_meta_progression
/// for roguelite meta-state.
/// </summary>
/// <param name="name">Class name for the generated component. Defaults to 'SignedSave'.</param>
/// <param name="directory">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>
/// <param name="fileName">FileSystem.Data path the signed envelope is written to. Defaults to 'save_signed.json'.</param>
/// <param name="version">Save-shape version baked into the file and the signature; mismatched files start fresh. Defaults to 1.</param>
/// <param name="salt">Signing salt baked into the generated code. Omit to bake a unique random salt (recommended); changing it later invalidates existing saves.</param>
/// <param name="autosaveSeconds">Dirty-flag autosave cadence in seconds; 0 disables the heartbeat (OnDestroy still saves). Defaults to 10.</param>
/// <param name="targetId">GUID of a save-manager GameObject to attach to (only attaches if the type is already loaded β hotload first).</param>
[McpTool( "create_signed_save" )]
public static Task<object> CreateSignedSave( string name = null, string directory = null, string fileName = null, int? version = null, string salt = null, double? autosaveSeconds = null, string targetId = null )
=> McpGate.Run( "create_signed_save", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "fileName", fileName ), ( "version", version ), ( "salt", salt ), ( "autosaveSeconds", autosaveSeconds ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate a speedrun timer component plus a leaderboard display panel. The timer
/// (<Name>.cs) is TimeSince-based with a static Instance: StartTimer() at run start,
/// StopTimer() at the finish (pairs with a trigger zone), ResetTimer() to abort. StopTimer persists
/// the local best via FileSystem.Data and submits Stats.SetValue(statName, seconds) ONLY when the
/// run beats it β configure the stat with MIN aggregation on sbox.game so the global board keeps
/// best times. The panel (<Name>Panel.razor + .razor.scss, razor_lint clean) fetches via
/// Leaderboards.GetFromStat with min aggregation + ascending sort, has a clickable Friends-only
/// filter button, and overlays a local-best row read from the same save file. Returns { created,
/// path, className, panelRazorPath, panelScssPath, panelClassName, statName, placedOn, note,
/// nextSteps }. After trigger_hotload: place ONE timer (add_component_to_new_object or targetId)
/// and host the panel under a ScreenPanel/WorldPanel (add_screen_panel). maxRows clamps to 1..50;
/// makePanel=false skips the panel files. Fails if the .cs or panel .razor already exists.
/// </summary>
/// <param name="name">Class name for the generated timer component (panel becomes <name>Panel). Defaults to 'SpeedrunTimer'.</param>
/// <param name="directory">Subdirectory for all generated files. Defaults to 'Code'.</param>
/// <param name="statName">Sandbox.Services stat the best time is written to (sanitized to [a-z0-9_-]). Defaults to 'best_time'.</param>
/// <param name="fileName">Save file name inside FileSystem.Data for the local best. Defaults to 'speedrun.json'.</param>
/// <param name="title">Panel title text. Defaults to 'Best Times'.</param>
/// <param name="maxRows">Leaderboard rows fetched/shown. Defaults to 10, clamped to 1..50.</param>
/// <param name="makePanel">Also emit the <name>Panel.razor + .razor.scss display panel. Defaults to true.</param>
/// <param name="targetId">GUID of a GameObject to attach the timer to (only attaches if the type is already loaded β hotload first).</param>
[McpTool( "create_speedrun_leaderboard" )]
public static Task<object> CreateSpeedrunLeaderboard( string name = null, string directory = null, string statName = null, string fileName = null, string title = null, double? maxRows = null, bool? makePanel = null, string targetId = null )
=> McpGate.Run( "create_speedrun_leaderboard", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "statName", statName ), ( "fileName", fileName ), ( "title", title ), ( "maxRows", maxRows ), ( "makePanel", makePanel ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate an enum-keyed stat modifier system with three modifier layers: SET
/// (highest-priority-wins hard override), ADD (summed bonuses), MULT (multiplied factors applied
/// last). Modifier storage uses parallel private Lists of primitive types (serialization-safe).
/// RemoveModifiersFrom(source) cleans up all mods from a buff/debuff source by reference. Static
/// OnStatChanged(stat, value) event fires after every add/remove. Mined from RPG/buff/debuff
/// patterns across shipped s&box games. Returns { created, path, className, stats, placedOn,
/// note } β stats echoes the sanitized stat names ({name}Stat enum values); placedOn is the target
/// GameObject when attached (needs the type hotloaded). Follow with trigger_hotload, then
/// get_compile_errors.
/// </summary>
/// <param name="name">Class name prefix -- generates {name}Stat enum + {name} Component. Defaults to 'StatSystem'.</param>
/// <param name="directory">Subdirectory for the .cs file. Defaults to 'Code'.</param>
/// <param name="stats">Stat names as a JSON array or comma-separated string. Defaults to 'Health,Speed,Damage'. JSON value.</param>
/// <param name="targetId">GUID of an existing GameObject to attach to (hotload first).</param>
[McpTool( "create_stat_modifier_system" )]
public static Task<object> CreateStatModifierSystem( string name = null, string directory = null, JsonNode stats = null, string targetId = null )
=> McpGate.Run( "create_stat_modifier_system", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "stats", stats ), ( "targetId", targetId ) ) );
/// <summary>
/// Generate a host-authoritative balanced team assigner component (smallest-bucket draft):
/// AssignSmallest(steamId) drops a joining player into the emptiest team, announces via
/// [Rpc.Broadcast] so every client's roster agrees, and fires static OnTeamAssigned(steamId, index,
/// name); plus Rebalance(), GetTeam, GetMembers. Writes a .cs file and returns { created, path,
/// className, teams, nextSteps } β follow with trigger_hotload + compile_status, attach to your
/// game manager, call AssignSmallest from your join hook (e.g. INetworkListener.OnActive).
/// </summary>
/// <param name="name">Class/file name (default 'TeamAssigner' -> Code/TeamAssigner.cs). Errors if the file exists.</param>
/// <param name="directory">Directory for the .cs file. Default 'Code'.</param>
/// <param name="teams">Team names in index order. Default ["Red", "Blue"].</param>
[McpTool( "create_team_assigner" )]
public static Task<object> CreateTeamAssigner( string name = null, string directory = null, string[] teams = null )
=> McpGate.Run( "create_team_assigner", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "teams", teams ) ) );
/// <summary>
/// Generate a trigger-zone Component (Component.ITriggerListener): auto-adds a trigger BoxCollider
/// on start, filters entrants by a TriggerTag [Property] (default 'player'), and logs enter/exit
/// via private OnPlayerEnter/OnPlayerExit extension points you fill in. Writes <name>.cs and
/// returns { created, path, className }. NOTE: the action/filterTag params are not currently
/// applied at generation time β the zone always logs; implement teleport/damage/spawn in the
/// generated methods (edit_script). Follow with trigger_hotload, then get_compile_errors.
/// </summary>
/// <param name="name">Class name. Defaults to 'TriggerZone'.</param>
/// <param name="directory">Subdirectory under code/ for the file.</param>
/// <param name="action">What happens on trigger (currently not applied by the handler β the generated zone always logs; implement the effect in OnPlayerEnter yourself). One of: log | teleport | damage | spawn.</param>
/// <param name="filterTag">Only trigger for objects with this tag (currently not applied at generation β the generated TriggerTag [Property] defaults to 'player'; change it per-instance with set_property).</param>
[McpTool( "create_trigger_zone" )]
public static Task<object> CreateTriggerZone( string name = null, string directory = null, string action = null, string filterTag = null )
=> McpGate.Run( "create_trigger_zone", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "action", action ), ( "filterTag", filterTag ) ) );
/// <summary>
/// Generate a cumulative-weight random loot picker: parallel Name/Weight lists
/// (inspector-editable), a Roll() method that returns a winning entry name and fires a static
/// OnLoot event, and optional pity (guarantee the last/rarest entry after PityAfter consecutive
/// non-rare rolls). Roll() is host-authoritative -- only call it on the host and replicate the
/// result (clients rolling their own loot is equivalent to clients writing their own money
/// balance). Optionally attached to an existing GameObject by GUID (after a hotload).
/// </summary>
/// <param name="name">Class name. Defaults to 'LootTable'.</param>
/// <param name="directory">Subdirectory for the .cs file. Defaults to 'Code'.</param>
/// <param name="entries">Loot table entries. Defaults to common:70 / uncommon:25 / rare:5. JSON value.</param>
/// <param name="pity">If true, guarantee the last (rarest) entry after PityAfter consecutive non-rare rolls. Defaults to false.</param>
/// <param name="targetId">GUID of an existing GameObject to attach to (only if the type is already loaded -- hotload first).</param>
[McpTool( "create_weighted_loot_table" )]
public static Task<object> CreateWeightedLootTable( string name = null, string directory = null, JsonNode entries = null, bool? pity = null, string targetId = null )
=> McpGate.Run( "create_weighted_loot_table", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "entries", entries ), ( "pity", pity ), ( "targetId", targetId ) ) );
/// <summary>
/// Scaffold an end-of-round map vote. Three files: <Name>.cs (sealed host-authoritative
/// controller) + <Name>Panel.razor + <Name>Panel.razor.scss (vote UI: one button per
/// map, live tallies, countdown, own-pick highlight, winner banner). Flow: host calls StartVote()
/// (usually from a post-round phase/state, or set the AutoStart [Property]) -> clients click
/// -> votes route client-to-host via [Rpc.Host] SubmitVote with the caller re-resolved HOST-SIDE
/// from Rpc.Caller (null-checked β Connection has no IsValid on this SDK) and the map index
/// re-validated (re-votes overwrite, keyed by SteamId) -> tallies replicate via [Sync(FromHost)]
/// NetList<int> -> when the [Sync] TimeUntil 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 calls Scene.LoadFromFile(winner) (API
/// verified live on this SDK; clients follow via the scene networking layer β verify the client
/// hand-off in a real multi-client session). Static event OnVoteFinished(sceneFile) fires on every
/// machine. Returns { created, componentPath, razorPath, scssPath, className, panelClassName, maps,
/// voteDurationSeconds, resultLingerSeconds, autoStart, note, nextSteps }. REQUIREMENTS: the
/// controller must sit on a NETWORK-SPAWNED object in multiplayer or [Sync] never replicates; if
/// maps is omitted the MapScenes list is generated EMPTY and StartVote() refuses with a warning
/// until you fill it in the inspector. Follow with trigger_hotload, attach via
/// add_component_with_properties, host the panel under add_screen_panel.
/// </summary>
/// <param name="name">Class name for the controller; the panel is generated as <Name>Panel. Defaults to 'MapVote'.</param>
/// <param name="directory">Subdirectory for the generated .cs + .razor + .razor.scss. Defaults to 'Code'.</param>
/// <param name="maps">Scene files to vote between, e.g. ["scenes/arena.scene", "scenes/docks.scene"] (find them with list_scenes). Baked into the MapScenes [Property] list, editable later in the inspector. Defaults to an EMPTY list (StartVote() then refuses until it's filled).</param>
/// <param name="voteDurationSeconds">Seconds the vote stays open once StartVote() is called (clamped to >= 3). Defaults to 20.</param>
/// <param name="resultLingerSeconds">Seconds the winner banner shows before the host loads the winning scene (clamped to >= 0). Defaults to 4.</param>
/// <param name="autoStart">Start the vote automatically on spawn (host only). Usually false β call StartVote() from your round machine's post-round state instead. Defaults to false.</param>
[McpTool( "scaffold_map_vote_flow" )]
public static Task<object> ScaffoldMapVoteFlow( string name = null, string directory = null, string[] maps = null, double? voteDurationSeconds = null, double? resultLingerSeconds = null, bool? autoStart = null )
=> McpGate.Run( "scaffold_map_vote_flow", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "maps", maps ), ( "voteDurationSeconds", voteDurationSeconds ), ( "resultLingerSeconds", resultLingerSeconds ), ( "autoStart", autoStart ) ) );
}
Editor
library
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;
// =============================================================================
// Networking primitives pack (v1.20.0, Track B) -- four multiplayer scaffolds
// (code-gen; scene-mutating):
//
// create_host_rpc_action validated + rate-limited [Rpc.Host] action skeleton
// add_targeted_rpc Rpc.FilterInclude single-client (unicast) side-effect
// create_local_player_resolver proxy-safe "who is MY player" resolver (online + offline)
// add_host_migration_recovery proxy->authority transition detector + OnBecameHost hook
//
// Compiles into the SAME editor assembly as MyEditorMenu.cs / ScaffoldHandlers.cs,
// so it reuses the shared statics on ClaudeBridge (TryResolveProjectPath,
// SanitizeIdentifier, SerializeGo) and ScaffoldHelpers (PrepareCodeFile /
// WriteCode / Utf8NoBom). Handler code here is UNSANDBOXED editor code.
//
// The C# *strings these handlers WRITE TO DISK* are SANDBOXED game code and must
// obey the s&box sandbox rules:
// - System.Math/MathF/MathX all compile on the current SDK; Array.Clone() is
// whitelist-blocked (not used here).
// - Fully-qualify System.Collections.Generic.Dictionary (dodges a missing using).
// - TimeSince/TimeUntil for timers; float literals formatted InvariantCulture + 'f'.
// - Guard Networking access: check Networking.IsActive before Networking.IsHost
// (IsHost can throw with no session). Rpc.Caller re-resolved host-side, never
// trusting client args for identity.
// - VERIFIED live against the installed SDK before codegen (describe_type +
// networking-authority cookbook): Connection.Local (static), Connection.All,
// Connection.SteamId (NOTE: Connection has NO IsValid member on this SDK β
// null-check it; caught live by the v1.20.0 verify-gate), Rpc.Caller (Connection) / Rpc.CallerId (Guid),
// Rpc.FilterInclude(Connection) -> IDisposable, GameObject.Network (NetworkAccessor)
// -> Owner (Connection) / OwnerId (Guid) / IsOwner / IsProxy, [Sync(SyncFlags.FromHost)],
// [Rpc.Host] / [Rpc.Broadcast], (ulong)SteamId cast.
//
// Register(...) lines + the _sceneMutatingCommands additions live in
// MyEditorMenu.cs (Batch 45) to keep the files decoupled.
// =============================================================================
// -----------------------------------------------------------------------------
// create_host_rpc_action -- the validated, rate-limited host-action skeleton.
//
// The safe answer to "a client asks the host to DO something": a client-callable
// Request() forwards to an [Rpc.Host] body that re-resolves the caller via
// Rpc.Caller (NEVER trusting client args for identity), enforces a per-SteamId
// cooldown from a Dictionary<ulong, TimeSince>, runs a clearly-marked TODO hook,
// and fires a static OnActionExecuted event. Covers the backlog's
// add_rate_limited_rpc.
// -----------------------------------------------------------------------------
public class CreateHostRpcActionHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
if ( !ScaffoldHelpers.PrepareCodeFile( p, "HostRpcAction", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
float cooldown = p.TryGetProperty( "cooldownSeconds", out var cv ) && cv.TryGetSingle( out var cf ) ? cf : 1f;
if ( cooldown < 0f ) cooldown = 0f; // a negative cooldown would emit nonsense
var code = BuildCode( className, cooldown, ci );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = NetPrimitivesHelpers.PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
cooldownSeconds = cooldown,
placedOn,
note,
nextSteps = new[]
{
$"trigger_hotload to compile {className} into the game assembly.",
placedOn != null
? $"{className} was attached to the target GameObject."
: $"Attach it to the object that owns this action (a player, a station, or your game manager): add_component_with_properties (component=\"{className}\") after the hotload, or re-run with targetId.",
$"Fire it from the owning client (input / UI button): GetComponent<{className}>()?.Request(); -- it routes to the host, which re-validates and rate-limits.",
$"Fill in the TODO host block with your authoritative action (spend currency, NetworkSpawn, grant a reward). Re-clamp any gameplay args there -- forged client args bypass NetFlags.",
$"React to accepted actions: {className}.OnActionExecuted += conn => Log.Info( $\"action by {{conn.DisplayName}}\" ); (fires on the host). Wrap an [Rpc.Broadcast] if every client should react.",
"Tune CooldownSeconds with set_property. The per-SteamId cooldown is host-only runtime state (not [Sync])."
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_host_rpc_action failed: {ex.Message}" } );
}
}
static string BuildCode( string className, float cooldown, System.Globalization.CultureInfo ci )
{
string cd = cooldown.ToString( ci ) + "f";
return $@"using Sandbox;
using System;
/// <summary>
/// {className} -- a validated, rate-limited host action. The safe skeleton for
/// ""a client asks the host to DO something"" (buy, use, vote, interact).
///
/// Flow: client calls Request() -> [Rpc.Host] SubmitRequest() runs ON THE HOST
/// -> host re-resolves WHO called it via Rpc.Caller (never trusts client
/// args for identity) -> enforces a per-SteamId cooldown -> runs your
/// host-authoritative action -> fires OnActionExecuted.
///
/// [Rpc.Host] is callable by ANY client with forged args -- NetFlags restrict who
/// may INVOKE, which is not security. That is why identity + cooldown + your
/// validation all live INSIDE the host body. Single-player safe (no session -> the
/// RPC just runs locally; the caller falls back to Connection.Local).
///
/// Usage:
/// GetComponent<{className}>()?.Request(); // from input / a UI button, on the owning client
/// {className}.OnActionExecuted += conn => Log.Info( $""action by {{conn.DisplayName}}"" );
/// </summary>
public sealed class {className} : Component
{{
/// <summary>Minimum seconds between accepted requests, per calling player.</summary>
[Property] public float CooldownSeconds {{ get; set; }} = {cd};
/// <summary>Fires ON THE HOST after an accepted request. Arg = the validated caller.</summary>
public static Action<Connection> OnActionExecuted {{ get; set; }}
// Host-only runtime state: last-accept time keyed by the caller's SteamId.
// NOT [Sync] -- it is the host's own rate-limit bookkeeping, never replicated.
private readonly System.Collections.Generic.Dictionary<ulong, TimeSince> _cooldowns = new();
/// <summary>
/// Client entry point. Call this on the owning client (input handler / UI button).
/// It routes to the host; do NOT put authoritative logic here -- a client controls
/// this machine and could call anything. The real work happens host-side.
/// </summary>
public void Request()
{{
SubmitRequest(); // [Rpc.Host] -- executes on the host (or locally in solo)
}}
/// <summary>
/// Host-authoritative handler. Public so the RPC source generator is happy; the
/// re-validation below is what actually protects it. NEVER trust args passed from
/// the client for identity -- re-resolve the caller here.
/// </summary>
[Rpc.Host]
public void SubmitRequest()
{{
// Re-resolve the caller SERVER-SIDE. Read Rpc.Caller only when a session is
// active (offline it is meaningless); fall back to us in solo.
var caller = Networking.IsActive ? Rpc.Caller : Connection.Local;
if ( caller == null ) caller = Connection.Local;
if ( caller == null ) return; // no identity at all -- refuse
// FOOTGUN (some SDK builds): Rpc.Caller can return the HOST's own connection
// for a proxy-initiated call. If identity is security-critical, resolve the
// acting player from the OWNING component's Network.Owner instead.
ulong callerId = (ulong)caller.SteamId;
// Per-SteamId rate limit -- spamming the RPC cannot bypass the cooldown.
if ( _cooldowns.TryGetValue( callerId, out var since ) && since < CooldownSeconds )
return; // still cooling down for this caller
_cooldowns[callerId] = 0f; // reset this caller's timer
// --- TODO: your host-authoritative action goes here ---------------------
// Runs ONLY on the host. Re-validate + re-clamp any gameplay values, then
// mutate [Sync(SyncFlags.FromHost)] state / NetworkSpawn() / grant rewards.
// Example: GetComponent<Wallet>()?.AddMoney( 10 );
// ------------------------------------------------------------------------
OnActionExecuted?.Invoke( caller );
}}
}}
";
}
}
// -----------------------------------------------------------------------------
// add_targeted_rpc -- the Rpc.FilterInclude single-client (unicast) pattern.
//
// A host-side SendTo(Connection, string) wraps an [Rpc.Broadcast] call in
// using ( Rpc.FilterInclude( target ) ) so ONLY that one connection executes the
// body, which raises a static OnReceived event.
// -----------------------------------------------------------------------------
public class AddTargetedRpcHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "TargetedRpc", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
var code = BuildCode( className );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = NetPrimitivesHelpers.PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
placedOn,
note,
nextSteps = new[]
{
$"trigger_hotload to compile {className} into the game assembly.",
placedOn != null
? $"{className} was attached to the target GameObject."
: $"Attach it to a networked manager object: add_component_with_properties (component=\"{className}\") after the hotload, or re-run with targetId. The object must be NetworkSpawn'd for the RPC to route.",
$"Send to ONE player from the host: GetComponent<{className}>()?.SendTo( player.Network.Owner, \"You're up next!\" ); -- only that client runs the body.",
$"Receive on the target: {className}.OnReceived += msg => ShowToast( msg ); -- fires only on the filtered client (and locally in solo).",
"Use this instead of [Rpc.Broadcast] + a client-side 'is this for me?' check -- FilterInclude scopes it server-side, so no data leaks and no wasted bandwidth."
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"add_targeted_rpc failed: {ex.Message}" } );
}
}
static string BuildCode( string className )
{
return $@"using Sandbox;
using System;
/// <summary>
/// {className} -- send a message to exactly ONE client using Rpc.FilterInclude.
///
/// A normal [Rpc.Broadcast] runs on EVERY machine. Wrapping the call in
/// using ( Rpc.FilterInclude( target ) ) scopes it server-side so ONLY the target
/// connection executes the RPC body -- the right way to unicast (a private prompt,
/// a personal reward toast, a per-player cutscene) instead of broadcasting to all
/// and filtering on the client (which leaks data + wastes bandwidth).
///
/// Call SendTo on the host. Single-player safe (with no session it just runs locally).
///
/// Usage (host-side):
/// GetComponent<{className}>()?.SendTo( somePlayer.Network.Owner, ""You're up next!"" );
/// {className}.OnReceived += msg => Log.Info( $""(only me) {{msg}}"" );
/// </summary>
public sealed class {className} : Component
{{
/// <summary>Fires on the TARGET client only (and locally in solo) when a message arrives.</summary>
public static Action<string> OnReceived {{ get; set; }}
/// <summary>
/// Host-side: deliver <paramref name=""message""/> to exactly one connection.
/// FilterInclude scopes the broadcast so only <paramref name=""target""/> runs it.
/// </summary>
public void SendTo( Connection target, string message )
{{
if ( target == null ) return;
// Only the host should originate a targeted message in a host-authoritative
// game. Guarded behind IsActive because Networking.IsHost can throw with no
// session; in solo this falls through and just runs locally.
if ( Networking.IsActive && !Networking.IsHost ) return;
using ( Rpc.FilterInclude( target ) )
Receive( message );
}}
/// <summary>
/// The unicast body. Public so the RPC source generator is happy. Runs ONLY on the
/// filtered target connection (FilterInclude decided that server-side).
/// </summary>
[Rpc.Broadcast]
public void Receive( string message )
{{
OnReceived?.Invoke( message );
}}
}}
";
}
}
// -----------------------------------------------------------------------------
// create_local_player_resolver -- proxy-safe "who is MY player".
//
// Static Local property that lazily finds the player GameObject owned by the local
// connection ( Network.Owner == Connection.Local, or Network.IsOwner ) when
// networking is active, and falls back to the first/only tagged player when it is
// NOT (offline/solo). Cached with an IsValid() revalidation. The corpus footgun
// killer -- running "my player" logic against a proxy of someone else's player.
// -----------------------------------------------------------------------------
public class CreateLocalPlayerResolverHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "LocalPlayerResolver", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
var tag = p.TryGetProperty( "playerTag", out var tv ) && !string.IsNullOrWhiteSpace( tv.GetString() )
? tv.GetString().Trim() : "player";
var tagLiteral = NetPrimitivesHelpers.EscapeStringLiteral( tag );
var code = BuildCode( className, tagLiteral );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = NetPrimitivesHelpers.PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
playerTag = tag,
placedOn,
note,
nextSteps = new[]
{
$"trigger_hotload to compile {className} into the game assembly.",
placedOn != null
? $"{className} was attached to the target GameObject."
: $"Attach ONE to a persistent object (your game manager): add_component_with_properties (component=\"{className}\") after the hotload, or re-run with targetId. Placing it lets you set PlayerTag in the inspector.",
$"Tag each player GameObject with \"{tag}\" (set_tags) so the resolver can find them.",
$"Read your player from anywhere: var me = {className}.Local; -- online it is the object you OWN, offline it is the only player. Cached + revalidated automatically.",
$"Filter events to your own player: if ( {className}.IsLocal( someGameObject ) ) {{ ... }} -- kills the 'ran my UI/logic against a proxy' footgun."
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_local_player_resolver failed: {ex.Message}" } );
}
}
static string BuildCode( string className, string tagLiteral )
{
return $@"using Sandbox;
using System;
/// <summary>
/// {className} -- ""who is MY player?"", the proxy-safe way. Resolves the player
/// GameObject that belongs to THIS machine, both online and offline.
///
/// Online: your player is the tagged object whose Network.Owner is the local
/// connection ( Network.Owner == Connection.Local, or Network.IsOwner ). Offline /
/// solo (no session), there is exactly one player, so it returns the first tagged
/// object. The result is cached and revalidated with IsValid() so a destroyed /
/// respawned player is re-resolved automatically.
///
/// Attach ONE of these to a persistent object (your game manager) so PlayerTag is
/// configurable; the resolver itself is static and callable from anywhere:
/// var me = {className}.Local; // my player GameObject (or null)
/// if ( {className}.IsLocal( someGo ) ) ... // filter events to my own player
///
/// This kills the #1 multiplayer footgun -- running ""my player"" logic against a
/// proxy of someone else's player.
/// </summary>
public sealed class {className} : Component
{{
/// <summary>Tag that marks a player GameObject. Players must carry this tag.</summary>
[Property] public string PlayerTag {{ get; set; }} = ""{tagLiteral}"";
private static {className} _instance;
private static string _tag = ""{tagLiteral}"";
private static GameObject _cached;
protected override void OnEnabled()
{{
_instance = this;
_tag = PlayerTag;
}}
protected override void OnDisabled()
{{
if ( _instance == this ) _instance = null;
}}
/// <summary>The local machine's player GameObject, or null if not found yet.</summary>
public static GameObject Local
{{
get
{{
if ( IsLocal( _cached ) ) return _cached; // cache hit, still valid + still ours
_cached = Resolve();
return _cached;
}}
}}
/// <summary>True if <paramref name=""go""/> is the local machine's player.</summary>
public static bool IsLocal( GameObject go )
{{
if ( !go.IsValid() ) return false;
if ( !Networking.IsActive ) return true; // solo: the only player is mine
return go.Network.Owner == Connection.Local || go.Network.IsOwner;
}}
private static GameObject Resolve()
{{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return null;
if ( !Networking.IsActive )
{{
// Offline / solo: the first tagged player is ours.
foreach ( var go in scene.GetAllObjects( true ) )
if ( go.Tags.Has( _tag ) ) return go;
return null;
}}
// Online: our player is the tagged object owned by the local connection.
foreach ( var go in scene.GetAllObjects( true ) )
{{
if ( !go.Tags.Has( _tag ) ) continue;
if ( go.Network.Owner == Connection.Local || go.Network.IsOwner )
return go;
}}
return null;
}}
}}
";
}
}
// -----------------------------------------------------------------------------
// add_host_migration_recovery -- proxy->authority transition detector.
//
// Tracks previous IsProxy each frame; when it flips from true to false (we became
// the authority for this object, i.e. host migration promoted us), it fires a
// static OnBecameHost event and runs a virtual-style TODO rebuild hook, then -- a
// short settle delay later -- a deferred validation hook. Inert offline (IsProxy
// is always false with no session).
// -----------------------------------------------------------------------------
public class AddHostMigrationRecoveryHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
if ( !ScaffoldHelpers.PrepareCodeFile( p, "HostMigrationRecovery", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
// Settle delay is fixed at the cookbook-recommended ~1s but exposed as a
// [Property] so it is tunable; no param for it (keeps the schema to name/directory).
float settle = 1f;
var code = BuildCode( className, settle, ci );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = NetPrimitivesHelpers.PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
placedOn,
note,
nextSteps = new[]
{
$"trigger_hotload to compile {className} into the game assembly.",
placedOn != null
? $"{className} was attached to the target GameObject."
: $"Attach it to your host-authoritative manager object: add_component_with_properties (component=\"{className}\") after the hotload, or re-run with targetId. The object should be NetworkSpawn'd.",
$"React to becoming host: {className}.OnBecameHost += go => Log.Info( \"I am the host now -- rebuilding\" );",
"Fill in the RebuildAfterMigration() TODO region: re-arm host-only loops/timers against your clock, TakeOwnership of orphans, rebuild handle maps by world position, reconcile your [Sync] registry against the real scene.",
"Fill in the deferred ValidateAfterMigration() TODO: sanity-check expected-vs-actual and hard-reset the round if it looks corrupt (SettleSeconds delay lets in-flight packets land first).",
"Requires a real host migration to fire (a second client that becomes host when the first leaves) -- it is inert in solo/offline play."
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"add_host_migration_recovery failed: {ex.Message}" } );
}
}
static string BuildCode( string className, float settle, System.Globalization.CultureInfo ci )
{
string st = settle.ToString( ci ) + "f";
return $@"using Sandbox;
using System;
/// <summary>
/// {className} -- detects when THIS machine takes authority over this object
/// (proxy -> owner), which is what happens to a host-authoritative manager during
/// host migration, and gives you a clean hook to rebuild host-only state.
///
/// It tracks IsProxy each frame; when it flips from true (someone else was the
/// authority) to false (now it is us), it fires OnBecameHost and runs the rebuild
/// hook, then -- after a short settle delay so in-flight packets can land -- runs a
/// deferred validation hook. Inert offline (IsProxy is always false with no session).
///
/// Attach to your host-authoritative manager object. Fill in the two TODO regions.
///
/// Usage:
/// {className}.OnBecameHost += go => Log.Info( ""I am the host now -- rebuilding"" );
/// </summary>
public sealed class {className} : Component
{{
/// <summary>Seconds to wait after becoming host before the deferred validation runs.</summary>
[Property] public float SettleSeconds {{ get; set; }} = {st};
/// <summary>Fires on the machine that just gained authority. Arg = this GameObject.</summary>
public static Action<GameObject> OnBecameHost {{ get; set; }}
private bool _wasProxy;
private bool _initialized;
private bool _pendingValidate;
private TimeSince _sinceBecameHost;
protected override void OnEnabled()
{{
_wasProxy = IsProxy; // baseline so we only fire on a real transition
_initialized = true;
}}
protected override void OnUpdate()
{{
bool proxyNow = IsProxy;
if ( _initialized && _wasProxy && !proxyNow )
BecameHost();
_wasProxy = proxyNow;
if ( _pendingValidate && _sinceBecameHost > SettleSeconds )
{{
_pendingValidate = false;
ValidateAfterMigration();
}}
}}
private void BecameHost()
{{
_sinceBecameHost = 0f;
_pendingValidate = true;
RebuildAfterMigration();
OnBecameHost?.Invoke( GameObject );
}}
// virtual-style rebuild hook -- edit this body (the component is sealed, so there
// is nothing to override; this region IS your override point).
private void RebuildAfterMigration()
{{
// TODO: rebuild host-only state now that YOU are the authority. The previous
// host is gone; anything it owned or was mid-computing is now your job. Typical
// moves (networking-authority cookbook, pattern 17):
// - Re-arm host-only loops / spawners. A [Sync] TimeUntil stores the DEAD
// host's clock epoch -- read its .Relative remaining and re-arm it here.
// - Network.TakeOwnership() any orphaned objects you must now manage/destroy.
// - Rebuild handle->handle maps by world-position matching (object Ids do not
// survive migration).
// - Reconcile your [Sync] registry against the REAL scene (drop dead entries,
// add visible objects the list is missing).
}}
// deferred sanity check -- runs SettleSeconds after becoming host so in-flight
// packets that have not applied yet do not make a healthy scene look broken.
private void ValidateAfterMigration()
{{
// TODO: compare expected-vs-actual (child counts, roster tags) and hard-reset
// the round rather than limping along if it looks corrupt. (cookbook pattern 17)
}}
}}
";
}
}
/// <summary>
/// Shared helpers for the networking-primitives handlers -- mirrors the standard
/// scaffold placement (GameFeelHelpers / create_event_director) plus a tiny
/// string-literal escaper for baked-in tag defaults.
/// </summary>
internal static class NetPrimitivesHelpers
{
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; }
}
/// <summary>Escape a user string so it can be baked as a C# double-quoted literal.</summary>
public static string EscapeStringLiteral( string s )
{
if ( string.IsNullOrEmpty( s ) ) return "player";
return s.Replace( "\\", "\\\\" ).Replace( "\"", "\\\"" );
}
}
Editor
library
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Batch 51 β Project audit & batch operations (v2 relaunch wave 1)
// find_broken_references β scene-wide broken/dead reference scan
// batch_set_property β one property across many objects, with dry-run
// describe_project β one-call project orientation summary
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
/// <summary>
/// find_broken_references β scan the open scene for null models on renderers,
/// destroyed-but-still-referenced GameObjects/Components in component properties,
/// and unresolvable (null) component entries.
/// </summary>
public class FindBrokenReferencesHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
int limit = p.TryGetProperty( "limit", out var l ) ? l.GetInt32() : 100;
if ( limit < 1 ) limit = 1; if ( limit > 500 ) limit = 500;
var issues = new List<object>();
int total = 0;
int objectsScanned = 0;
void AddIssue( GameObject go, string component, string kind, string detail )
{
total++;
if ( issues.Count < limit )
issues.Add( new { id = go.Id.ToString(), name = go.Name, component, kind, detail } );
}
foreach ( var go in scene.GetAllObjects( true ) )
{
if ( go == null ) continue;
objectsScanned++;
foreach ( var comp in go.Components.GetAll() )
{
if ( comp == null )
{
AddIssue( go, "(null)", "missing_component", "Component entry is null β its type may no longer exist/compile" );
continue;
}
if ( comp is ModelRenderer mr && mr.Model == null )
AddIssue( go, comp.GetType().Name, "missing_model", "Renderer has no Model assigned" );
// Destroyed-but-referenced objects/components: a null ref is usually a
// legitimate 'unset optional', but a ref to a DESTROYED thing is broken.
var typeDesc = Game.TypeLibrary.GetType( comp.GetType().Name );
if ( typeDesc == null ) continue;
foreach ( var prop in typeDesc.Properties )
{
var pt = prop.PropertyType;
bool isGo = pt == typeof( GameObject );
bool isComp = typeof( Component ).IsAssignableFrom( pt );
if ( !isGo && !isComp ) continue;
object val;
try { val = prop.GetValue( comp ); }
catch { continue; }
if ( val == null ) continue;
if ( val is GameObject g && !g.IsValid() )
AddIssue( go, comp.GetType().Name, "dead_gameobject_ref", $"{prop.Name} references a destroyed GameObject" );
else if ( val is Component c && !c.IsValid() )
AddIssue( go, comp.GetType().Name, "dead_component_ref", $"{prop.Name} references a destroyed Component" );
}
}
}
// v2 round 2: scan .scene/.prefab FILES for prefab references to files that no
// longer exist ({"_type":"gameobject","prefab":"prefabs/x.prefab"} with x deleted
// or renamed) β the break class scene-level checks can't see.
int filesScanned = 0;
bool scanFiles = !( p.TryGetProperty( "scanFiles", out var sf ) && sf.ValueKind == JsonValueKind.False );
if ( scanFiles )
{
try
{
var root = Project.Current?.GetRootPath();
if ( root != null )
{
var rx = new System.Text.RegularExpressions.Regex( "\"prefab\":\\s*\"([^\"]+)\"" );
var files = Directory.GetFiles( root, "*.scene", SearchOption.AllDirectories )
.Concat( Directory.GetFiles( root, "*.prefab", SearchOption.AllDirectories ) )
.Where( f => { var r = Path.GetRelativePath( root, f ).Replace( '\\', '/' ); return !r.StartsWith( "Libraries/" ) && !r.StartsWith( ".sbox/" ); } );
foreach ( var file in files )
{
filesScanned++;
var rel = Path.GetRelativePath( root, file ).Replace( '\\', '/' );
foreach ( System.Text.RegularExpressions.Match m in rx.Matches( File.ReadAllText( file ) ) )
{
var refPath = m.Groups[1].Value;
bool exists = File.Exists( Path.Combine( root, refPath ) )
|| File.Exists( Path.Combine( root, "Assets", refPath ) );
if ( !exists )
{
total++;
if ( issues.Count < limit )
issues.Add( new { id = (string)null, name = rel, component = "(file)", kind = "missing_prefab_file", detail = $"references '{refPath}' which does not exist in the project" } );
}
}
}
}
}
catch { /* file scan is best-effort β scene checks above already reported */ }
}
return Task.FromResult<object>( new
{
total,
showing = issues.Count,
truncated = total > issues.Count,
objectsScanned,
filesScanned,
issues,
note = total == 0
? "No broken references found."
: "Fix missing_model with assign_model; clear dead refs with set_property (value null) or set_component_reference to a live target; missing_prefab_file means a .scene/.prefab references a deleted/renamed prefab β fix the path or recreate it with create_prefab."
} );
}
}
/// <summary>
/// batch_set_property β set one component property to the same value across many
/// GameObjects, with a dry-run mode that validates and reports without applying.
/// </summary>
public class BatchSetPropertyHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
if ( !p.TryGetProperty( "ids", out var idsEl ) || idsEl.ValueKind != JsonValueKind.Array || idsEl.GetArrayLength() == 0 )
return Task.FromResult<object>( new { error = "ids (non-empty array of GameObject GUIDs) is required" } );
var componentType = p.TryGetProperty( "component", out var ct ) ? ct.GetString() : null;
if ( string.IsNullOrWhiteSpace( componentType ) )
return Task.FromResult<object>( new { error = "component (type name) is required" } );
var propertyName = p.TryGetProperty( "property", out var pn ) ? pn.GetString() : null;
if ( string.IsNullOrWhiteSpace( propertyName ) )
return Task.FromResult<object>( new { error = "property (name) is required" } );
if ( !p.TryGetProperty( "value", out var valueEl ) )
return Task.FromResult<object>( new { error = "value is required" } );
bool dryRun = p.TryGetProperty( "dryRun", out var dr ) && dr.ValueKind == JsonValueKind.True;
var results = new List<object>();
int succeeded = 0, failed = 0, changed = 0, unchanged = 0;
foreach ( var idEl in idsEl.EnumerateArray() )
{
var id = idEl.GetString();
void Fail( string why ) { failed++; results.Add( new { id, ok = false, error = why } ); }
var go = ClaudeBridge.ResolveGameObject( scene, id );
if ( go == null ) { Fail( "GameObject not found" ); continue; }
var component = go.Components.GetAll()
.FirstOrDefault( c => c != null && c.GetType().Name.Equals( componentType, StringComparison.OrdinalIgnoreCase ) );
if ( component == null ) { Fail( $"No '{componentType}' component" ); continue; }
var typeDesc = Game.TypeLibrary.GetType( component.GetType().Name );
var propDesc = typeDesc?.Properties.FirstOrDefault( pp => pp.Name.Equals( propertyName, StringComparison.OrdinalIgnoreCase ) );
if ( propDesc == null ) { Fail( $"Property '{propertyName}' not found on {componentType}" ); continue; }
object current = null;
try { current = propDesc.GetValue( component ); } catch { }
// Resolve the exact typed value before either a dry-run receipt or a write.
// Previously dry-run skipped coercion entirely and always claimed a change.
object proposed = null;
var valueStr = ClaudeBridge.ElementToValueString( valueEl );
if ( !ClaudeBridge.CoercePropertyAndSet(
propDesc.PropertyType,
v => proposed = v,
propDesc.Name,
valueStr,
out var coerceError ) )
{
Fail( coerceError );
continue;
}
bool wouldChange = !Equals( current, proposed );
if ( dryRun )
{
succeeded++;
if ( wouldChange ) changed++; else unchanged++;
results.Add( new
{
id,
ok = true,
wouldChange,
currentValue = current?.ToString(),
proposedValue = proposed?.ToString()
} );
continue;
}
try
{
// Keep apply aligned with dry-run and avoid needless setter side effects.
if ( !wouldChange )
{
succeeded++;
unchanged++;
results.Add( new { id, ok = true, changed = false, previous = current?.ToString(), value = proposed?.ToString() } );
continue;
}
propDesc.SetValue( component, proposed );
succeeded++;
changed++;
results.Add( new { id, ok = true, changed = true, previous = current?.ToString(), value = proposed?.ToString() } );
}
catch ( Exception ex )
{
Fail( ex.Message );
}
}
return Task.FromResult<object>( new
{
total = results.Count,
succeeded,
failed,
dryRun,
changed,
unchanged,
results,
note = dryRun
? $"Dry run - nothing was changed. {changed} would change; {unchanged} already match."
: $"Applied {changed} change(s); {unchanged} object(s) already matched; {failed} failed."
} );
}
}
/// <summary>
/// describe_project β a one-call orientation summary: project identity, scenes,
/// prefabs, code footprint, custom component types, and installed libraries.
/// </summary>
public class DescribeProjectHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var project = Project.Current;
if ( project == null )
return Task.FromResult<object>( new { error = "No current project" } );
var root = project.GetRootPath();
var scene = SceneEditorSession.Active?.Scene;
string[] Rel( IEnumerable<string> paths, int cap ) =>
paths.Select( f => Path.GetRelativePath( root, f ).Replace( '\\', '/' ) )
.Where( f => !f.StartsWith( "Libraries/" ) && !f.StartsWith( ".sbox/" ) )
.Take( cap ).ToArray();
string[] scenes = Array.Empty<string>(), prefabs = Array.Empty<string>();
int codeFiles = 0, razorFiles = 0;
try { scenes = Rel( Directory.GetFiles( root, "*.scene", SearchOption.AllDirectories ), 50 ); } catch { }
try { prefabs = Rel( Directory.GetFiles( root, "*.prefab", SearchOption.AllDirectories ), 50 ); } catch { }
try { codeFiles = Directory.GetFiles( Path.Combine( root, "Code" ), "*.cs", SearchOption.AllDirectories ).Length; } catch { }
try { razorFiles = Directory.GetFiles( Path.Combine( root, "Code" ), "*.razor", SearchOption.AllDirectories ).Length; } catch { }
// Custom components = Component subclasses outside the engine namespaces.
string[] customComponents = Array.Empty<string>();
try
{
customComponents = Game.TypeLibrary.GetTypes<Component>()
.Where( t => !t.IsAbstract && t.FullName != null
&& !t.FullName.StartsWith( "Sandbox." ) && !t.FullName.StartsWith( "Editor." )
&& !t.FullName.StartsWith( "Facepunch." ) )
.Select( t => t.Name ).OrderBy( n => n ).Take( 100 ).ToArray();
}
catch { }
string[] libraries = Array.Empty<string>();
try
{
var libDir = Path.Combine( root, "Libraries" );
if ( Directory.Exists( libDir ) )
libraries = Directory.GetDirectories( libDir ).Select( Path.GetFileName ).OrderBy( n => n ).ToArray();
}
catch { }
return Task.FromResult<object>( new
{
name = project.Config.Title,
ident = project.Config.Ident,
org = project.Config.Org,
type = project.Config.Type,
rootPath = root.Replace( '\\', '/' ),
openScene = scene == null ? null : new { name = scene.Name, objectCount = scene.GetAllObjects( true ).Count() },
scenes = new { total = scenes.Length, files = scenes },
prefabs = new { total = prefabs.Length, files = prefabs },
code = new { csFiles = codeFiles, razorFiles },
customComponents = new { total = customComponents.Length, names = customComponents },
libraries,
note = "Orient here, then: get_scene_hierarchy for the open scene, describe_type for any component, list_prefabs/get_prefab_info for prefabs, find_broken_references for health."
} );
}
}
Editor
library
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 & 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 );
}}
}}
";
}
}
Editor
library
// AUTO-GENERATED by scripts/emit-mcp-wrappers.mjs β DO NOT EDIT.
// Regenerate: node scripts/extract-manifest.mjs && node scripts/emit-mcp-wrappers.mjs
// Source of truth: sbox-mcp-server/src/tools/ (zod schemas) β scripts/tools-manifest.json
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Editor.Mcp;
/// <summary>
/// Add, configure, inspect and invoke components on GameObjects: set/get properties, wire
/// cross-component references, call methods and editor buttons.
/// </summary>
[McpToolset( "bridge_component", "Add, configure, inspect and invoke components on GameObjects: set/get properties, wire cross-component references, call methods and editor buttons." )]
public static class BridgeComponentTools
{
/// <summary>
/// Create a new GameObject, add a component, set its properties, and optionally parent/position/tag
/// it β all in one atomic call. Collapses the create_gameobject β add_component_with_properties β
/// set_parent sequence. NOTE: a freshly GENERATED component type only resolves after a
/// trigger_hotload; generate the script, hotload, THEN call this.
/// </summary>
/// <param name="component">Component type name to add (e.g. 'CameraComponent', 'ObjectiveManager'). Use list_available_components to find valid types.</param>
/// <param name="name">Display name for the new GameObject. Defaults to the component type name.</param>
/// <param name="properties">Key-value map of property names to values, auto-converted to the right type (same convention as add_component_with_properties). JSON value.</param>
/// <param name="position">World position. As "x,y,z" (or JSON {x,y,z}).</param>
/// <param name="rotation">World rotation. As "pitch,yaw,roll" degrees.</param>
/// <param name="scale">World scale (per-axis). As "x,y,z" (or JSON {x,y,z}).</param>
/// <param name="parentId">GUID of a parent GameObject. Omit for scene root.</param>
/// <param name="tags">Tags to add to the new GameObject (e.g. ['player']).</param>
[McpTool( "add_component_to_new_object" )]
public static Task<object> AddComponentToNewObject( string component, string name = null, JsonNode properties = null, string position = null, string rotation = null, string scale = null, string parentId = null, string[] tags = null )
=> McpGate.Run( "add_component_to_new_object", McpGate.Args( ( "component", component ), ( "name", name ), ( "properties", properties ), ( "position", position ), ( "rotation", rotation ), ( "scale", scale ), ( "parentId", parentId ), ( "tags", tags ) ) );
/// <summary>
/// Add a component to a GameObject and configure its properties in one call (properties PERSIST
/// through save+reload). Use list_available_components to find valid types. Returns
/// appliedProperties + failedProperties so you can see exactly what stuck.
/// </summary>
/// <param name="id">GUID of the GameObject.</param>
/// <param name="component">Component type name (e.g. 'ModelRenderer', 'Rigidbody', 'BoxCollider').</param>
/// <param name="properties">Key-value map of property names to values, each auto-converted to the property's real type. Primitives '5'/true; Color/Vector3 as comma strings '1,0,0,1'; enum member names; ASSET refs as a path ('Model':'models/dev/box.vmdl', 'MaterialOverride':'materials/x.vmat'); GameObject/Component refs as a target GUID. Best-effort per key β failures are reported in failedProperties, not silently dropped. JSON value.</param>
[McpTool( "add_component_with_properties" )]
public static Task<object> AddComponentWithProperties( string id, string component, JsonNode properties = null )
=> McpGate.Run( "add_component_with_properties", McpGate.Args( ( "id", id ), ( "component", component ), ( "properties", properties ) ) );
/// <summary>
/// Dump all public properties of every component on a GameObject. Returns { id, components } where
/// each entry is { component, properties: [{ name, type, value }] } β values are stringified
/// (unreadable ones show '<error>'). Use the exact component/property names it reports with
/// set_property or get_property; can be large on component-heavy objects.
/// </summary>
/// <param name="id">GUID of the GameObject.</param>
[McpTool.ReadOnly( "get_all_properties" )]
public static Task<object> GetAllProperties( string id )
=> McpGate.Run( "get_all_properties", McpGate.Args( ( "id", id ) ) );
/// <summary>
/// Read a single property value from a component on a GameObject.
/// </summary>
/// <param name="id">GUID of the GameObject.</param>
/// <param name="component">Component type name (e.g. 'ModelRenderer', 'PlayerController').</param>
/// <param name="property">Property name to read.</param>
[McpTool.ReadOnly( "get_property" )]
public static Task<object> GetProperty( string id, string component, string property )
=> McpGate.Run( "get_property", McpGate.Args( ( "id", id ), ( "component", component ), ( "property", property ) ) );
/// <summary>
/// Call a public method on a component. Matching is tried in order: (1) a [Button] attribute label,
/// (2) the exact method NAME, (3) case-insensitive name with spaces stripped. Calls ANY public
/// method, not only [Button]-attributed ones (e.g. 'StartGame'). Pass `args` to call methods that
/// take parameters β the arg count must match and each value is coerced to the parameter type
/// (primitives: string/number/bool work; complex types like Vector3 may not coerce). Omit args (or
/// []) for parameterless methods. (list_component_buttons only lists [Button] methods, so a plain
/// method may be invokable yet not appear there.).
/// </summary>
/// <param name="component">Component type name (e.g. 'MapBuilder', 'SasquatchedGame').</param>
/// <param name="button">A [Button] label OR a public method name (e.g. 'Build Terrain', 'StartGame'); case- and space-insensitive.</param>
/// <param name="id">Optional GameObject GUID β if omitted, finds first matching component in scene.</param>
/// <param name="args">Arguments to pass (must match the method's parameter count); coerced to each parameter type. JSON array.</param>
[McpTool( "invoke_button" )]
public static Task<object> InvokeButton( string component, string button, string id = null, JsonNode args = null )
=> McpGate.Run( "invoke_button", McpGate.Args( ( "component", component ), ( "button", button ), ( "id", id ), ( "args", args ) ) );
/// <summary>
/// Call a public method BY NAME on a component of a live scene GameObject, passing ARGUMENTS. The
/// with-args sibling of invoke_button (which only calls parameterless [Button]/methods on a scene
/// component). Finds a public method matching name + arg-count, coerces each JSON arg to the
/// parameter type (primitives/enums; Color/Vector3 as comma strings '1,0,0,1'; asset refs as a
/// path; GameObject/Component refs as a target GUID), invokes it, and returns the method's return
/// value as a string (null for void). Returns success=false with a clear error on
/// resolve/coerce/throw.
/// </summary>
/// <param name="id">GUID of the GameObject.</param>
/// <param name="method">Name of the public method to call (e.g. 'TakeDamage', 'AddGold').</param>
/// <param name="component">Component type name to target (e.g. 'Health', 'PlayerController'). Omit to search all components on the object for a method matching name + arg-count.</param>
/// <param name="args">Ordered arguments, each coerced to the matching parameter's type. Numbers/bools/strings pass through; Color/Vector3/Rotation as comma strings '1,0,0,1'; enum member names; ASSET refs as a path ('models/dev/box.vmdl'); GameObject/Component refs as a target GUID. Omit (or []) for a no-arg method. JSON array.</param>
[McpTool( "invoke_method" )]
public static Task<object> InvokeMethod( string id, string method, string component = null, JsonNode args = null )
=> McpGate.Run( "invoke_method", McpGate.Args( ( "id", id ), ( "method", method ), ( "component", component ), ( "args", args ) ) );
/// <summary>
/// List all instantiable component types in the TypeLibrary β built-in AND your project's custom
/// components (abstract types excluded); filter does a substring match on the type name. Returns {
/// count, components } with { name, title, description, fullName } per type, sorted by name β the
/// unfiltered list is LARGE, so pass filter. Use the returned name with
/// add_component_with_properties, and describe_type for a type's full property list.
/// </summary>
/// <param name="filter">Search filter β matches against component name and title.</param>
/// <param name="category">Filter by category/group (e.g. 'Rendering', 'Physics', 'Audio').</param>
[McpTool.ReadOnly( "list_available_components" )]
public static Task<object> ListAvailableComponents( string filter = null, string category = null )
=> McpGate.Run( "list_available_components", McpGate.Args( ( "filter", filter ), ( "category", category ) ) );
/// <summary>
/// List the [Button]-attributed methods on a component. NOTE: this only finds methods decorated
/// with [Button]; invoke_button can ALSO call any plain public no-arg method by name, so a method
/// missing here may still be invokable. Use describe_type / get_method_signature to find non-button
/// methods.
/// </summary>
/// <param name="component">Component type name.</param>
/// <param name="id">Optional GameObject GUID.</param>
[McpTool.ReadOnly( "list_component_buttons" )]
public static Task<object> ListComponentButtons( string component, string id = null )
=> McpGate.Run( "list_component_buttons", McpGate.Args( ( "component", component ), ( "id", id ) ) );
/// <summary>
/// Wire a component's GameObject/Component-typed property to ANOTHER live object in the scene by
/// GUID (e.g. ObjectiveManager.Player = the player, a camera's follow target, a door's hinge).
/// Preferred for object/component refs (can pick a specific component type off the target via
/// targetComponent, and validates). set_property also accepts a GUID for ref props; set_prefab_ref
/// is for prefab assets. Set clear:true to null the reference.
/// </summary>
/// <param name="id">GUID of the GameObject that HOLDS the component you're writing into.</param>
/// <param name="component">Component type name on that object (e.g. 'ObjectiveManager', 'CameraComponent').</param>
/// <param name="property">The property to set (must be a GameObject- or Component-typed property).</param>
/// <param name="targetId">GUID of the GameObject to reference. Required unless clear:true.</param>
/// <param name="targetComponent">If the property is a Component subtype, the specific component type to pull off the target object. Omit to auto-match by the property's type.</param>
/// <param name="clear">If true, set the reference to null instead of assigning a target.</param>
[McpTool( "set_component_reference" )]
public static Task<object> SetComponentReference( string id, string component, string property, string targetId = null, string targetComponent = null, bool? clear = null )
=> McpGate.Run( "set_component_reference", McpGate.Args( ( "id", id ), ( "component", component ), ( "property", property ), ( "targetId", targetId ), ( "targetComponent", targetComponent ), ( "clear", clear ) ) );
/// <summary>
/// Set a property value on a component (editor mode), and PERSIST it (survives save+reload).
/// Handles primitives, enums, value types (Color/Vector3 as comma strings), AND references: pass an
/// asset PATH for Model/Material/Texture/SoundEvent props, or a GameObject GUID for
/// GameObject/Component-typed props (resolved like set_component_reference). Returns success=false
/// with a clear error if a path/GUID can't be resolved (no more silent null). For wiring object
/// refs prefer set_component_reference; for prefab refs use set_prefab_ref.
/// </summary>
/// <param name="id">GUID of the GameObject.</param>
/// <param name="component">Component type name.</param>
/// <param name="property">Property name to set.</param>
/// <param name="value">New value. Primitive: '5', 'true'. Color/Vector3: a comma string ('1,0,0,1' / '0,0,200'), an array ([0,0,200]), or an object ({r,g,b,a} / {x,y,z}). Enum: the member name. Asset ref (Model/Material/...): the asset path e.g. 'models/dev/box.vmdl'. GameObject/Component ref: the target GameObject's GUID. Empty/'null' clears the property. JSON value.</param>
[McpTool( "set_property" )]
public static Task<object> SetProperty( string id, string component, string property, JsonNode value )
=> McpGate.Run( "set_property", McpGate.Args( ( "id", id ), ( "component", component ), ( "property", property ), ( "value", value ) ) );
}
Editor
library
// AUTO-GENERATED by scripts/emit-mcp-wrappers.mjs β DO NOT EDIT.
// Regenerate: node scripts/extract-manifest.mjs && node scripts/emit-mcp-wrappers.mjs
// Source of truth: sbox-mcp-server/src/tools/ (zod schemas) β scripts/tools-manifest.json
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Editor.Mcp;
/// <summary>
/// Reflect over the s&box API: describe types, search types, get method signatures, list
/// installed libraries, and search project files. Use before writing C# against unfamiliar SDK
/// types.
/// </summary>
[McpToolset( "bridge_discovery", "Reflect over the s&box API: describe types, search types, get method signatures, list installed libraries, and search project files. Use before writing C# against unfamiliar SDK types." )]
public static class BridgeDiscoveryTools
{
/// <summary>
/// Inspect a type's full surface β properties, methods, events, attributes β via reflection on
/// Game.TypeLibrary and loaded assemblies. Use this before writing code touching an unfamiliar
/// component or s&box API. Examples: 'MeshComponent', 'PlayerController', 'NetworkHelper',
/// 'Vector3'.
/// </summary>
/// <param name="name">Type name (short or fully-qualified).</param>
[McpTool.ReadOnly( "describe_type" )]
public static Task<object> DescribeType( string name )
=> McpGate.Run( "describe_type", McpGate.Args( ( "name", name ) ) );
/// <summary>
/// Grep the user's s&box project for a symbol (case-sensitive substring; skips .git/bin/obj).
/// Useful for finding usage examples of an API or seeing how the project already does something.
/// Returns `symbol`, `count`, and `results` [{file, line, text}], capped at `max_results` (default
/// 25) β raise it if you may be missing hits. Follow up with read_file on a result's file path.
/// </summary>
/// <param name="symbol">Substring or symbol to search for.</param>
/// <param name="extension">File extension filter. Default: ".cs".</param>
/// <param name="max_results">Maximum hits to return (default 25); the search stops once reached.</param>
[McpTool.ReadOnly( "find_in_project" )]
public static Task<object> FindInProject( string symbol, string extension = ".cs", int max_results = 25 )
=> McpGate.Run( "find_in_project", McpGate.Args( ( "symbol", symbol ), ( "extension", extension ), ( "max_results", max_results ) ) );
/// <summary>
/// Get the formal signature(s) of a method on a type β parameter names, types, defaults, return
/// type, all overloads. Use before invoking an API you're unsure of.
/// </summary>
/// <param name="type">Type name (e.g. 'Scene', 'GameObject').</param>
/// <param name="method">Method name (case-sensitive).</param>
[McpTool.ReadOnly( "get_method_signature" )]
public static Task<object> GetMethodSignature( string type, string method )
=> McpGate.Run( "get_method_signature", McpGate.Args( ( "type", type ), ( "method", method ) ) );
/// <summary>
/// List the s&box libraries/addons installed in this project (reads Libraries/ + each .sbproj).
/// Discovers what's available to build ON β e.g. character controllers (fish.scc = Shrimple
/// Character Controller, facepunch.playercontroller), world/spline/road tools β so you can leverage
/// an installed library (add its components via add_component_with_properties, or generate code
/// against its API) instead of writing from scratch. Returns `count` and `libraries` [{folder,
/// ident, org, title, type, enabled}] β ALL libraries, no limit or pagination; `enabled` is false
/// when the library's .sbproj has been disabled (renamed .sbproj.disabled). Read-only.
/// </summary>
[McpTool.ReadOnly( "list_libraries" )]
public static Task<object> ListLibraries()
=> McpGate.Run( "list_libraries", McpGate.Args() );
/// <summary>
/// Find loaded types matching a name pattern. Useful for discovering 'is there a built-in X for
/// this?'. Returns `count` and `matches` with each type's name, fullName, isComponent, and
/// isAbstract β results are silently truncated at `limit` (default 50), so narrow the pattern if
/// you hit the cap. Pass a match's name to describe_type for its full member surface.
/// </summary>
/// <param name="pattern">Substring to match against type name (case-insensitive).</param>
/// <param name="namespace">Optional namespace filter (case-insensitive substring).</param>
/// <param name="components_only">Only return Component subclasses (default false).</param>
/// <param name="limit">Maximum matches to return (default 50); the search stops silently at this cap.</param>
[McpTool.ReadOnly( "search_types" )]
public static Task<object> SearchTypes( string pattern, string @namespace = null, bool components_only = false, int limit = 50 )
=> McpGate.Run( "search_types", McpGate.Args( ( "pattern", pattern ), ( "namespace", @namespace ), ( "components_only", components_only ), ( "limit", limit ) ) );
}
Editor
library
// AUTO-GENERATED by scripts/emit-mcp-wrappers.mjs β DO NOT EDIT.
// Regenerate: node scripts/extract-manifest.mjs && node scripts/emit-mcp-wrappers.mjs
// Source of truth: sbox-mcp-server/src/tools/ (zod schemas) β scripts/tools-manifest.json
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Editor.Mcp;
/// <summary>
/// Assign models and materials to renderers, author .vmat materials, and set material properties.
/// </summary>
[McpToolset( "bridge_material", "Assign models and materials to renderers, author .vmat materials, and set material properties." )]
public static class BridgeMaterialTools
{
/// <summary>
/// Apply a material to a GameObject by setting its ModelRenderer's MaterialOverride (overrides the
/// whole model's material). Requires an existing ModelRenderer (assign_model first) and errors if
/// the material path can't be loaded. Returns { assigned, id, material } β tweak values afterwards
/// with set_material_property.
/// </summary>
/// <param name="id">GUID of the GameObject.</param>
/// <param name="material">Material path (e.g. 'materials/walls/brick.vmat').</param>
/// <param name="slot">Material slot index. Defaults to 0 (first slot).</param>
[McpTool( "assign_material" )]
public static Task<object> AssignMaterial( string id, string material, double? slot = null )
=> McpGate.Run( "assign_material", McpGate.Args( ( "id", id ), ( "material", material ), ( "slot", slot ) ) );
/// <summary>
/// Set a 3D model on a GameObject's ModelRenderer. Creates the renderer component if it doesn't
/// exist; errors if the model path can't be loaded. Returns { assigned, id, model } β follow with
/// assign_material / set_material_property to style it, or take a screenshot to verify.
/// </summary>
/// <param name="id">GUID of the GameObject.</param>
/// <param name="model">Model path (e.g. 'models/citizen/citizen.vmdl', 'models/dev/box.vmdl').</param>
[McpTool( "assign_model" )]
public static Task<object> AssignModel( string id, string model )
=> McpGate.Run( "assign_model", McpGate.Args( ( "id", id ), ( "model", model ) ) );
/// <summary>
/// Create a new material file (.vmat, KV1 format) with a shader and properties like color,
/// roughness, metallic, texture. Errors if the file already exists; when no properties are given it
/// writes sensible PBR defaults (g_flMetalness 0, g_flRoughness 1). Returns { created, path,
/// shader, propertiesWritten } β pass the returned path to recompile_asset (so the editor compiles
/// it) and then assign_material.
/// </summary>
/// <param name="path">Relative path for the material (e.g. 'materials/walls/brick.vmat').</param>
/// <param name="shader">Shader to use. Defaults to 'shaders/complex.shader' (PBR).</param>
/// <param name="properties">Material properties as key-value pairs (e.g. { "Color": "#ff0000", "Roughness": 0.8 }). JSON value.</param>
[McpTool( "create_material" )]
public static Task<object> CreateMaterial( string path, string shader = null, JsonNode properties = null )
=> McpGate.Run( "create_material", McpGate.Args( ( "path", path ), ( "shader", shader ), ( "properties", properties ) ) );
/// <summary>
/// Change a property on the material assigned to a GameObject β color, roughness, metallic,
/// texture, etc. Operates on the ModelRenderer's MaterialOverride; if none is assigned it
/// auto-creates one from the default complex shader (no separate assign_material step needed).
/// Returns { set, id, property, autoCreatedMaterial } β screenshot to verify the visual change.
/// </summary>
/// <param name="id">GUID of the GameObject.</param>
/// <param name="property">Material property name (e.g. 'Color', 'Roughness', 'Metalness', 'Normal').</param>
/// <param name="value">Property value β number for floats, string for texture paths/colors, {r,g,b,a} for colors. JSON value.</param>
[McpTool( "set_material_property" )]
public static Task<object> SetMaterialProperty( string id, string property, JsonNode value )
=> McpGate.Run( "set_material_property", McpGate.Args( ( "id", id ), ( "property", property ), ( "value", value ) ) );
}
Debug: View Raw JSON Response
{
"TotalCount": 74,
"Files": [
{
"Ident": "sboxskinsgg.claudebridge",
"Path": "Editor/DebugDrawHandlers.cs",
"FileName": "DebugDrawHandlers.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 335526,
"Code": "using Editor;\r\nusing Sandbox;\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.Globalization;\r\nusing System.Linq;\r\nusing System.Text.Json;\r\nusing System.Threading.Tasks;\r\n\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n// debug_draw_* / debug_clear \u2014 visualize debug primitives in the scene.\r\n//\r\n// Ported from the Claude Bridge for Unity's debug_draw_* family. s&box has no\r\n// bridge debug-viz; this fills the gap so a raycast hit / physics_overlap\r\n// volume / trigger_zone bounds / NPC sight cone / patrol path can be SEEN\r\n// (and screenshot-verified) instead of reasoned about blind.\r\n//\r\n// ONE component, dual render path:\r\n// \u2022 EDIT scene \u2192 Gizmo.Draw.* inside ClaudeDebugDraw.DrawGizmos()\r\n// \u2022 PLAY scene \u2192 Game.ActiveScene.DebugOverlay.* re-emitted each OnUpdate()\r\n// A single NotSaved holder GameObject (\"__ClaudeDebugDraw\") stores the prim\r\n// list; the draw handlers append, debug_clear destroys it.\r\n//\r\n// APIs reflected live on this SDK (describe_type, 2026-06-18):\r\n// Gizmo.Draw: Line(a,b) \u00b7 Arrow(from,to,len,width) \u00b7 LineBBox(bbox) \u00b7\r\n// LineSphere(Sphere,rings) \u00b7 Color/LineThickness/IgnoreDepth\r\n// Scene.DebugOverlay (DebugOverlaySystem):\r\n// Line(from,to,color,dur,tx,overlay) \u00b7 Box(BBox,color,dur,tx,overlay) \u00b7\r\n// Sphere(Sphere,color,dur,tx,overlay)\r\n//\r\n// Must work WHILE playing \u2192 these are NOT added to _sceneMutatingCommands.\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n\r\npublic enum DebugDrawKind { Line, Ray, Box, Sphere }\r\n\r\npublic sealed class DebugDrawPrim\r\n{\r\n\tpublic DebugDrawKind Kind;\r\n\tpublic Vector3 A; // line/ray start \u00b7 box/sphere center\r\n\tpublic Vector3 B; // line/ray end\r\n\tpublic Vector3 Size; // box full extents\r\n\tpublic float Radius; // sphere\r\n\tpublic Color Color = Color.Yellow;\r\n\tpublic float Thickness = 2f;\r\n}\r\n\r\n/// <summary>\r\n/// Holds bridge-issued debug primitives and renders them in both the editor\r\n/// (DrawGizmos) and play mode (DebugOverlay). One per scene, NotSaved.\r\n/// </summary>\r\npublic sealed class ClaudeDebugDraw : Component\r\n{\r\n\tpublic List<DebugDrawPrim> Prims { get; set; } = new();\r\n\r\n\tprotected override void DrawGizmos()\r\n\t{\r\n\t\tif ( Prims == null ) return;\r\n\t\tforeach ( var p in Prims )\r\n\t\t{\r\n\t\t\tGizmo.Draw.Color = p.Color;\r\n\t\t\tGizmo.Draw.LineThickness = p.Thickness;\r\n\t\t\tGizmo.Draw.IgnoreDepth = true;\r\n\t\t\tswitch ( p.Kind )\r\n\t\t\t{\r\n\t\t\t\tcase DebugDrawKind.Line: Gizmo.Draw.Line( p.A, p.B ); break;\r\n\t\t\t\tcase DebugDrawKind.Ray: Gizmo.Draw.Arrow( p.A, p.B, 8f, 3f ); break;\r\n\t\t\t\tcase DebugDrawKind.Box: Gizmo.Draw.LineBBox( new BBox( p.A - p.Size * 0.5f, p.A + p.Size * 0.5f ) ); break;\r\n\t\t\t\tcase DebugDrawKind.Sphere: Gizmo.Draw.LineSphere( new Sphere( p.A, p.Radius ), 16 ); break;\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif ( !Game.IsPlaying || Prims == null ) return;\r\n\t\tvar ov = Scene?.DebugOverlay;\r\n\t\tif ( ov == null ) return;\r\n\t\tconst float dur = 0.1f; // refreshed every frame while in the list\r\n\t\tvar tx = global::Transform.Zero; // identity \u2192 world-space coords (Transform is global-namespace, not Sandbox.*)\r\n\t\tforeach ( var p in Prims )\r\n\t\t{\r\n\t\t\tswitch ( p.Kind )\r\n\t\t\t{\r\n\t\t\t\tcase DebugDrawKind.Line:\r\n\t\t\t\tcase DebugDrawKind.Ray:\r\n\t\t\t\t\tov.Line( p.A, p.B, p.Color, dur, tx, true );\r\n\t\t\t\t\tbreak;\r\n\t\t\t\tcase DebugDrawKind.Box:\r\n\t\t\t\t\tov.Box( new BBox( p.A - p.Size * 0.5f, p.A + p.Size * 0.5f ), p.Color, dur, tx, true );\r\n\t\t\t\t\tbreak;\r\n\t\t\t\tcase DebugDrawKind.Sphere:\r\n\t\t\t\t\tov.Sphere( new Sphere( p.A, p.Radius ), p.Color, dur, tx, true );\r\n\t\t\t\t\tbreak;\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\ninternal static class DebugDrawHelpers\r\n{\r\n\tstatic readonly CultureInfo Inv = CultureInfo.InvariantCulture;\r\n\r\n\t// ponytail: one global holder per session, recreated if invalidated by a\r\n\t// scene change / hotload. Debug viz is inherently global, so a single\r\n\t// instance is correct \u2014 no per-call scene scan needed.\r\n\tstatic ClaudeDebugDraw _holder;\r\n\r\n\tpublic static Scene CurrentScene()\r\n\t\t=> Game.IsPlaying ? Game.ActiveScene : SceneEditorSession.Active?.Scene;\r\n\r\n\tpublic static ClaudeDebugDraw EnsureHolder()\r\n\t{\r\n\t\tvar scene = CurrentScene();\r\n\t\tif ( scene == null ) return null;\r\n\t\tif ( _holder.IsValid() && _holder.Scene == scene ) return _holder;\r\n\t\tvar go = scene.CreateObject( true );\r\n\t\tgo.Name = \"__ClaudeDebugDraw\";\r\n\t\tgo.Flags = GameObjectFlags.NotSaved;\r\n\t\t_holder = go.AddComponent<ClaudeDebugDraw>();\r\n\t\treturn _holder;\r\n\t}\r\n\r\n\tpublic static int ClearHolder()\r\n\t{\r\n\t\tint n = 0;\r\n\t\t// cached holder \u2014 reliable for the common same-scene case\r\n\t\tif ( _holder.IsValid() )\r\n\t\t{\r\n\t\t\tn += _holder.Prims?.Count ?? 0;\r\n\t\t\t_holder.GameObject?.Destroy();\r\n\t\t}\r\n\t\t// plus any holders orphaned by an edit\u2194play scene switch (the static ref\r\n\t\t// only tracks the most recent scene's holder)\r\n\t\tvar scene = CurrentScene();\r\n\t\tif ( scene != null )\r\n\t\t{\r\n\t\t\tforeach ( var c in scene.GetAllComponents<ClaudeDebugDraw>().ToList() )\r\n\t\t\t{\r\n\t\t\t\tif ( c == _holder ) continue;\r\n\t\t\t\tn += c.Prims?.Count ?? 0;\r\n\t\t\t\tc.GameObject?.Destroy();\r\n\t\t\t}\r\n\t\t}\r\n\t\t_holder = null;\r\n\t\treturn n;\r\n\t}\r\n\r\n\tpublic static bool TryVec( JsonElement p, string key, out Vector3 v )\r\n\t{\r\n\t\tv = Vector3.Zero;\r\n\t\tif ( !p.TryGetProperty( key, out var e ) ) return false;\r\n\t\tswitch ( e.ValueKind )\r\n\t\t{\r\n\t\t\tcase JsonValueKind.String:\r\n\t\t\t\tvar s = e.GetString().Split( ',' );\r\n\t\t\t\tif ( s.Length < 3 ) return false;\r\n\t\t\t\tv = new Vector3( F( s[0] ), F( s[1] ), F( s[2] ) );\r\n\t\t\t\treturn true;\r\n\t\t\tcase JsonValueKind.Array:\r\n\t\t\t\tif ( e.GetArrayLength() < 3 ) return false;\r\n\t\t\t\tv = new Vector3( (float)e[0].GetDouble(), (float)e[1].GetDouble(), (float)e[2].GetDouble() );\r\n\t\t\t\treturn true;\r\n\t\t\tcase JsonValueKind.Object:\r\n\t\t\t\tv = new Vector3(\r\n\t\t\t\t\t(float)e.GetProperty( \"x\" ).GetDouble(),\r\n\t\t\t\t\t(float)e.GetProperty( \"y\" ).GetDouble(),\r\n\t\t\t\t\t(float)e.GetProperty( \"z\" ).GetDouble() );\r\n\t\t\t\treturn true;\r\n\t\t\tdefault:\r\n\t\t\t\treturn false;\r\n\t\t}\r\n\t}\r\n\r\n\tpublic static Color Col( JsonElement p, string key, Color def )\r\n\t{\r\n\t\tif ( !p.TryGetProperty( key, out var e ) || e.ValueKind != JsonValueKind.String ) return def;\r\n\t\tvar s = e.GetString().Split( ',' );\r\n\t\tif ( s.Length < 3 ) return def;\r\n\t\tfloat a = s.Length >= 4 ? F( s[3] ) : 1f;\r\n\t\treturn new Color( F( s[0] ), F( s[1] ), F( s[2] ), a );\r\n\t}\r\n\r\n\tpublic static float Flt( JsonElement p, string key, float def )\r\n\t\t=> p.TryGetProperty( key, out var e ) && e.ValueKind == JsonValueKind.Number ? (float)e.GetDouble() : def;\r\n\r\n\tstatic float F( string s ) => float.Parse( s.Trim(), Inv );\r\n}\r\n\r\n// \u2500\u2500 handlers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\npublic class DebugDrawLineHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tif ( !DebugDrawHelpers.TryVec( p, \"from\", out var a ) || !DebugDrawHelpers.TryVec( p, \"to\", out var b ) )\r\n\t\t\t\treturn Task.FromResult<object>( new { error = \"from and to are required (\\\"x,y,z\\\")\" } );\r\n\t\t\tvar h = DebugDrawHelpers.EnsureHolder();\r\n\t\t\tif ( h == null ) return Task.FromResult<object>( new { error = \"no active scene\" } );\r\n\t\t\th.Prims.Add( new DebugDrawPrim\r\n\t\t\t{\r\n\t\t\t\tKind = DebugDrawKind.Line, A = a, B = b,\r\n\t\t\t\tColor = DebugDrawHelpers.Col( p, \"color\", Color.Yellow ),\r\n\t\t\t\tThickness = DebugDrawHelpers.Flt( p, \"thickness\", 2f )\r\n\t\t\t} );\r\n\t\t\treturn Task.FromResult<object>( new { drawn = \"line\", count = h.Prims.Count, mode = Game.IsPlaying ? \"play\" : \"edit\" } );\r\n\t\t}\r\n\t\tcatch ( Exception ex ) { return Task.FromResult<object>( new { error = $\"debug_draw_line failed: {ex.Message}\" } ); }\r\n\t}\r\n}\r\n\r\npublic class DebugDrawRayHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tif ( !DebugDrawHelpers.TryVec( p, \"origin\", out var o ) || !DebugDrawHelpers.TryVec( p, \"direction\", out var d ) )\r\n\t\t\t\treturn Task.FromResult<object>( new { error = \"origin and direction are required (\\\"x,y,z\\\")\" } );\r\n\t\t\tfloat len = DebugDrawHelpers.Flt( p, \"length\", 64f );\r\n\t\t\tvar h = DebugDrawHelpers.EnsureHolder();\r\n\t\t\tif ( h == null ) return Task.FromResult<object>( new { error = \"no active scene\" } );\r\n\t\t\th.Prims.Add( new DebugDrawPrim\r\n\t\t\t{\r\n\t\t\t\tKind = DebugDrawKind.Ray, A = o, B = o + d.Normal * len,\r\n\t\t\t\tColor = DebugDrawHelpers.Col( p, \"color\", Color.Yellow ),\r\n\t\t\t\tThickness = DebugDrawHelpers.Flt( p, \"thickness\", 2f )\r\n\t\t\t} );\r\n\t\t\treturn Task.FromResult<object>( new { drawn = \"ray\", count = h.Prims.Count, mode = Game.IsPlaying ? \"play\" : \"edit\" } );\r\n\t\t}\r\n\t\tcatch ( Exception ex ) { return Task.FromResult<object>( new { error = $\"debug_draw_ray failed: {ex.Message}\" } ); }\r\n\t}\r\n}\r\n\r\npublic class DebugDrawBoxHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tif ( !DebugDrawHelpers.TryVec( p, \"center\", out var c ) )\r\n\t\t\t\treturn Task.FromResult<object>( new { error = \"center is required (\\\"x,y,z\\\")\" } );\r\n\t\t\tVector3 size = DebugDrawHelpers.TryVec( p, \"size\", out var sz ) ? sz : new Vector3( 32f, 32f, 32f );\r\n\t\t\tvar h = DebugDrawHelpers.EnsureHolder();\r\n\t\t\tif ( h == null ) return Task.FromResult<object>( new { error = \"no active scene\" } );\r\n\t\t\th.Prims.Add( new DebugDrawPrim\r\n\t\t\t{\r\n\t\t\t\tKind = DebugDrawKind.Box, A = c, Size = size,\r\n\t\t\t\tColor = DebugDrawHelpers.Col( p, \"color\", Color.Green ),\r\n\t\t\t\tThickness = DebugDrawHelpers.Flt( p, \"thickness\", 2f )\r\n\t\t\t} );\r\n\t\t\treturn Task.FromResult<object>( new { drawn = \"box\", count = h.Prims.Count, mode = Game.IsPlaying ? \"play\" : \"edit\" } );\r\n\t\t}\r\n\t\tcatch ( Exception ex ) { return Task.FromResult<object>( new { error = $\"debug_draw_box failed: {ex.Message}\" } ); }\r\n\t}\r\n}\r\n\r\npublic class DebugDrawSphereHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tif ( !DebugDrawHelpers.TryVec( p, \"center\", out var c ) )\r\n\t\t\t\treturn Task.FromResult<object>( new { error = \"center is required (\\\"x,y,z\\\")\" } );\r\n\t\t\tfloat r = DebugDrawHelpers.Flt( p, \"radius\", 32f );\r\n\t\t\tvar h = DebugDrawHelpers.EnsureHolder();\r\n\t\t\tif ( h == null ) return Task.FromResult<object>( new { error = \"no active scene\" } );\r\n\t\t\th.Prims.Add( new DebugDrawPrim\r\n\t\t\t{\r\n\t\t\t\tKind = DebugDrawKind.Sphere, A = c, Radius = r,\r\n\t\t\t\tColor = DebugDrawHelpers.Col( p, \"color\", Color.Red ),\r\n\t\t\t\tThickness = DebugDrawHelpers.Flt( p, \"thickness\", 2f )\r\n\t\t\t} );\r\n\t\t\treturn Task.FromResult<object>( new { drawn = \"sphere\", count = h.Prims.Count, mode = Game.IsPlaying ? \"play\" : \"edit\" } );\r\n\t\t}\r\n\t\tcatch ( Exception ex ) { return Task.FromResult<object>( new { error = $\"debug_draw_sphere failed: {ex.Message}\" } ); }\r\n\t}\r\n}\r\n\r\npublic class DebugClearHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tint removed = DebugDrawHelpers.ClearHolder();\r\n\t\t\treturn Task.FromResult<object>( new { cleared = true, removed } );\r\n\t\t}\r\n\t\tcatch ( Exception ex ) { return Task.FromResult<object>( new { error = $\"debug_clear failed: {ex.Message}\" } ); }\r\n\t}\r\n}\r\n"
},
{
"Ident": "sboxskinsgg.claudebridge",
"Path": "Editor/EconomySaveHandlers.cs",
"FileName": "EconomySaveHandlers.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 335526,
"Code": "using Editor;\r\nusing Sandbox;\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.IO;\r\nusing System.Linq;\r\nusing System.Text;\r\nusing System.Text.Json;\r\nusing System.Threading;\r\nusing System.Threading.Tasks;\r\n\r\n// =============================================================================\r\n// Economy & Save family (Track E) -- six Tier-2 scaffolds (code-gen; scene-mutating):\r\n//\r\n// create_currency_account audited host-authoritative ledger: [Sync(FromHost)]\r\n// balance + Deposit/Withdraw/TryTransfer + fixed-size\r\n// transaction ring buffer (Time.Now, reason, amount)\r\n// create_idle_economy geometric bulk-buy: BaseCost * Growth^Owned, closed-form\r\n// Buy 1 / Buy N / Buy Max, income tick auto-wired to a\r\n// sibling wallet via TypeLibrary reflection\r\n// create_signed_save tamper-evident save: FNV-1a signature over payload+salt,\r\n// verify-on-load, clamp Sanitize() hook, forced reset on\r\n// mismatch, versioned\r\n// create_meta_progression between-runs roguelite meta: persistent meta-currency +\r\n// unlock flags, Grant/TrySpend/Unlock/IsUnlocked,\r\n// OnUnlocked static event, BankRun(int) run-end seam\r\n// add_steam_stat_currency currency persisted over Sandbox.Services.Stats\r\n// (SetValue/Flush; read-back via GetLocalPlayerStats)\r\n// create_loot_table_resource GameResource-based loot tables ([AssetType], .loot files)\r\n// with nested-table entries + depth-capped resolver component\r\n//\r\n// Compiles into the SAME editor assembly as MyEditorMenu.cs / ScaffoldHandlers.cs,\r\n// so it reuses the shared statics on ClaudeBridge (TryResolveProjectPath,\r\n// SanitizeIdentifier, SerializeGo) and ScaffoldHelpers (PrepareCodeFile /\r\n// WriteCode / Utf8NoBom). Handler code here is UNSANDBOXED editor code (System.* fine).\r\n//\r\n// The C# *strings these handlers WRITE TO DISK* are SANDBOXED game code:\r\n// - sealed Component classes, no virtual members.\r\n// - [Sync(SyncFlags.FromHost)] for host-auth state (create_economy_wallet-verified);\r\n// IsProxy guards on every mutation.\r\n// - System.Math/MathF compile on this SDK; Array.Clone() is blocked (not used).\r\n// - FileSystem.Data.ReadJsonOrDefault<T>/WriteJson + ReadAllText/WriteAllText/\r\n// FileExists/DeleteFile all verified live via describe_type BaseFileSystem.\r\n// - Sandbox.Json.Serialize(object)/Deserialize<T>(string) verified live.\r\n// - Sandbox.Services.Stats: Increment(string,double), SetValue(string,double,string,object),\r\n// Flush(), GetLocalPlayerStats(string packageIdent) -> Stats.PlayerStats (NESTED type;\r\n// .Get(name) returns Stats.PlayerStat with .Value) -- all verified live. There is NO\r\n// Stats.LocalPlayer on this SDK.\r\n// - GameResourceAttribute is [Obsolete] on this SDK -- generated resources use\r\n// [AssetType( Name=..., Extension=..., Category=... )] (the modern corpus pattern).\r\n// - TypeLibrary wallet wiring copies the compile-verified create_idle_income shape:\r\n// Game.TypeLibrary.GetType(comp.GetType()) -> Methods.FirstOrDefault(...) ->\r\n// Invoke / InvokeWithReturn<bool> (both verified on MethodDescription);\r\n// PropertyDescription.GetValue(object) verified live.\r\n//\r\n// Register(...) lines + the _sceneMutatingCommands additions live in MyEditorMenu.cs\r\n// (orchestrator integration) to keep the files decoupled -- see the handoff summary.\r\n// =============================================================================\r\n\r\n// -----------------------------------------------------------------------------\r\n// create_currency_account -- the audited sibling of create_economy_wallet.\r\n// Wallet = simple money (AddMoney/TrySpend). Account = money + a fixed-size\r\n// transaction ring buffer (timestamp, reason, amount, balance-after) with\r\n// GetRecentTransactions() for ledger UIs / audit trails, plus TryTransfer\r\n// between accounts. Folds the corpus asks create_economy_ledger / create_currency.\r\n// -----------------------------------------------------------------------------\r\npublic class CreateCurrencyAccountHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tif ( !ScaffoldHelpers.PrepareCodeFile( p, \"CurrencyAccount\", out var fullPath, out var relPath, out var className, out var err ) )\r\n\t\t\t\treturn Task.FromResult<object>( err );\r\n\r\n\t\t\tlong start = p.TryGetProperty( \"startingBalance\", out var sv ) && sv.TryGetInt64( out var sl ) ? sl : 0L;\r\n\t\t\tint history = p.TryGetProperty( \"historySize\", out var hv ) && hv.TryGetInt32( out var hi ) ? hi : 32;\r\n\t\t\tif ( history < 1 ) history = 1;\r\n\t\t\tif ( history > 4096 ) history = 4096;\r\n\r\n\t\t\tvar code = BuildCode( className, start, history );\r\n\t\t\tScaffoldHelpers.WriteCode( fullPath, code );\r\n\r\n\t\t\tobject placedOn = null; string note = null;\r\n\t\t\tif ( p.TryGetProperty( \"targetId\", out var tid ) && tid.ValueKind == JsonValueKind.String )\r\n\t\t\t\tplacedOn = EconomySaveHelpers.PlaceOnTarget( tid.GetString(), className, out note );\r\n\r\n\t\t\treturn Task.FromResult<object>( new\r\n\t\t\t{\r\n\t\t\t\tcreated = true,\r\n\t\t\t\tpath = relPath,\r\n\t\t\t\tclassName,\r\n\t\t\t\tstartingBalance = start,\r\n\t\t\t\thistorySize = history,\r\n\t\t\t\tplacedOn,\r\n\t\t\t\tnote,\r\n\t\t\t\tnextSteps = new[]\r\n\t\t\t\t{\r\n\t\t\t\t\t$\"trigger_hotload to compile {className} into the game assembly.\",\r\n\t\t\t\t\tplacedOn != null\r\n\t\t\t\t\t\t? $\"{className} was attached to the target GameObject.\"\r\n\t\t\t\t\t\t: $\"Place it on a per-player or bank GameObject: add_component_to_new_object (component=\\\"{className}\\\") after the hotload, or re-run with targetId.\",\r\n\t\t\t\t\t$\"Move money host-side: GetComponent<{className}>()?.Deposit( 100, \\\"quest reward\\\" ); .Withdraw( 50, \\\"shop\\\" ); .TryTransfer( other, 25, \\\"trade\\\" );\",\r\n\t\t\t\t\t$\"Read the ledger (host-side, newest first): foreach ( var t in GetComponent<{className}>().GetRecentTransactions() ) Log.Info( $\\\"{{t.Time}} {{t.Amount}} {{t.Reason}} -> {{t.BalanceAfter}}\\\" );\",\r\n\t\t\t\t\t$\"Bind a HUD: GetComponent<{className}>().OnBalanceChanged = bal => {{ /* update label */ }}; Balance is [Sync(FromHost)] so clients can read it directly.\",\r\n\t\t\t\t\t$\"History keeps the last {history} transactions (HistorySize, fixed once the first transaction is recorded); older entries are overwritten silently. The ledger itself is host-side only -- it does not replicate.\"\r\n\t\t\t\t}\r\n\t\t\t} );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"create_currency_account failed: {ex.Message}\" } );\r\n\t\t}\r\n\t}\r\n\r\n\tstatic string BuildCode( string className, long start, int history )\r\n\t{\r\n\t\tvar ci = System.Globalization.CultureInfo.InvariantCulture;\r\n\t\tstring st = start.ToString( ci );\r\n\t\tstring hs = history.ToString( ci );\r\n\r\n\t\treturn $@\"using Sandbox;\r\nusing System;\r\nusing System.Collections.Generic;\r\n\r\n/// <summary>\r\n/// {className} -- a host-authoritative currency ACCOUNT: an audited ledger.\r\n///\r\n/// Use create_economy_wallet's Wallet when you just need money; use this when you need\r\n/// money PLUS an audit trail. Balance is [Sync(SyncFlags.FromHost)] so only the host\r\n/// writes it (clients can't author their own balance); every Deposit / Withdraw /\r\n/// TryTransfer records a Transaction (Time.Now, reason, signed amount, balance-after)\r\n/// into a fixed-size ring buffer, newest overwriting oldest past HistorySize.\r\n///\r\n/// The ledger is HOST-SIDE ONLY -- it does not replicate. Balance replicates; feed a\r\n/// client-side ledger UI over an RPC if you need remote history. Single-player safe\r\n/// (IsProxy is false with no networking active).\r\n///\r\n/// Usage (host-side):\r\n/// GetComponent<{className}>()?.Deposit( 100, \"\"quest reward\"\" );\r\n/// if ( GetComponent<{className}>().Withdraw( 50, \"\"shop\"\" ) ) {{ /* grant the item */ }}\r\n/// GetComponent<{className}>().TryTransfer( otherAccount, 25, \"\"trade\"\" );\r\n/// foreach ( var t in GetComponent<{className}>().GetRecentTransactions() ) {{ /* newest first */ }}\r\n/// </summary>\r\npublic sealed class {className} : Component\r\n{{\r\n\t/// Balance the account opens with (host seeds it in OnStart).\r\n\t[Property] public long StartingBalance {{ get; set; }} = {st}L;\r\n\r\n\t/// Ring-buffer capacity. Fixed once the first transaction is recorded.\r\n\t[Property] public int HistorySize {{ get; set; }} = {hs};\r\n\r\n\t// Host-authoritative balance -- replicates to clients, only the host writes.\r\n\t[Sync( SyncFlags.FromHost )] public long Balance {{ get; set; }}\r\n\r\n\t/// One ledger line. Amount is signed: positive = deposit, negative = withdrawal.\r\n\tpublic struct Transaction\r\n\t{{\r\n\t\tpublic float Time; // Time.Now when recorded\r\n\t\tpublic long Amount; // signed delta\r\n\t\tpublic string Reason; // free-form audit string\r\n\t\tpublic long BalanceAfter; // balance after applying the delta\r\n\t}}\r\n\r\n\t/// Fired (on the writing machine) whenever the balance changes -- bind a HUD here.\r\n\tpublic Action<long> OnBalanceChanged {{ get; set; }}\r\n\r\n\t// Host-side ring buffer. _head = next write slot, _count = filled slots.\r\n\tprivate Transaction[] _history;\r\n\tprivate int _head;\r\n\tprivate int _count;\r\n\r\n\tprotected override void OnStart()\r\n\t{{\r\n\t\tif ( IsProxy ) return; // only the authority seeds the balance\r\n\t\tBalance = StartingBalance;\r\n\t\tif ( StartingBalance != 0 ) Record( StartingBalance, \"\"opening balance\"\" );\r\n\t\tOnBalanceChanged?.Invoke( Balance );\r\n\t}}\r\n\r\n\tpublic bool CanAfford( long amount ) => Balance >= amount;\r\n\r\n\t/// <summary>Deposit (host-authoritative). Non-positive amounts are ignored.</summary>\r\n\tpublic void Deposit( long amount, string reason = \"\"deposit\"\" )\r\n\t{{\r\n\t\tif ( IsProxy || amount <= 0 ) return;\r\n\t\tBalance += amount;\r\n\t\tRecord( amount, reason );\r\n\t\tOnBalanceChanged?.Invoke( Balance );\r\n\t}}\r\n\r\n\t/// <summary>Withdraw if affordable; returns false and changes nothing if not (host-authoritative).</summary>\r\n\tpublic bool Withdraw( long amount, string reason = \"\"withdraw\"\" )\r\n\t{{\r\n\t\tif ( IsProxy || amount <= 0 ) return false;\r\n\t\tif ( Balance < amount ) return false;\r\n\t\tBalance -= amount;\r\n\t\tRecord( -amount, reason );\r\n\t\tOnBalanceChanged?.Invoke( Balance );\r\n\t\treturn true;\r\n\t}}\r\n\r\n\t/// <summary>\r\n\t/// Atomically move money into another account (host-authoritative). Both legs are\r\n\t/// recorded in their respective ledgers. Returns false (nothing moves) when the\r\n\t/// target is missing/self, the amount is non-positive, or funds are short.\r\n\t/// </summary>\r\n\tpublic bool TryTransfer( {className} to, long amount, string reason = \"\"transfer\"\" )\r\n\t{{\r\n\t\tif ( IsProxy || to == null || to == this || amount <= 0 ) return false;\r\n\t\tif ( Balance < amount ) return false;\r\n\t\tBalance -= amount;\r\n\t\tRecord( -amount, reason );\r\n\t\tOnBalanceChanged?.Invoke( Balance );\r\n\t\tto.ReceiveTransfer( amount, reason );\r\n\t\treturn true;\r\n\t}}\r\n\r\n\t// The receiving leg of TryTransfer -- runs on the host alongside the sending leg.\r\n\tprivate void ReceiveTransfer( long amount, string reason )\r\n\t{{\r\n\t\tBalance += amount;\r\n\t\tRecord( amount, reason );\r\n\t\tOnBalanceChanged?.Invoke( Balance );\r\n\t}}\r\n\r\n\t/// <summary>\r\n\t/// The most recent transactions, NEWEST FIRST. max = 0 returns everything retained\r\n\t/// (up to HistorySize). Host-side only -- proxies always get an empty list.\r\n\t/// </summary>\r\n\tpublic List<Transaction> GetRecentTransactions( int max = 0 )\r\n\t{{\r\n\t\tvar list = new List<Transaction>();\r\n\t\tif ( _history == null || _count == 0 ) return list;\r\n\t\tint take = _count;\r\n\t\tif ( max > 0 && max < take ) take = max;\r\n\t\tfor ( int i = 0; i < take; i++ )\r\n\t\t{{\r\n\t\t\tint idx = ( _head - 1 - i + _history.Length * 2 ) % _history.Length;\r\n\t\t\tlist.Add( _history[idx] );\r\n\t\t}}\r\n\t\treturn list;\r\n\t}}\r\n\r\n\tprivate void Record( long amount, string reason )\r\n\t{{\r\n\t\tif ( _history == null )\r\n\t\t\t_history = new Transaction[HistorySize < 1 ? 1 : HistorySize];\r\n\r\n\t\t_history[_head] = new Transaction\r\n\t\t{{\r\n\t\t\tTime = Time.Now,\r\n\t\t\tAmount = amount,\r\n\t\t\tReason = reason ?? \"\"\"\",\r\n\t\t\tBalanceAfter = Balance\r\n\t\t}};\r\n\t\t_head = ( _head + 1 ) % _history.Length;\r\n\t\tif ( _count < _history.Length ) _count++;\r\n\t}}\r\n}}\r\n\";\r\n\t}\r\n}\r\n\r\n// -----------------------------------------------------------------------------\r\n// create_idle_economy -- geometric bulk-buy purchasing. Generators follow the\r\n// classic BaseCost * Growth^Owned curve; Buy 1 / Buy N / Buy Max use the\r\n// closed-form geometric series (no loops). Income ticks grant into a sibling\r\n// wallet's AddMoney via TypeLibrary reflection (the compile-verified\r\n// create_idle_income pattern); purchases spend via the sibling's TrySpend and\r\n// Buy Max reads its Money property the same way.\r\n// -----------------------------------------------------------------------------\r\npublic class CreateIdleEconomyHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar ci = System.Globalization.CultureInfo.InvariantCulture;\r\n\r\n\t\t\tif ( !ScaffoldHelpers.PrepareCodeFile( p, \"IdleEconomy\", out var fullPath, out var relPath, out var className, out var err ) )\r\n\t\t\t\treturn Task.FromResult<object>( err );\r\n\r\n\t\t\tfloat tick = p.TryGetProperty( \"tickSeconds\", out var tv ) && tv.TryGetSingle( out var tf ) ? tf : 1f;\r\n\t\t\tif ( tick < 0.1f ) tick = 0.1f;\r\n\r\n\t\t\tvar gens = ParseGenerators( p );\r\n\r\n\t\t\tvar code = BuildCode( className, gens, tick, ci );\r\n\t\t\tScaffoldHelpers.WriteCode( fullPath, code );\r\n\r\n\t\t\tobject placedOn = null; string note = null;\r\n\t\t\tif ( p.TryGetProperty( \"targetId\", out var tid ) && tid.ValueKind == JsonValueKind.String )\r\n\t\t\t\tplacedOn = EconomySaveHelpers.PlaceOnTarget( tid.GetString(), className, out note );\r\n\r\n\t\t\treturn Task.FromResult<object>( new\r\n\t\t\t{\r\n\t\t\t\tcreated = true,\r\n\t\t\t\tpath = relPath,\r\n\t\t\t\tclassName,\r\n\t\t\t\tgenerators = gens.Select( g => g.Name ).ToArray(),\r\n\t\t\t\ttickSeconds = tick,\r\n\t\t\t\tplacedOn,\r\n\t\t\t\tnote,\r\n\t\t\t\tnextSteps = new[]\r\n\t\t\t\t{\r\n\t\t\t\t\t$\"trigger_hotload to compile {className} into the game assembly.\",\r\n\t\t\t\t\tplacedOn != null\r\n\t\t\t\t\t\t? $\"{className} was attached to the target GameObject.\"\r\n\t\t\t\t\t\t: $\"Place it NEXT TO a wallet component (create_economy_wallet / create_currency_account) on the same GameObject: add_component_to_new_object (component=\\\"{className}\\\") after the hotload, or re-run with targetId.\",\r\n\t\t\t\t\t\"It auto-wires the sibling wallet by reflection: income invokes AddMoney(long|int), purchases invoke TrySpend(long|int), Buy Max reads the Money property. No wallet sibling = purchases refused with a Log.Warning (never silent).\",\r\n\t\t\t\t\t$\"Buy from game code: GetComponent<{className}>().TryBuy( 0, 1 ); .TryBuy( 0, 10 ); int n = GetComponent<{className}>().BuyMax( 0 );\",\r\n\t\t\t\t\t$\"Show prices: double cost = GetComponent<{className}>().CostOf( 0, 10 ); int max = GetComponent<{className}>().MaxAffordable( 0 ); -- both closed-form geometric series, no loops.\",\r\n\t\t\t\t\t$\"React to events: {className}.OnPurchased += ( index, count, cost ) => {{ }}; {className}.OnIncomeTick += ( amount, total ) => {{ }};\",\r\n\t\t\t\t\t\"Tune GeneratorNames / BaseCosts / Growths / IncomesPerSecond (parallel lists) in the inspector or with set_property. Owned counts are host-side state (not replicated); pair with create_offline_progress for away-time earnings.\"\r\n\t\t\t\t}\r\n\t\t\t} );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"create_idle_economy failed: {ex.Message}\" } );\r\n\t\t}\r\n\t}\r\n\r\n\tinternal struct GeneratorDef { public string Name; public float BaseCost; public float Growth; public float IncomePerSecond; }\r\n\r\n\tstatic List<GeneratorDef> ParseGenerators( JsonElement p )\r\n\t{\r\n\t\tvar result = new List<GeneratorDef>();\r\n\t\tif ( p.TryGetProperty( \"generators\", out var gv ) && gv.ValueKind == JsonValueKind.Array )\r\n\t\t{\r\n\t\t\tforeach ( var item in gv.EnumerateArray() )\r\n\t\t\t{\r\n\t\t\t\tvar g = new GeneratorDef\r\n\t\t\t\t{\r\n\t\t\t\t\tName = item.TryGetProperty( \"name\", out var nv ) && !string.IsNullOrWhiteSpace( nv.GetString() ) ? nv.GetString() : \"Generator\",\r\n\t\t\t\t\tBaseCost = item.TryGetProperty( \"baseCost\", out var bv ) && bv.TryGetSingle( out var bf ) ? bf : 15f,\r\n\t\t\t\t\tGrowth = item.TryGetProperty( \"growth\", out var grv ) && grv.TryGetSingle( out var grf ) ? grf : 1.15f,\r\n\t\t\t\t\tIncomePerSecond = item.TryGetProperty( \"incomePerSecond\", out var iv ) && iv.TryGetSingle( out var inf ) ? inf : 0.5f\r\n\t\t\t\t};\r\n\t\t\t\t// Escape-strip: the name is baked into a generated string literal.\r\n\t\t\t\tg.Name = ( g.Name ?? \"Generator\" ).Replace( \"\\\\\", \"\" ).Replace( \"\\\"\", \"\" );\r\n\t\t\t\tif ( g.BaseCost <= 0f ) g.BaseCost = 1f;\r\n\t\t\t\tif ( g.Growth < 1f ) g.Growth = 1f;\r\n\t\t\t\tif ( g.IncomePerSecond < 0f ) g.IncomePerSecond = 0f;\r\n\t\t\t\tresult.Add( g );\r\n\t\t\t}\r\n\t\t}\r\n\t\tif ( result.Count == 0 )\r\n\t\t{\r\n\t\t\tresult.Add( new GeneratorDef { Name = \"Cursor\", BaseCost = 15f, Growth = 1.15f, IncomePerSecond = 0.5f } );\r\n\t\t\tresult.Add( new GeneratorDef { Name = \"Farm\", BaseCost = 200f, Growth = 1.15f, IncomePerSecond = 4f } );\r\n\t\t\tresult.Add( new GeneratorDef { Name = \"Factory\", BaseCost = 3000f, Growth = 1.12f, IncomePerSecond = 30f } );\r\n\t\t}\r\n\t\treturn result;\r\n\t}\r\n\r\n\tstatic string BuildCode( string className, List<GeneratorDef> gens, float tick, System.Globalization.CultureInfo ci )\r\n\t{\r\n\t\tstring nameLits = string.Join( \", \", gens.Select( g => $\"\\\"{g.Name}\\\"\" ) );\r\n\t\tstring costLits = string.Join( \", \", gens.Select( g => g.BaseCost.ToString( ci ) + \"f\" ) );\r\n\t\tstring growthLits = string.Join( \", \", gens.Select( g => g.Growth.ToString( ci ) + \"f\" ) );\r\n\t\tstring incomeLits = string.Join( \", \", gens.Select( g => g.IncomePerSecond.ToString( ci ) + \"f\" ) );\r\n\t\tstring tk = tick.ToString( ci ) + \"f\";\r\n\r\n\t\treturn $@\"using Sandbox;\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\n\r\n/// <summary>\r\n/// {className} -- a geometric idle economy: generators, bulk buying, passive income.\r\n///\r\n/// COST CURVE: buying copy k of generator i costs BaseCosts[i] * Growths[i]^k -- the\r\n/// classic incremental-game curve. CostOf / MaxAffordable / TryBuy all use the CLOSED-FORM\r\n/// geometric series (no per-copy loops), so Buy 1000 is the same math as Buy 1:\r\n/// cost(n) = c0 * (g^n - 1) / (g - 1) where c0 = BaseCost * g^Owned\r\n/// buyMax = floor( log_g( funds*(g-1)/c0 + 1 ) )\r\n///\r\n/// WALLET WIRING (TypeLibrary reflection -- no compile-time wallet dependency): income\r\n/// invokes AddMoney(long|int) on the first sibling component that has one; purchases\r\n/// invoke TrySpend(long|int); Buy Max reads the sibling's Money property. Works out of\r\n/// the box next to a create_economy_wallet or create_currency_account scaffold. No wallet\r\n/// sibling = purchases are REFUSED with a Log.Warning (never silent).\r\n///\r\n/// HOST-AUTHORITATIVE: all mutation is IsProxy-guarded; owned counts are host-side state\r\n/// (not replicated -- replicate via your own [Sync]/RPC if clients need them). TotalEarned\r\n/// is [Sync(FromHost)]. Single-player safe.\r\n///\r\n/// Usage:\r\n/// GetComponent<{className}>().TryBuy( 0, 1 ); // Buy 1\r\n/// GetComponent<{className}>().TryBuy( 0, 10 ); // Buy N\r\n/// int bought = GetComponent<{className}>().BuyMax( 0 ); // Buy Max\r\n/// {className}.OnPurchased += ( i, count, cost ) => {{ /* refresh shop UI */ }};\r\n/// {className}.OnIncomeTick += ( amount, total ) => {{ /* +N popup */ }};\r\n/// </summary>\r\npublic sealed class {className} : Component\r\n{{\r\n\t/// Generator display names -- parallel to BaseCosts / Growths / IncomesPerSecond.\r\n\t[Property] public List<string> GeneratorNames {{ get; set; }} = new List<string> {{ {nameLits} }};\r\n\r\n\t/// Cost of the FIRST copy of each generator (curve: BaseCost * Growth^Owned).\r\n\t[Property] public List<float> BaseCosts {{ get; set; }} = new List<float> {{ {costLits} }};\r\n\r\n\t/// Per-copy cost multiplier (1.15 = the classic curve). Values below 1 are treated as 1 (flat cost).\r\n\t[Property] public List<float> Growths {{ get; set; }} = new List<float> {{ {growthLits} }};\r\n\r\n\t/// Income each owned copy produces per second.\r\n\t[Property] public List<float> IncomesPerSecond {{ get; set; }} = new List<float> {{ {incomeLits} }};\r\n\r\n\t/// Seconds between income grants.\r\n\t[Property] public float TickSeconds {{ get; set; }} = {tk};\r\n\r\n\t/// Total income ever granted (host-authoritative, replicates to clients).\r\n\t[Sync( SyncFlags.FromHost )] public float TotalEarned {{ get; set; }}\r\n\r\n\t/// Fires host-side after a purchase: (generatorIndex, countBought, totalCost).\r\n\tpublic static Action<int, int, double> OnPurchased {{ get; set; }}\r\n\r\n\t/// Fires host-side after each income grant: (amount, newTotalEarned).\r\n\tpublic static Action<float, float> OnIncomeTick {{ get; set; }}\r\n\r\n\t// Host-side owned counts, parallel to the property lists.\r\n\tprivate int[] _owned;\r\n\tprivate TimeUntil _nextTick;\r\n\r\n\tprotected override void OnStart()\r\n\t{{\r\n\t\t_nextTick = TickSeconds;\r\n\t}}\r\n\r\n\tprotected override void OnFixedUpdate()\r\n\t{{\r\n\t\tif ( IsProxy ) return;\r\n\t\tif ( !_nextTick ) return;\r\n\t\t_nextTick = TickSeconds;\r\n\r\n\t\tEnsureOwned();\r\n\t\tfloat amount = 0f;\r\n\t\tfor ( int i = 0; i < _owned.Length; i++ )\r\n\t\t\tamount += _owned[i] * IncomeOf( i ) * TickSeconds;\r\n\t\tif ( amount <= 0f ) return;\r\n\r\n\t\tTotalEarned += amount;\r\n\t\tGrantIncome( amount );\r\n\t\tOnIncomeTick?.Invoke( amount, TotalEarned );\r\n\t}}\r\n\r\n\t/// <summary>Copies of a generator owned (host-side state; 0 on proxies).</summary>\r\n\tpublic int GetOwned( int index )\r\n\t{{\r\n\t\tEnsureOwned();\r\n\t\treturn index >= 0 && index < _owned.Length ? _owned[index] : 0;\r\n\t}}\r\n\r\n\t/// <summary>\r\n\t/// Closed-form cost of the next `count` copies of generator `index` from the current\r\n\t/// owned count. 0 for an invalid index or non-positive count.\r\n\t/// </summary>\r\n\tpublic double CostOf( int index, int count )\r\n\t{{\r\n\t\tif ( count <= 0 || !ValidIndex( index ) ) return 0.0;\r\n\t\tdouble g = GrowthOf( index );\r\n\t\tdouble c0 = BaseCosts[index] * Math.Pow( g, GetOwned( index ) );\r\n\t\tif ( Math.Abs( g - 1.0 ) < 0.0001 ) return c0 * count;\r\n\t\treturn c0 * ( Math.Pow( g, count ) - 1.0 ) / ( g - 1.0 );\r\n\t}}\r\n\r\n\t/// <summary>\r\n\t/// Closed-form Buy-Max count against the sibling wallet's current Money.\r\n\t/// 0 when nothing is affordable or no wallet sibling exposes a Money property.\r\n\t/// </summary>\r\n\tpublic int MaxAffordable( int index )\r\n\t{{\r\n\t\tif ( !ValidIndex( index ) ) return 0;\r\n\t\tdouble funds = ReadWalletBalance();\r\n\t\tif ( funds <= 0.0 ) return 0;\r\n\t\tdouble g = GrowthOf( index );\r\n\t\tdouble c0 = BaseCosts[index] * Math.Pow( g, GetOwned( index ) );\r\n\t\tif ( c0 <= 0.0 ) return 0;\r\n\t\tif ( Math.Abs( g - 1.0 ) < 0.0001 ) return (int) Math.Floor( funds / c0 );\r\n\t\treturn (int) Math.Floor( Math.Log( funds * ( g - 1.0 ) / c0 + 1.0 ) / Math.Log( g ) );\r\n\t}}\r\n\r\n\t/// <summary>\r\n\t/// Buy `count` copies if the sibling wallet's TrySpend accepts the closed-form cost\r\n\t/// (rounded up to whole currency). Host-only; false when unaffordable or no wallet.\r\n\t/// </summary>\r\n\tpublic bool TryBuy( int index, int count )\r\n\t{{\r\n\t\tif ( IsProxy || count <= 0 || !ValidIndex( index ) ) return false;\r\n\t\tEnsureOwned();\r\n\t\tdouble cost = CostOf( index, count );\r\n\t\tif ( !SpendFromWallet( cost ) ) return false;\r\n\t\t_owned[index] += count;\r\n\t\tOnPurchased?.Invoke( index, count, cost );\r\n\t\treturn true;\r\n\t}}\r\n\r\n\t/// <summary>\r\n\t/// Buy as many copies as the wallet can afford. Returns the count bought (0 = none).\r\n\t/// Steps down once past a whole-currency rounding edge rather than failing.\r\n\t/// </summary>\r\n\tpublic int BuyMax( int index )\r\n\t{{\r\n\t\tint n = MaxAffordable( index );\r\n\t\twhile ( n > 0 )\r\n\t\t{{\r\n\t\t\tif ( TryBuy( index, n ) ) return n;\r\n\t\t\tn--; // ceil-rounding edge: the closed form said n, the wallet said no -- step down\r\n\t\t}}\r\n\t\treturn 0;\r\n\t}}\r\n\r\n\tprivate bool ValidIndex( int index )\r\n\t\t=> BaseCosts != null && index >= 0 && index < BaseCosts.Count;\r\n\r\n\tprivate double GrowthOf( int index )\r\n\t{{\r\n\t\tfloat g = Growths != null && index < Growths.Count ? Growths[index] : 1.15f;\r\n\t\treturn g < 1f ? 1.0 : g;\r\n\t}}\r\n\r\n\tprivate float IncomeOf( int index )\r\n\t\t=> IncomesPerSecond != null && index < IncomesPerSecond.Count && index >= 0 ? IncomesPerSecond[index] : 0f;\r\n\r\n\tprivate void EnsureOwned()\r\n\t{{\r\n\t\tint size = BaseCosts?.Count ?? 0;\r\n\t\tint names = GeneratorNames?.Count ?? 0;\r\n\t\tif ( names > size ) size = names;\r\n\t\tif ( size < 1 ) size = 1;\r\n\r\n\t\tif ( _owned == null )\r\n\t\t{{\r\n\t\t\t_owned = new int[size];\r\n\t\t}}\r\n\t\telse if ( _owned.Length < size )\r\n\t\t{{\r\n\t\t\tvar grown = new int[size];\r\n\t\t\tfor ( int i = 0; i < _owned.Length; i++ ) grown[i] = _owned[i];\r\n\t\t\t_owned = grown;\r\n\t\t}}\r\n\t}}\r\n\r\n\t// ---- sibling-wallet wiring (TypeLibrary reflection; no hard wallet dependency) ----\r\n\r\n\t// Deliver income: AddMoney(long|int) on the first sibling that has one.\r\n\tprivate void GrantIncome( float amount )\r\n\t{{\r\n\t\tforeach ( var comp in Components.GetAll() )\r\n\t\t{{\r\n\t\t\tif ( comp == this || comp is null ) continue;\r\n\t\t\tvar type = Game.TypeLibrary?.GetType( comp.GetType() );\r\n\t\t\tvar method = type?.Methods?.FirstOrDefault( m => m.Name == \"\"AddMoney\"\" );\r\n\t\t\tif ( method == null ) continue;\r\n\t\t\ttry {{ method.Invoke( comp, new object[] {{ (long) amount }} ); return; }}\r\n\t\t\tcatch {{ }}\r\n\t\t\ttry {{ method.Invoke( comp, new object[] {{ (int) amount }} ); return; }}\r\n\t\t\tcatch {{ /* wrong signature -- keep looking */ }}\r\n\t\t}}\r\n\t\t// No wallet sibling -- TotalEarned still accumulates; read it directly.\r\n\t}}\r\n\r\n\t// Spend: TrySpend(long|int) on the first sibling that has one. Never silent on failure.\r\n\tprivate bool SpendFromWallet( double cost )\r\n\t{{\r\n\t\tif ( cost <= 0.0 ) return false;\r\n\t\tlong rounded = (long) Math.Ceiling( cost );\r\n\t\tforeach ( var comp in Components.GetAll() )\r\n\t\t{{\r\n\t\t\tif ( comp == this || comp is null ) continue;\r\n\t\t\tvar type = Game.TypeLibrary?.GetType( comp.GetType() );\r\n\t\t\tvar method = type?.Methods?.FirstOrDefault( m => m.Name == \"\"TrySpend\"\" );\r\n\t\t\tif ( method == null ) continue;\r\n\t\t\ttry {{ return method.InvokeWithReturn<bool>( comp, new object[] {{ rounded }} ); }}\r\n\t\t\tcatch {{ }}\r\n\t\t\ttry {{ return method.InvokeWithReturn<bool>( comp, new object[] {{ (int) rounded }} ); }}\r\n\t\t\tcatch {{ /* wrong signature -- keep looking */ }}\r\n\t\t}}\r\n\t\tLog.Warning( $\"\"[{className}] No sibling wallet with TrySpend found -- add a create_economy_wallet / create_currency_account component next to it. Purchase refused.\"\" );\r\n\t\treturn false;\r\n\t}}\r\n\r\n\t// Read funds for Buy Max: the first sibling exposing a numeric Money property.\r\n\tprivate double ReadWalletBalance()\r\n\t{{\r\n\t\tforeach ( var comp in Components.GetAll() )\r\n\t\t{{\r\n\t\t\tif ( comp == this || comp is null ) continue;\r\n\t\t\tvar type = Game.TypeLibrary?.GetType( comp.GetType() );\r\n\t\t\tvar prop = type?.Properties?.FirstOrDefault( pp => pp.Name == \"\"Money\"\" || pp.Name == \"\"Balance\"\" );\r\n\t\t\tif ( prop == null ) continue;\r\n\t\t\ttry\r\n\t\t\t{{\r\n\t\t\t\tobject v = prop.GetValue( comp );\r\n\t\t\t\tif ( v is long l ) return l;\r\n\t\t\t\tif ( v is int i ) return i;\r\n\t\t\t\tif ( v is float f ) return f;\r\n\t\t\t\tif ( v is double d ) return d;\r\n\t\t\t}}\r\n\t\t\tcatch {{ /* unreadable -- keep looking */ }}\r\n\t\t}}\r\n\t\treturn 0.0;\r\n\t}}\r\n}}\r\n\";\r\n\t}\r\n}\r\n\r\n// -----------------------------------------------------------------------------\r\n// create_signed_save -- tamper-evident save file. The payload POCO is serialized\r\n// to JSON (Sandbox.Json), FNV-1a-64 hashed together with a salt + version, and\r\n// written inside a signed envelope via FileSystem.Data. Load verifies the\r\n// signature; a mismatch = forced reset (delete + defaults) + OnTampered event.\r\n// Clamp-on-load Sanitize() hook + versioning copy create_save_system's shape.\r\n// -----------------------------------------------------------------------------\r\npublic class CreateSignedSaveHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tif ( !ScaffoldHelpers.PrepareCodeFile( p, \"SignedSave\", out var fullPath, out var relPath, out var className, out var err ) )\r\n\t\t\t\treturn Task.FromResult<object>( err );\r\n\r\n\t\t\tvar ci = System.Globalization.CultureInfo.InvariantCulture;\r\n\t\t\tstring fileName = p.TryGetProperty( \"fileName\", out var fn ) && !string.IsNullOrWhiteSpace( fn.GetString() ) ? fn.GetString() : \"save_signed.json\";\r\n\t\t\tint version = p.TryGetProperty( \"version\", out var vv ) && vv.TryGetInt32( out var vi ) ? vi : 1;\r\n\t\t\tfloat autosave = p.TryGetProperty( \"autosaveSeconds\", out var av ) && av.TryGetSingle( out var af ) ? af : 10f;\r\n\t\t\tstring salt = p.TryGetProperty( \"salt\", out var sv ) && !string.IsNullOrWhiteSpace( sv.GetString() )\r\n\t\t\t\t? sv.GetString()\r\n\t\t\t\t: Guid.NewGuid().ToString( \"N\" ); // unique per generated file by default\r\n\r\n\t\t\t// These are baked into generated string literals -- strip escape characters.\r\n\t\t\tfileName = fileName.Replace( \"\\\\\", \"\" ).Replace( \"\\\"\", \"\" );\r\n\t\t\tsalt = salt.Replace( \"\\\\\", \"\" ).Replace( \"\\\"\", \"\" );\r\n\r\n\t\t\tvar code = BuildCode( className, fileName, version.ToString( ci ), autosave.ToString( ci ) + \"f\", salt );\r\n\t\t\tScaffoldHelpers.WriteCode( fullPath, code );\r\n\r\n\t\t\tobject placedOn = null; string note = null;\r\n\t\t\tif ( p.TryGetProperty( \"targetId\", out var tid ) && tid.ValueKind == JsonValueKind.String )\r\n\t\t\t\tplacedOn = EconomySaveHelpers.PlaceOnTarget( tid.GetString(), className, out note );\r\n\r\n\t\t\treturn Task.FromResult<object>( new\r\n\t\t\t{\r\n\t\t\t\tcreated = true,\r\n\t\t\t\tpath = relPath,\r\n\t\t\t\tclassName,\r\n\t\t\t\tfileName,\r\n\t\t\t\tversion,\r\n\t\t\t\tautosaveSeconds = autosave,\r\n\t\t\t\tplacedOn,\r\n\t\t\t\tnote,\r\n\t\t\t\tnextSteps = new[]\r\n\t\t\t\t{\r\n\t\t\t\t\t$\"trigger_hotload to compile {className} into the game assembly.\",\r\n\t\t\t\t\tplacedOn != null\r\n\t\t\t\t\t\t? $\"{className} was attached to the target GameObject.\"\r\n\t\t\t\t\t\t: $\"Place it on your save-manager GameObject: add_component_to_new_object (component=\\\"{className}\\\") after the hotload, or re-run with targetId.\",\r\n\t\t\t\t\t$\"Add your game fields to the SaveData inner class in {className}.cs, extend Sanitize() to clamp them, and bump Version when the shape changes.\",\r\n\t\t\t\t\t$\"Use it: GetComponent<{className}>().Data.Money += 100; GetComponent<{className}>().MarkDirty(); -- the dirty-flag autosave (or OnDestroy) writes and re-signs.\",\r\n\t\t\t\t\t$\"React: {className}.OnLoaded += d => {{ }}; {className}.OnSaved += d => {{ }}; {className}.OnTampered += reason => {{ /* tell the player their save was reset */ }};\",\r\n\t\t\t\t\t\"TAMPER = FORCED RESET: an edited payload fails the FNV-1a signature check on load, the file is DELETED and defaults are used (OnTampered fires with the reason). This is tamper-EVIDENT, not cryptographically secure -- the salt ships in the game code, so a determined user can re-sign; it stops casual notepad edits, not reverse engineers.\"\r\n\t\t\t\t}\r\n\t\t\t} );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"create_signed_save failed: {ex.Message}\" } );\r\n\t\t}\r\n\t}\r\n\r\n\tstatic string BuildCode( string className, string fileName, string version, string autosave, string salt )\r\n\t{\r\n\t\treturn $@\"using Sandbox;\r\nusing System;\r\n\r\n/// <summary>\r\n/// {className} -- a tamper-evident, versioned save system.\r\n///\r\n/// The SaveData payload is serialized to JSON, hashed with FNV-1a-64 over\r\n/// payload + version + salt, and written inside a signed envelope to\r\n/// FileSystem.Data. Load re-computes the signature: a mismatch (hand-edited or\r\n/// corrupt file) triggers a FORCED RESET -- the file is deleted, defaults are\r\n/// used, and the static OnTampered event fires. A version mismatch starts fresh\r\n/// (add migrations in Load if you need them). Loaded values pass through the\r\n/// Sanitize() clamp hook so even a re-signed save can't smuggle absurd values.\r\n///\r\n/// NOT cryptography: the salt ships inside the game assembly, so this is\r\n/// tamper-EVIDENT (stops notepad edits), not tamper-PROOF.\r\n///\r\n/// Host/owner-only (IsProxy-guarded). Dirty-flag autosave every AutosaveSeconds\r\n/// plus a final save in OnDestroy.\r\n///\r\n/// Usage:\r\n/// var save = GetComponent<{className}>();\r\n/// save.Data.Money += 100; save.MarkDirty();\r\n/// {className}.OnTampered += reason => Log.Warning( $\"\"save reset: {{reason}}\"\" );\r\n/// </summary>\r\npublic sealed class {className} : Component\r\n{{\r\n\t/// FileSystem.Data path the signed envelope is written to.\r\n\t[Property] public string FileName {{ get; set; }} = \"\"{fileName}\"\";\r\n\r\n\t/// Autosave cadence in seconds. 0 disables the heartbeat (OnDestroy still saves).\r\n\t[Property] public float AutosaveSeconds {{ get; set; }} = {autosave};\r\n\r\n\t/// Save-shape version -- bump when SaveData changes so old files start fresh.\r\n\tpublic const int Version = {version};\r\n\r\n\t// Baked-in signing salt (unique to this generated file). Changing it invalidates existing saves.\r\n\tprivate const string Salt = \"\"{salt}\"\";\r\n\r\n\t/// The save payload. Add your own fields here; clamp them in Sanitize().\r\n\tpublic class SaveData\r\n\t{{\r\n\t\tpublic int Money {{ get; set; }}\r\n\t\tpublic int Day {{ get; set; }} = 1;\r\n\t\t// Add game fields here.\r\n\t}}\r\n\r\n\t/// The envelope actually written to disk: version + raw payload JSON + signature.\r\n\tpublic class SaveEnvelope\r\n\t{{\r\n\t\tpublic int Version {{ get; set; }}\r\n\t\tpublic string Payload {{ get; set; }}\r\n\t\tpublic ulong Signature {{ get; set; }}\r\n\t}}\r\n\r\n\tpublic SaveData Data {{ get; private set; }} = new SaveData();\r\n\tpublic bool IsDirty {{ get; private set; }}\r\n\r\n\t/// Fires after a successful Load() with the loaded (sanitized) data.\r\n\tpublic static Action<SaveData> OnLoaded {{ get; set; }}\r\n\t/// Fires after every Save().\r\n\tpublic static Action<SaveData> OnSaved {{ get; set; }}\r\n\t/// Fires when the signature check fails and the save is force-reset. Arg = reason.\r\n\tpublic static Action<string> OnTampered {{ get; set; }}\r\n\r\n\tprivate TimeUntil _nextAutosave;\r\n\r\n\tprotected override void OnStart()\r\n\t{{\r\n\t\tif ( IsProxy ) return; // only the owning machine loads\r\n\t\tLoad();\r\n\t\t_nextAutosave = AutosaveSeconds;\r\n\t}}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{{\r\n\t\tif ( IsProxy || AutosaveSeconds <= 0f ) return;\r\n\t\tif ( _nextAutosave )\r\n\t\t{{\r\n\t\t\t_nextAutosave = AutosaveSeconds;\r\n\t\t\tif ( IsDirty ) Save();\r\n\t\t}}\r\n\t}}\r\n\r\n\tprotected override void OnDestroy()\r\n\t{{\r\n\t\tif ( !IsProxy && IsDirty ) Save();\r\n\t}}\r\n\r\n\t/// Mark the data changed so the next autosave tick (or OnDestroy) writes + re-signs it.\r\n\tpublic void MarkDirty() => IsDirty = true;\r\n\r\n\tpublic void Load()\r\n\t{{\r\n\t\tvar envelope = FileSystem.Data.ReadJsonOrDefault<SaveEnvelope>( FileName, null );\r\n\t\tif ( envelope == null )\r\n\t\t{{\r\n\t\t\t// Missing or unreadable envelope: start fresh (not treated as tampering).\r\n\t\t\tData = new SaveData();\r\n\t\t\tIsDirty = true;\r\n\t\t}}\r\n\t\telse if ( envelope.Version != Version )\r\n\t\t{{\r\n\t\t\t// Old save shape: start fresh (add migrations here later).\r\n\t\t\tData = new SaveData();\r\n\t\t\tIsDirty = true;\r\n\t\t}}\r\n\t\telse if ( envelope.Payload == null || ComputeSignature( envelope.Payload ) != envelope.Signature )\r\n\t\t{{\r\n\t\t\tForceReset( \"\"signature mismatch -- save file was modified outside the game\"\" );\r\n\t\t\treturn; // ForceReset already fired OnLoaded\r\n\t\t}}\r\n\t\telse\r\n\t\t{{\r\n\t\t\tSaveData loaded = null;\r\n\t\t\ttry {{ loaded = Json.Deserialize<SaveData>( envelope.Payload ); }}\r\n\t\t\tcatch {{ }}\r\n\t\t\tif ( loaded == null )\r\n\t\t\t{{\r\n\t\t\t\tForceReset( \"\"payload failed to parse despite a valid signature\"\" );\r\n\t\t\t\treturn;\r\n\t\t\t}}\r\n\t\t\tData = Sanitize( loaded );\r\n\t\t\tIsDirty = false;\r\n\t\t}}\r\n\t\tOnLoaded?.Invoke( Data );\r\n\t}}\r\n\r\n\tpublic void Save()\r\n\t{{\r\n\t\tvar payload = Json.Serialize( Data );\r\n\t\tvar envelope = new SaveEnvelope\r\n\t\t{{\r\n\t\t\tVersion = Version,\r\n\t\t\tPayload = payload,\r\n\t\t\tSignature = ComputeSignature( payload )\r\n\t\t}};\r\n\t\tFileSystem.Data.WriteJson( FileName, envelope );\r\n\t\tIsDirty = false;\r\n\t\tOnSaved?.Invoke( Data );\r\n\t}}\r\n\r\n\t/// <summary>Delete the save file and reset to defaults. Fires OnTampered then OnLoaded.</summary>\r\n\tpublic void ForceReset( string reason )\r\n\t{{\r\n\t\ttry\r\n\t\t{{\r\n\t\t\tif ( FileSystem.Data.FileExists( FileName ) )\r\n\t\t\t\tFileSystem.Data.DeleteFile( FileName );\r\n\t\t}}\r\n\t\tcatch {{ }}\r\n\t\tData = new SaveData();\r\n\t\tIsDirty = true;\r\n\t\tOnTampered?.Invoke( reason ?? \"\"forced reset\"\" );\r\n\t\tOnLoaded?.Invoke( Data );\r\n\t}}\r\n\r\n\t/// Clamp-on-load: keep loaded values inside sane ranges so even a re-signed\r\n\t/// save can't smuggle absurd values. Extend per field you add.\r\n\tprivate SaveData Sanitize( SaveData d )\r\n\t{{\r\n\t\tif ( d.Money < 0 ) d.Money = 0;\r\n\t\tif ( d.Day < 1 ) d.Day = 1;\r\n\t\treturn d;\r\n\t}}\r\n\r\n\t// FNV-1a 64-bit over payload + version + salt. Deterministic, allocation-light.\r\n\tprivate static ulong ComputeSignature( string payload )\r\n\t{{\r\n\t\tconst ulong offsetBasis = 14695981039346656037UL;\r\n\t\tconst ulong prime = 1099511628211UL;\r\n\r\n\t\tulong hash = offsetBasis;\r\n\t\tstring material = payload + \"\"|\"\" + Version + \"\"|\"\" + Salt;\r\n\t\tfor ( int i = 0; i < material.Length; i++ )\r\n\t\t{{\r\n\t\t\thash ^= material[i];\r\n\t\t\thash *= prime;\r\n\t\t}}\r\n\t\treturn hash;\r\n\t}}\r\n}}\r\n\";\r\n\t}\r\n}\r\n\r\n// -----------------------------------------------------------------------------\r\n// create_meta_progression -- the between-runs roguelite meta layer: persistent\r\n// meta-currency + unlock-flag dictionary saved to FileSystem.Data JSON.\r\n// Grant/TrySpend/Unlock/IsUnlocked + a BankRun(int) run-end seam + a static\r\n// OnUnlocked event. Persistence copies create_save_system's dirty-flag shape.\r\n// -----------------------------------------------------------------------------\r\npublic class CreateMetaProgressionHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tif ( !ScaffoldHelpers.PrepareCodeFile( p, \"MetaProgression\", out var fullPath, out var relPath, out var className, out var err ) )\r\n\t\t\t\treturn Task.FromResult<object>( err );\r\n\r\n\t\t\tvar ci = System.Globalization.CultureInfo.InvariantCulture;\r\n\t\t\tstring fileName = p.TryGetProperty( \"fileName\", out var fn ) && !string.IsNullOrWhiteSpace( fn.GetString() ) ? fn.GetString() : \"meta.json\";\r\n\t\t\tint version = p.TryGetProperty( \"version\", out var vv ) && vv.TryGetInt32( out var vi ) ? vi : 1;\r\n\t\t\tfloat autosave = p.TryGetProperty( \"autosaveSeconds\", out var av ) && av.TryGetSingle( out var af ) ? af : 10f;\r\n\r\n\t\t\tfileName = fileName.Replace( \"\\\\\", \"\" ).Replace( \"\\\"\", \"\" );\r\n\r\n\t\t\tvar code = BuildCode( className, fileName, version.ToString( ci ), autosave.ToString( ci ) + \"f\" );\r\n\t\t\tScaffoldHelpers.WriteCode( fullPath, code );\r\n\r\n\t\t\tobject placedOn = null; string note = null;\r\n\t\t\tif ( p.TryGetProperty( \"targetId\", out var tid ) && tid.ValueKind == JsonValueKind.String )\r\n\t\t\t\tplacedOn = EconomySaveHelpers.PlaceOnTarget( tid.GetString(), className, out note );\r\n\r\n\t\t\treturn Task.FromResult<object>( new\r\n\t\t\t{\r\n\t\t\t\tcreated = true,\r\n\t\t\t\tpath = relPath,\r\n\t\t\t\tclassName,\r\n\t\t\t\tfileName,\r\n\t\t\t\tversion,\r\n\t\t\t\tplacedOn,\r\n\t\t\t\tnote,\r\n\t\t\t\tnextSteps = new[]\r\n\t\t\t\t{\r\n\t\t\t\t\t$\"trigger_hotload to compile {className} into the game assembly.\",\r\n\t\t\t\t\tplacedOn != null\r\n\t\t\t\t\t\t? $\"{className} was attached to the target GameObject.\"\r\n\t\t\t\t\t\t: $\"Place it on a persistent manager GameObject (one that exists in your hub/menu scene): add_component_to_new_object (component=\\\"{className}\\\") after the hotload, or re-run with targetId.\",\r\n\t\t\t\t\t$\"At run end, bank the earnings: GetComponent<{className}>().BankRun( runCurrencyEarned ); -- it grants and saves immediately.\",\r\n\t\t\t\t\t$\"Gate content: if ( GetComponent<{className}>().TrySpend( 50 ) ) GetComponent<{className}>().Unlock( \\\"double_jump\\\" ); then check IsUnlocked( \\\"double_jump\\\" ) when building the player.\",\r\n\t\t\t\t\t$\"React to unlocks anywhere: {className}.OnUnlocked += key => {{ /* flash the new item in the meta shop */ }};\",\r\n\t\t\t\t\t\"MetaCurrency and the unlock flags persist to FileSystem.Data across sessions (dirty-flag autosave + OnDestroy). IsProxy-guarded: in multiplayer each machine banks only its own meta file.\"\r\n\t\t\t\t}\r\n\t\t\t} );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"create_meta_progression failed: {ex.Message}\" } );\r\n\t\t}\r\n\t}\r\n\r\n\tstatic string BuildCode( string className, string fileName, string version, string autosave )\r\n\t{\r\n\t\treturn $@\"using Sandbox;\r\nusing System;\r\nusing System.Collections.Generic;\r\n\r\n/// <summary>\r\n/// {className} -- the between-runs roguelite meta layer.\r\n///\r\n/// Persists a meta-currency plus an unlock-flag dictionary to FileSystem.Data JSON\r\n/// (dirty-flag autosave + OnDestroy, create_save_system's shape). During a run you earn\r\n/// normal run-currency; at run end call BankRun(earned) to convert it into persistent\r\n/// meta-currency. Spend meta-currency on permanent Unlock() flags and gate content with\r\n/// IsUnlocked(). The static OnUnlocked event fires on every new unlock.\r\n///\r\n/// Owner-only (IsProxy-guarded): each machine banks only its own meta file.\r\n///\r\n/// Usage:\r\n/// GetComponent<{className}>().BankRun( 120 ); // run over\r\n/// if ( GetComponent<{className}>().TrySpend( 50 ) )\r\n/// GetComponent<{className}>().Unlock( \"\"double_jump\"\" );\r\n/// if ( GetComponent<{className}>().IsUnlocked( \"\"double_jump\"\" ) ) {{ /* enable it */ }}\r\n/// {className}.OnUnlocked += key => {{ /* celebrate */ }};\r\n/// </summary>\r\npublic sealed class {className} : Component\r\n{{\r\n\t/// FileSystem.Data path the meta state is written to.\r\n\t[Property] public string FileName {{ get; set; }} = \"\"{fileName}\"\";\r\n\r\n\t/// Autosave cadence in seconds. 0 disables the heartbeat (OnDestroy still saves).\r\n\t[Property] public float AutosaveSeconds {{ get; set; }} = {autosave};\r\n\r\n\t/// The persisted payload. Bump Version when the shape changes so old files start fresh.\r\n\tpublic class MetaData\r\n\t{{\r\n\t\tpublic int Version {{ get; set; }} = {version};\r\n\t\tpublic long MetaCurrency {{ get; set; }}\r\n\t\tpublic int RunsBanked {{ get; set; }}\r\n\t\tpublic Dictionary<string, bool> Unlocks {{ get; set; }} = new Dictionary<string, bool>();\r\n\t}}\r\n\r\n\tpublic MetaData Data {{ get; private set; }} = new MetaData();\r\n\tpublic bool IsDirty {{ get; private set; }}\r\n\r\n\t/// Fires (on the owning machine) when a key is unlocked for the FIRST time.\r\n\tpublic static Action<string> OnUnlocked {{ get; set; }}\r\n\r\n\t/// Fires whenever MetaCurrency changes -- bind the meta-shop balance label here.\r\n\tpublic Action<long> OnCurrencyChanged {{ get; set; }}\r\n\r\n\tprivate TimeUntil _nextAutosave;\r\n\r\n\tprotected override void OnStart()\r\n\t{{\r\n\t\tif ( IsProxy ) return; // only the owning machine loads\r\n\t\tLoad();\r\n\t\t_nextAutosave = AutosaveSeconds;\r\n\t}}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{{\r\n\t\tif ( IsProxy || AutosaveSeconds <= 0f ) return;\r\n\t\tif ( _nextAutosave )\r\n\t\t{{\r\n\t\t\t_nextAutosave = AutosaveSeconds;\r\n\t\t\tif ( IsDirty ) Save();\r\n\t\t}}\r\n\t}}\r\n\r\n\tprotected override void OnDestroy()\r\n\t{{\r\n\t\tif ( !IsProxy && IsDirty ) Save();\r\n\t}}\r\n\r\n\t/// <summary>Add meta-currency. Non-positive amounts are ignored.</summary>\r\n\tpublic void Grant( long amount )\r\n\t{{\r\n\t\tif ( IsProxy || amount <= 0 ) return;\r\n\t\tData.MetaCurrency += amount;\r\n\t\tIsDirty = true;\r\n\t\tOnCurrencyChanged?.Invoke( Data.MetaCurrency );\r\n\t}}\r\n\r\n\t/// <summary>Spend meta-currency if affordable; false and no change otherwise.</summary>\r\n\tpublic bool TrySpend( long amount )\r\n\t{{\r\n\t\tif ( IsProxy || amount <= 0 ) return false;\r\n\t\tif ( Data.MetaCurrency < amount ) return false;\r\n\t\tData.MetaCurrency -= amount;\r\n\t\tIsDirty = true;\r\n\t\tOnCurrencyChanged?.Invoke( Data.MetaCurrency );\r\n\t\treturn true;\r\n\t}}\r\n\r\n\t/// <summary>Set a permanent unlock flag. Idempotent; OnUnlocked fires only the first time. Saves immediately.</summary>\r\n\tpublic void Unlock( string key )\r\n\t{{\r\n\t\tif ( IsProxy || string.IsNullOrEmpty( key ) ) return;\r\n\t\tif ( Data.Unlocks.TryGetValue( key, out var already ) && already ) return;\r\n\t\tData.Unlocks[key] = true;\r\n\t\tSave(); // unlocks are precious -- write through immediately\r\n\t\tOnUnlocked?.Invoke( key );\r\n\t}}\r\n\r\n\t/// <summary>True when a key has been permanently unlocked.</summary>\r\n\tpublic bool IsUnlocked( string key )\r\n\t\t=> !string.IsNullOrEmpty( key ) && Data.Unlocks.TryGetValue( key, out var v ) && v;\r\n\r\n\t/// <summary>\r\n\t/// Run-end seam: convert this run's earnings into persistent meta-currency and\r\n\t/// save immediately. Call it from your round machine's end-of-run transition.\r\n\t/// </summary>\r\n\tpublic void BankRun( int earned )\r\n\t{{\r\n\t\tif ( IsProxy ) return;\r\n\t\tif ( earned > 0 ) Data.MetaCurrency += earned;\r\n\t\tData.RunsBanked += 1;\r\n\t\tSave();\r\n\t\tOnCurrencyChanged?.Invoke( Data.MetaCurrency );\r\n\t}}\r\n\r\n\t/// Mark the data changed so the next autosave tick (or OnDestroy) writes it.\r\n\tpublic void MarkDirty() => IsDirty = true;\r\n\r\n\tpublic void Load()\r\n\t{{\r\n\t\tvar loaded = FileSystem.Data.ReadJsonOrDefault<MetaData>( FileName, null );\r\n\t\tif ( loaded == null || loaded.Version != {version} )\r\n\t\t{{\r\n\t\t\tData = new MetaData();\r\n\t\t\tIsDirty = true;\r\n\t\t}}\r\n\t\telse\r\n\t\t{{\r\n\t\t\tif ( loaded.MetaCurrency < 0 ) loaded.MetaCurrency = 0;\r\n\t\t\tif ( loaded.RunsBanked < 0 ) loaded.RunsBanked = 0;\r\n\t\t\tif ( loaded.Unlocks == null ) loaded.Unlocks = new Dictionary<string, bool>();\r\n\t\t\tData = loaded;\r\n\t\t\tIsDirty = false;\r\n\t\t}}\r\n\t\tOnCurrencyChanged?.Invoke( Data.MetaCurrency );\r\n\t}}\r\n\r\n\tpublic void Save()\r\n\t{{\r\n\t\tFileSystem.Data.WriteJson( FileName, Data );\r\n\t\tIsDirty = false;\r\n\t}}\r\n}}\r\n\";\r\n\t}\r\n}\r\n\r\n// -----------------------------------------------------------------------------\r\n// add_steam_stat_currency -- currency persisted over Sandbox.Services.Stats.\r\n// Verified live on this SDK: static Stats.Increment(string,double),\r\n// Stats.SetValue(string,double,string,object), Stats.Flush(), and\r\n// Stats.GetLocalPlayerStats(string packageIdent) returning the NESTED\r\n// Stats.PlayerStats (Get(name) -> Stats.PlayerStat with .Value). There is\r\n// NO Stats.LocalPlayer property on this SDK.\r\n// -----------------------------------------------------------------------------\r\npublic class AddSteamStatCurrencyHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tif ( !ScaffoldHelpers.PrepareCodeFile( p, \"SteamStatCurrency\", out var fullPath, out var relPath, out var className, out var err ) )\r\n\t\t\t\treturn Task.FromResult<object>( err );\r\n\r\n\t\t\tstring statName = p.TryGetProperty( \"statName\", out var sv ) && !string.IsNullOrWhiteSpace( sv.GetString() ) ? sv.GetString() : \"currency\";\r\n\t\t\tstring packageIdent = p.TryGetProperty( \"packageIdent\", out var pv ) && !string.IsNullOrWhiteSpace( pv.GetString() ) ? pv.GetString() : \"\";\r\n\t\t\tbool flushEveryChange = p.TryGetProperty( \"flushEveryChange\", out var fv ) && fv.ValueKind == JsonValueKind.True;\r\n\r\n\t\t\t// Baked into generated string literals -- strip escape characters.\r\n\t\t\tstatName = statName.Replace( \"\\\\\", \"\" ).Replace( \"\\\"\", \"\" );\r\n\t\t\tpackageIdent = packageIdent.Replace( \"\\\\\", \"\" ).Replace( \"\\\"\", \"\" );\r\n\r\n\t\t\tvar code = BuildCode( className, statName, packageIdent, flushEveryChange );\r\n\t\t\tScaffoldHelpers.WriteCode( fullPath, code );\r\n\r\n\t\t\tobject placedOn = null; string note = null;\r\n\t\t\tif ( p.TryGetProperty( \"targetId\", out var tid ) && tid.ValueKind == JsonValueKind.String )\r\n\t\t\t\tplacedOn = EconomySaveHelpers.PlaceOnTarget( tid.GetString(), className, out note );\r\n\r\n\t\t\treturn Task.FromResult<object>( new\r\n\t\t\t{\r\n\t\t\t\tcreated = true,\r\n\t\t\t\tpath = relPath,\r\n\t\t\t\tclassName,\r\n\t\t\t\tstatName,\r\n\t\t\t\tpackageIdent = string.IsNullOrEmpty( packageIdent ) ? \"(Game.Ident -- the running package)\" : packageIdent,\r\n\t\t\t\tflushEveryChange,\r\n\t\t\t\tplacedOn,\r\n\t\t\t\tnote,\r\n\t\t\t\tnextSteps = new[]\r\n\t\t\t\t{\r\n\t\t\t\t\t$\"trigger_hotload to compile {className} into the game assembly.\",\r\n\t\t\t\t\tplacedOn != null\r\n\t\t\t\t\t\t? $\"{className} was attached to the target GameObject.\"\r\n\t\t\t\t\t\t: $\"Place it on the LOCAL player's GameObject (each player writes only their own Steam stat): add_component_to_new_object (component=\\\"{className}\\\") after the hotload, or re-run with targetId.\",\r\n\t\t\t\t\t$\"Use it: GetComponent<{className}>().Add( 25 ); if ( GetComponent<{className}>().TrySpend( 10 ) ) {{ }} -- Balance is the in-session truth; every change pushes Stats.SetValue.\",\r\n\t\t\t\t\t$\"React: {className}.OnBalanceLoaded += bal => {{ }}; and instance OnBalanceChanged for HUD labels. Wait for IsLoaded before showing the balance -- the read-back is async.\",\r\n\t\t\t\t\t\"CLOUD SEMANTICS: stats writes are buffered by the backend (Flush() pushes; the component flushes on destroy) and only apply to the LOCAL Steam user -- calling it for another player silently does nothing. Read-back is eventually consistent and can lag minutes; the in-session Balance property is authoritative while playing.\",\r\n\t\t\t\t\t\"Stats persist per Steam account per package ident -- dev sessions without a real published ident may read back nothing (you'll get balance 0 + a log line). This is Steam-cloud persistence, not a local save file; pair with create_signed_save if you need offline saves.\"\r\n\t\t\t\t}\r\n\t\t\t} );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"add_steam_stat_currency failed: {ex.Message}\" } );\r\n\t\t}\r\n\t}\r\n\r\n\tstatic string BuildCode( string className, string statName, string packageIdent, bool flushEveryChange )\r\n\t{\r\n\t\tstring flushLit = flushEveryChange ? \"true\" : \"false\";\r\n\r\n\t\treturn $@\"using Sandbox;\r\nusing Sandbox.Services;\r\nusing System;\r\n\r\n/// <summary>\r\n/// {className} -- a currency persisted over Sandbox.Services.Stats (Steam cloud).\r\n///\r\n/// The stat named StatName stores the ABSOLUTE balance (Stats.SetValue on every change);\r\n/// on start the component reads it back asynchronously via\r\n/// Stats.GetLocalPlayerStats(ident).Refresh() -> Get(StatName).Value and fires\r\n/// OnBalanceLoaded. While playing, the in-session Balance property is the authoritative\r\n/// value -- the cloud read-back is eventually consistent and can lag behind writes.\r\n///\r\n/// SCOPE: stats writes apply only to the LOCAL Steam user (writes for other players\r\n/// silently no-op) and persist per package ident. Attach this to the local player's\r\n/// GameObject; IsProxy guards keep remote copies inert. Dev sessions without a real\r\n/// published ident may read back nothing (balance starts at 0).\r\n///\r\n/// Usage:\r\n/// GetComponent<{className}>().Add( 25 );\r\n/// if ( GetComponent<{className}>().TrySpend( 10 ) ) {{ /* grant the thing */ }}\r\n/// {className}.OnBalanceLoaded += bal => {{ /* show the wallet */ }};\r\n/// </summary>\r\npublic sealed class {className} : Component\r\n{{\r\n\t/// The Sandbox.Services stat that stores the balance.\r\n\t[Property] public string StatName {{ get; set; }} = \"\"{statName}\"\";\r\n\r\n\t/// Package ident to read stats from. Empty = the running package (Game.Ident).\r\n\t[Property] public string PackageIdent {{ get; set; }} = \"\"{packageIdent}\"\";\r\n\r\n\t/// Push Stats.Flush() after every change (rate-limited by the backend) instead of\r\n\t/// relying on the buffered flush + the OnDestroy flush.\r\n\t[Property] public bool FlushEveryChange {{ get; set; }} = {flushLit};\r\n\r\n\t/// In-session balance -- authoritative while playing. Cloud value catches up on flush.\r\n\tpublic double Balance {{ get; private set; }}\r\n\r\n\t/// True once the async cloud read-back has completed (successfully or not).\r\n\tpublic bool IsLoaded {{ get; private set; }}\r\n\r\n\t/// Fires once after the cloud read-back completes, with the loaded balance.\r\n\tpublic static Action<double> OnBalanceLoaded {{ get; set; }}\r\n\r\n\t/// Fires on every balance change (including the initial load) -- bind a HUD here.\r\n\tpublic Action<double> OnBalanceChanged {{ get; set; }}\r\n\r\n\tprotected override void OnStart()\r\n\t{{\r\n\t\tif ( IsProxy ) return; // only the local player's machine touches their stats\r\n\t\t_ = LoadAsync();\r\n\t}}\r\n\r\n\tprotected override void OnDestroy()\r\n\t{{\r\n\t\tif ( !IsProxy && IsLoaded ) Stats.Flush();\r\n\t}}\r\n\r\n\t/// <summary>Re-read the balance from the stats backend (async; also runs on start).</summary>\r\n\tpublic async System.Threading.Tasks.Task LoadAsync()\r\n\t{{\r\n\t\tdouble loaded = 0.0;\r\n\t\ttry\r\n\t\t{{\r\n\t\t\tstring ident = string.IsNullOrWhiteSpace( PackageIdent ) ? Game.Ident : PackageIdent;\r\n\t\t\tvar stats = Stats.GetLocalPlayerStats( ident );\r\n\t\t\tawait stats.Refresh();\r\n\t\t\tloaded = stats.Get( StatName ).Value;\r\n\t\t}}\r\n\t\tcatch ( Exception ex )\r\n\t\t{{\r\n\t\t\tLog.Warning( $\"\"[{className}] Stat read-back failed ({{ex.Message}}) -- starting at 0. Stats need a valid package ident + Steam session.\"\" );\r\n\t\t}}\r\n\t\tBalance = loaded;\r\n\t\tIsLoaded = true;\r\n\t\tOnBalanceLoaded?.Invoke( Balance );\r\n\t\tOnBalanceChanged?.Invoke( Balance );\r\n\t}}\r\n\r\n\tpublic bool CanAfford( double amount ) => Balance >= amount;\r\n\r\n\t/// <summary>Add currency and push the new balance to the stats backend. Non-positive ignored.</summary>\r\n\tpublic void Add( double amount )\r\n\t{{\r\n\t\tif ( IsProxy || amount <= 0.0 ) return;\r\n\t\tBalance += amount;\r\n\t\tPush();\r\n\t}}\r\n\r\n\t/// <summary>Spend if affordable; returns false and changes nothing if not.</summary>\r\n\tpublic bool TrySpend( double amount )\r\n\t{{\r\n\t\tif ( IsProxy || amount <= 0.0 ) return false;\r\n\t\tif ( Balance < amount ) return false;\r\n\t\tBalance -= amount;\r\n\t\tPush();\r\n\t\treturn true;\r\n\t}}\r\n\r\n\t/// <summary>Force-push buffered stat writes to the backend now (rate-limited upstream).</summary>\r\n\tpublic void Flush() => Stats.Flush();\r\n\r\n\t// Write the absolute balance to the stat and notify listeners.\r\n\tprivate void Push()\r\n\t{{\r\n\t\tStats.SetValue( StatName, Balance, null, null );\r\n\t\tif ( FlushEveryChange ) Stats.Flush();\r\n\t\tOnBalanceChanged?.Invoke( Balance );\r\n\t}}\r\n}}\r\n\";\r\n\t}\r\n}\r\n\r\n// -----------------------------------------------------------------------------\r\n// create_loot_table_resource -- the data-asset sibling of create_weighted_loot_table.\r\n// Generates ONE .cs containing: an entry POCO (name, weight, optional nested table\r\n// reference), a GameResource loot-table asset type ([AssetType] -- the modern\r\n// attribute; GameResourceAttribute is [Obsolete] on this SDK), and a resolver\r\n// Component that rolls a table by cumulative weight with a resolve depth cap.\r\n// Designers author .loot files in the asset browser; code rolls them.\r\n// -----------------------------------------------------------------------------\r\npublic class CreateLootTableResourceHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tif ( !ScaffoldHelpers.PrepareCodeFile( p, \"LootTableResource\", out var fullPath, out var relPath, out var className, out var err ) )\r\n\t\t\t\treturn Task.FromResult<object>( err );\r\n\r\n\t\t\tstring extension = p.TryGetProperty( \"extension\", out var ev ) && !string.IsNullOrWhiteSpace( ev.GetString() ) ? ev.GetString() : \"loot\";\r\n\t\t\tstring title = p.TryGetProperty( \"title\", out var tv ) && !string.IsNullOrWhiteSpace( tv.GetString() ) ? tv.GetString() : \"Loot Table\";\r\n\t\t\tint maxDepth = p.TryGetProperty( \"maxDepth\", out var mv ) && mv.TryGetInt32( out var mi ) ? mi : 4;\r\n\t\t\tif ( maxDepth < 0 ) maxDepth = 0;\r\n\t\t\tif ( maxDepth > 16 ) maxDepth = 16;\r\n\r\n\t\t\t// Extension: lowercase alphanumerics only.\r\n\t\t\tvar extChars = new StringBuilder();\r\n\t\t\tforeach ( var c in extension.ToLowerInvariant() )\r\n\t\t\t\tif ( ( c >= 'a' && c <= 'z' ) || ( c >= '0' && c <= '9' ) ) extChars.Append( c );\r\n\t\t\textension = extChars.Length > 0 ? extChars.ToString() : \"loot\";\r\n\r\n\t\t\t// Title is baked into an attribute string literal.\r\n\t\t\ttitle = title.Replace( \"\\\\\", \"\" ).Replace( \"\\\"\", \"\" );\r\n\r\n\t\t\tvar resolverClass = className + \"Resolver\";\r\n\t\t\tvar code = BuildCode( className, resolverClass, extension, title, maxDepth );\r\n\t\t\tScaffoldHelpers.WriteCode( fullPath, code );\r\n\r\n\t\t\t// Placement attaches the RESOLVER component (the resource itself is an asset type, not a component).\r\n\t\t\tobject placedOn = null; string note = null;\r\n\t\t\tif ( p.TryGetProperty( \"targetId\", out var tid ) && tid.ValueKind == JsonValueKind.String )\r\n\t\t\t\tplacedOn = EconomySaveHelpers.PlaceOnTarget( tid.GetString(), resolverClass, out note );\r\n\r\n\t\t\treturn Task.FromResult<object>( new\r\n\t\t\t{\r\n\t\t\t\tcreated = true,\r\n\t\t\t\tpath = relPath,\r\n\t\t\t\tclassName,\r\n\t\t\t\tresolverClass,\r\n\t\t\t\textension,\r\n\t\t\t\tmaxDepth,\r\n\t\t\t\tplacedOn,\r\n\t\t\t\tnote,\r\n\t\t\t\tnextSteps = new[]\r\n\t\t\t\t{\r\n\t\t\t\t\t$\"trigger_hotload to compile {className} + {resolverClass} into the game assembly -- the '.{extension}' asset type registers on compile.\",\r\n\t\t\t\t\t$\"Author tables as ASSETS: in the editor asset browser, New > {title} creates a .{extension} file; fill Entries (Name, Weight, optional NestedTable reference to another .{extension}) in the inspector.\",\r\n\t\t\t\t\tplacedOn != null\r\n\t\t\t\t\t\t? $\"{resolverClass} was attached to the target GameObject -- assign its Table property to a .{extension} asset (set_property with the asset path).\"\r\n\t\t\t\t\t\t: $\"Attach the resolver: add_component_to_new_object (component=\\\"{resolverClass}\\\") after the hotload, then set its Table property to a .{extension} asset path.\",\r\n\t\t\t\t\t$\"Roll from game code (host-side): string drop = GetComponent<{resolverClass}>().Roll(); {resolverClass}.OnLoot += ( go, item ) => {{ }};\",\r\n\t\t\t\t\t$\"Nested tables: an entry with a NestedTable rolls INTO that table instead of dropping its Name -- capped at MaxDepth ({maxDepth}) with a self-reference guard, so cycles terminate.\",\r\n\t\t\t\t\t\"Use create_weighted_loot_table instead when you want a single inline component with no asset files; use create_gacha_drop_table for pity + duplicate mechanics. Pick an extension that is NOT a suffix of a built-in one (e.g. avoid 'cfg') or ResourceLibrary.GetAll will pick up engine files as phantom instances.\"\r\n\t\t\t\t}\r\n\t\t\t} );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"create_loot_table_resource failed: {ex.Message}\" } );\r\n\t\t}\r\n\t}\r\n\r\n\tstatic string BuildCode( string className, string resolverClass, string extension, string title, int maxDepth )\r\n\t{\r\n\t\tvar ci = System.Globalization.CultureInfo.InvariantCulture;\r\n\t\tstring md = maxDepth.ToString( ci );\r\n\r\n\t\treturn $@\"using Sandbox;\r\nusing System;\r\nusing System.Collections.Generic;\r\n\r\n/// <summary>\r\n/// One row of a {className} asset. Amount is picked by cumulative weight; when\r\n/// NestedTable is set the roll continues INTO that table instead of dropping Name.\r\n/// </summary>\r\npublic sealed class {className}Entry\r\n{{\r\n\t/// What drops when this entry wins (ignored when NestedTable is set).\r\n\t[Property] public string Name {{ get; set; }} = \"\"\"\";\r\n\r\n\t/// Relative chance. Bigger = more likely. Entries with weight <= 0 never win.\r\n\t[Property] public float Weight {{ get; set; }} = 1f;\r\n\r\n\t/// Optional: roll this table instead of dropping Name (depth-capped on resolve).\r\n\t[Property] public {className} NestedTable {{ get; set; }}\r\n}}\r\n\r\n/// <summary>\r\n/// {className} -- a designer-authored loot table ASSET (.{extension} files).\r\n///\r\n/// Each .{extension} file holds weighted entries; entries may reference other\r\n/// .{extension} assets as nested tables (rarity tiers, per-biome sub-tables).\r\n/// Resolve() rolls by cumulative weight and follows nested references up to a\r\n/// depth cap, so cyclic references terminate. Author the files in the editor\r\n/// asset browser; roll them with {resolverClass} or call Resolve() directly.\r\n/// </summary>\r\n[AssetType( Name = \"\"{title}\"\", Extension = \"\"{extension}\"\", Category = \"\"Game\"\" )]\r\npublic sealed class {className} : GameResource\r\n{{\r\n\t/// The weighted rows of this table.\r\n\t[Property] public List<{className}Entry> Entries {{ get; set; }} = new List<{className}Entry>();\r\n\r\n\t/// <summary>\r\n\t/// Roll once: pick an entry by cumulative weight; if it references a nested table,\r\n\t/// keep rolling into it until a plain entry wins or maxDepth is exhausted (then the\r\n\t/// deepest entry's Name is returned). Null when the table is empty. HOST-authoritative:\r\n\t/// roll on the host and replicate the result -- clients rolling their own loot is the\r\n\t/// classic economy exploit.\r\n\t/// </summary>\r\n\tpublic string Resolve( int maxDepth = {md} )\r\n\t{{\r\n\t\tvar entry = RollEntry();\r\n\t\tif ( entry == null ) return null;\r\n\t\tif ( entry.NestedTable != null && entry.NestedTable != this && maxDepth > 0 )\r\n\t\t\treturn entry.NestedTable.Resolve( maxDepth - 1 );\r\n\t\treturn entry.Name;\r\n\t}}\r\n\r\n\t// Cumulative-weight pick over Entries. Null when empty; first entry when all weights are zero.\r\n\tprivate {className}Entry RollEntry()\r\n\t{{\r\n\t\tif ( Entries == null || Entries.Count == 0 ) return null;\r\n\r\n\t\tfloat total = 0f;\r\n\t\tforeach ( var e in Entries )\r\n\t\t\tif ( e != null && e.Weight > 0f ) total += e.Weight;\r\n\t\tif ( total <= 0f ) return Entries[0];\r\n\r\n\t\tfloat roll = Game.Random.Float( 0f, total );\r\n\t\tfloat cumulative = 0f;\r\n\t\t{className}Entry winner = null;\r\n\t\tforeach ( var e in Entries )\r\n\t\t{{\r\n\t\t\tif ( e == null || e.Weight <= 0f ) continue;\r\n\t\t\twinner = e;\r\n\t\t\tcumulative += e.Weight;\r\n\t\t\tif ( roll < cumulative ) break;\r\n\t\t}}\r\n\t\treturn winner;\r\n\t}}\r\n}}\r\n\r\n/// <summary>\r\n/// {resolverClass} -- rolls a {className} asset from the scene.\r\n///\r\n/// Assign Table to a .{extension} asset in the inspector (or via set_property with the\r\n/// asset path). Roll() resolves through nested tables up to MaxDepth and fires the\r\n/// static OnLoot event with the winning item name. Call it host-side and replicate\r\n/// the result yourself ([Sync] or an [Rpc.Broadcast]).\r\n///\r\n/// Usage:\r\n/// string drop = GetComponent<{resolverClass}>().Roll();\r\n/// {resolverClass}.OnLoot += ( go, item ) => Log.Info( $\"\"{{go.Name}} got {{item}}\"\" );\r\n/// </summary>\r\npublic sealed class {resolverClass} : Component\r\n{{\r\n\t/// The loot table asset this resolver rolls.\r\n\t[Property] public {className} Table {{ get; set; }}\r\n\r\n\t/// How deep nested-table references may chain before the roll settles.\r\n\t[Property] public int MaxDepth {{ get; set; }} = {md};\r\n\r\n\t/// Fires (on the rolling machine) when Roll() picks a winner: (roller, itemName).\r\n\tpublic static Action<GameObject, string> OnLoot {{ get; set; }}\r\n\r\n\t/// <summary>\r\n\t/// Roll the assigned table once. Null (with a warning) when no Table is assigned or\r\n\t/// the table is empty. HOST-authoritative by convention -- see the class summary.\r\n\t/// </summary>\r\n\tpublic string Roll()\r\n\t{{\r\n\t\tif ( Table == null )\r\n\t\t{{\r\n\t\t\tLog.Warning( $\"\"[{resolverClass}] No Table assigned on {{GameObject.Name}} -- assign a .{extension} asset.\"\" );\r\n\t\t\treturn null;\r\n\t\t}}\r\n\r\n\t\tvar drop = Table.Resolve( MaxDepth );\r\n\t\tif ( drop != null ) OnLoot?.Invoke( GameObject, drop );\r\n\t\treturn drop;\r\n\t}}\r\n}}\r\n\";\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// Shared placement helper for the economy/save handlers -- mirrors the standard scaffold\r\n/// placement (create_economy_wallet / create_weighted_loot_table / LootEconomyHelpers).\r\n/// </summary>\r\ninternal static class EconomySaveHelpers\r\n{\r\n\tpublic static object PlaceOnTarget( string targetId, string className, out string note )\r\n\t{\r\n\t\tnote = null;\r\n\t\tvar scene = SceneEditorSession.Active?.Scene;\r\n\t\tif ( scene == null ) { note = \"No active scene to place into.\"; return null; }\r\n\t\tif ( !Guid.TryParse( targetId, out var guid ) ) { note = \"Invalid targetId GUID.\"; return null; }\r\n\t\tvar go = scene.Directory.FindByGuid( guid );\r\n\t\tif ( go == null ) { note = $\"Target GameObject not found: {targetId}\"; return null; }\r\n\t\tvar typeDesc = Game.TypeLibrary.GetType( className );\r\n\t\tif ( typeDesc == null )\r\n\t\t{\r\n\t\t\tnote = $\"Generated {className}.cs but it is not in the TypeLibrary yet -- trigger_hotload, then add it with add_component_with_properties.\";\r\n\t\t\treturn null;\r\n\t\t}\r\n\t\ttry { go.Components.Create( typeDesc ); return ClaudeBridge.SerializeGo( go ); }\r\n\t\tcatch ( Exception ex ) { note = $\"Placement failed ({ex.Message}).\"; return null; }\r\n\t}\r\n}\r\n"
},
{
"Ident": "sboxskinsgg.claudebridge",
"Path": "Editor/Mcp/BridgeProjectTools.cs",
"FileName": "BridgeProjectTools.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 335526,
"Code": "// AUTO-GENERATED by scripts/emit-mcp-wrappers.mjs \u2014 DO NOT EDIT.\r\n// Regenerate: node scripts/extract-manifest.mjs && node scripts/emit-mcp-wrappers.mjs\r\n// Source of truth: sbox-mcp-server/src/tools/ (zod schemas) \u2192 scripts/tools-manifest.json\r\n\r\nusing System.Text.Json.Nodes;\r\nusing System.Threading.Tasks;\r\nusing Editor.Mcp;\r\n\r\n/// <summary>\r\n/// Project info and config (.sbproj), file read/write, C# script create/edit/delete, hotload, input\r\n/// actions, and publishing metadata.\r\n/// </summary>\r\n[McpToolset( \"bridge_project\", \"Project info and config (.sbproj), file read/write, C# script create/edit/delete, hotload, input actions, and publishing metadata.\" )]\r\npublic static class BridgeProjectTools\r\n{\r\n\t/// <summary>\r\n\t/// Create a new C# component script in the project \u2014 a minimal s&box Component class (name is\r\n\t/// sanitized to a valid identifier), or your exact code when content is provided. Errors if the\r\n\t/// file already exists. Returns { path, created, className } \u2014 the new type is NOT live until a\r\n\t/// recompile, so call trigger_hotload, then attach it with add_component_with_properties\r\n\t/// (component=className).\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name for the component (e.g. 'PlayerController'). Will also be the filename.</param>\r\n\t/// <param name=\"directory\">Subdirectory under code/ to place the script (e.g. 'Components'). Defaults to 'code/'.</param>\r\n\t/// <param name=\"description\">Description of what this component does \u2014 used to generate appropriate code.</param>\r\n\t/// <param name=\"properties\">List of [Property] fields to include in the component. JSON array.</param>\r\n\t/// <param name=\"content\">Full C# file content. If provided, ignores name/properties and writes this directly.</param>\r\n\t[McpTool( \"create_script\" )]\r\n\tpublic static Task<object> CreateScript( string name, string directory = null, string description = null, JsonNode properties = null, string content = null )\r\n\t\t=> McpGate.Run( \"create_script\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"description\", description ), ( \"properties\", properties ), ( \"content\", content ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Permanently delete a file from the project by its project-relative path (built for C# scripts,\r\n\t/// but removes any file; no recycle bin, and editor undo cannot restore it). Errors if the file\r\n\t/// doesn't exist. Returns a confirmation with the path \u2014 follow with trigger_hotload so the removed\r\n\t/// class actually leaves the compiled assembly.\r\n\t/// </summary>\r\n\t/// <param name=\"path\">Relative path to the script file to delete.</param>\r\n\t[McpTool( \"delete_script\" )]\r\n\tpublic static Task<object> DeleteScript( string path )\r\n\t\t=> McpGate.Run( \"delete_script\", McpGate.Args( ( \"path\", path ) ) );\r\n\r\n\t/// <summary>\r\n\t/// One-call project orientation: identity (name/ident/org/type), the open scene with object count,\r\n\t/// scene and prefab file lists (capped at 50 each, Libraries/.sbox excluded), code footprint\r\n\t/// (.cs/.razor counts), custom Component types (up to 100, engine types excluded), and installed\r\n\t/// libraries. Returns a structured summary \u2014 orient here first, then get_scene_hierarchy for the\r\n\t/// scene, describe_type for components, find_broken_references for project health. Read-only.\r\n\t/// </summary>\r\n\t[McpTool.ReadOnly( \"describe_project\" )]\r\n\tpublic static Task<object> DescribeProject()\r\n\t\t=> McpGate.Run( \"describe_project\", McpGate.Args() );\r\n\r\n\t/// <summary>\r\n\t/// Edit an existing C# script in place via exact-text find/replace or a full-content overwrite.\r\n\t/// Errors if the file or the find text isn't found (find/replace replaces ALL occurrences). Returns\r\n\t/// { path, edited, operation } where operation is 'find_replace' or 'overwrite' \u2014 follow with\r\n\t/// trigger_hotload so the change compiles, then get_compile_errors if in doubt.\r\n\t/// </summary>\r\n\t/// <param name=\"path\">Relative path to the script file (e.g. 'code/PlayerController.cs').</param>\r\n\t/// <param name=\"operations\">List of edit operations to apply in order. JSON array.</param>\r\n\t[McpTool( \"edit_script\" )]\r\n\tpublic static Task<object> EditScript( string path, JsonNode operations )\r\n\t\t=> McpGate.Run( \"edit_script\", McpGate.Args( ( \"path\", path ), ( \"operations\", operations ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Register a custom named INPUT ACTION in the project so a generated game's custom verbs work in\r\n\t/// play mode. Writes to <project>.sbproj \u2192 Metadata.InputSettings.Actions[]. Idempotent: if\r\n\t/// the action already exists it is left alone (pass update=true to rebind its key). If the project\r\n\t/// has no InputSettings yet, the full DEFAULT action set\r\n\t/// (Forward/Back/Left/Right/Jump/Use/attack1/...) is seeded first so player movement/use are\r\n\t/// preserved \u2014 the engine only auto-injects defaults when a game defines NONE. After adding, call\r\n\t/// it from game code with Input.Pressed(\"name\") / Input.Down(\"name\") / Input.Released(\"name\").\r\n\t/// Note: input config is read at project load, so restart_editor (or reload the project) for a new\r\n\t/// action to take effect in play mode.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">The action verb game code will call, e.g. \"interact\", \"sprint\", \"drop\". Matches Input.Pressed(\"interact\").</param>\r\n\t/// <param name=\"keyboardKey\">Default keyboard binding, e.g. \"e\", \"f\", \"space\", \"mouse1\", \"shift\". Omit to add the action with no default key (player can bind it).</param>\r\n\t/// <param name=\"group\">UI group the action is listed under in the bindings menu (e.g. \"Actions\", \"Movement\", \"Other\"). Defaults to \"Actions\".</param>\r\n\t/// <param name=\"update\">If the action already exists, rebind its keyboardKey to the provided value instead of leaving it untouched. Default false (idempotent no-op when present).</param>\r\n\t[McpTool( \"ensure_input_action\" )]\r\n\tpublic static Task<object> EnsureInputAction( string name, string keyboardKey = null, string group = null, bool? update = null )\r\n\t\t=> McpGate.Run( \"ensure_input_action\", McpGate.Args( ( \"name\", name ), ( \"keyboardKey\", keyboardKey ), ( \"group\", group ), ( \"update\", update ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Fetch package information from the s&box package backend (Package.FetchAsync) by ident.\r\n\t/// Returns { fullIdent, title, summary, description, org } \u2014 no download/rating/dependency data is\r\n\t/// included. Use it to confirm a package exists and what it is before install_asset.\r\n\t/// </summary>\r\n\t/// <param name=\"ident\">Package identifier (e.g. 'facepunch.flatgrass', 'myorg.mygame').</param>\r\n\t[McpTool.ReadOnly( \"get_package_details\" )]\r\n\tpublic static Task<object> GetPackageDetails( string ident )\r\n\t\t=> McpGate.Run( \"get_package_details\", McpGate.Args( ( \"ident\", ident ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Read the full project configuration from the .sbproj file including title, description, version,\r\n\t/// type, package references, metadata, and raw JSON.\r\n\t/// </summary>\r\n\t[McpTool.ReadOnly( \"get_project_config\" )]\r\n\tpublic static Task<object> GetProjectConfig()\r\n\t\t=> McpGate.Run( \"get_project_config\", McpGate.Args() );\r\n\r\n\t/// <summary>\r\n\t/// Get information about the current s&box project \u2014 path, name, game type, dependencies, and\r\n\t/// configuration.\r\n\t/// </summary>\r\n\t[McpTool.ReadOnly( \"get_project_info\" )]\r\n\tpublic static Task<object> GetProjectInfo()\r\n\t\t=> McpGate.Run( \"get_project_info\", McpGate.Args() );\r\n\r\n\t/// <summary>\r\n\t/// Browse the project file tree. Optionally filter by directory path and/or file extension (e.g.\r\n\t/// '.cs', '.scene'). Returns { path, count, files } as project-root-relative paths \u2014 CAPPED AT 500\r\n\t/// files (count reflects the truncated list, with no marker that more exist), so on large projects\r\n\t/// narrow with path/extension or use find_in_project. Recursive by default.\r\n\t/// </summary>\r\n\t/// <param name=\"path\">Relative directory path to list (e.g. 'code/Components'). Defaults to project root.</param>\r\n\t/// <param name=\"extension\">Filter by file extension, including the dot (e.g. '.cs', '.scene').</param>\r\n\t/// <param name=\"recursive\">Whether to list files recursively. Defaults to true.</param>\r\n\t[McpTool.ReadOnly( \"list_project_files\" )]\r\n\tpublic static Task<object> ListProjectFiles( string path = null, string extension = null, bool? recursive = null )\r\n\t\t=> McpGate.Run( \"list_project_files\", McpGate.Args( ( \"path\", path ), ( \"extension\", extension ), ( \"recursive\", recursive ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Read the contents of a file in the s&box project (scripts, scenes, configs, etc.).\r\n\t/// </summary>\r\n\t/// <param name=\"path\">Relative path to the file within the project (e.g. 'code/PlayerController.cs').</param>\r\n\t[McpTool.ReadOnly( \"read_file\" )]\r\n\tpublic static Task<object> ReadFile( string path )\r\n\t\t=> McpGate.Run( \"read_file\", McpGate.Args( ( \"path\", path ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Update project configuration fields for publishing: title, description, version, type, package\r\n\t/// ident, summary, visibility. Only provided fields are changed \u2014 edits string values in the\r\n\t/// .sbproj file in place. Returns { updated, path } (the .sbproj path); read the result back with\r\n\t/// get_project_config to confirm what actually changed.\r\n\t/// </summary>\r\n\t/// <param name=\"title\">Project display title.</param>\r\n\t/// <param name=\"description\">Project description for publishing.</param>\r\n\t/// <param name=\"version\">Version string (e.g. '1.0.0', '2.1.3').</param>\r\n\t/// <param name=\"type\">Project type: 'game', 'addon', 'library', or 'template'.</param>\r\n\t/// <param name=\"packageIdent\">Package identifier (e.g. 'myorg.mygame').</param>\r\n\t/// <param name=\"summary\">Short summary for asset.party listing.</param>\r\n\t/// <param name=\"isPublic\">Whether the project is publicly visible.</param>\r\n\t[McpTool( \"set_project_config\" )]\r\n\tpublic static Task<object> SetProjectConfig( string title = null, string description = null, string version = null, string type = null, string packageIdent = null, string summary = null, bool? isPublic = null )\r\n\t\t=> McpGate.Run( \"set_project_config\", McpGate.Args( ( \"title\", title ), ( \"description\", description ), ( \"version\", version ), ( \"type\", type ), ( \"packageIdent\", packageIdent ), ( \"summary\", summary ), ( \"isPublic\", isPublic ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Set or update the project thumbnail image (thumb.png) used for publishing. Provide either a\r\n\t/// source path or base64 image data.\r\n\t/// </summary>\r\n\t/// <param name=\"sourcePath\">Relative path to an image file within the project to use as thumbnail.</param>\r\n\t/// <param name=\"base64\">Base64-encoded image data to write as thumbnail.</param>\r\n\t/// <param name=\"format\">Image format when using base64 mode. Defaults to 'png'. One of: png | jpg.</param>\r\n\t[McpTool( \"set_project_thumbnail\" )]\r\n\tpublic static Task<object> SetProjectThumbnail( string sourcePath = null, string base64 = null, string format = null )\r\n\t\t=> McpGate.Run( \"set_project_thumbnail\", McpGate.Args( ( \"sourcePath\", sourcePath ), ( \"base64\", base64 ), ( \"format\", format ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Force s&box to recompile and hotload all C# scripts immediately. Use after creating or\r\n\t/// editing scripts to see changes in real-time.\r\n\t/// </summary>\r\n\t[McpTool( \"trigger_hotload\" )]\r\n\tpublic static Task<object> TriggerHotload()\r\n\t\t=> McpGate.Run( \"trigger_hotload\", McpGate.Args() );\r\n\r\n\t/// <summary>\r\n\t/// Write or overwrite a file in the s&box project (SILENTLY replaces existing content \u2014\r\n\t/// read_file first if you need to preserve it). Creates parent directories as needed; paths are\r\n\t/// confined to the project root (traversal outside it is denied). Returns a confirmation with the\r\n\t/// path \u2014 for C# follow with trigger_hotload so it compiles; for assets (.vmat etc.) follow with\r\n\t/// recompile_asset.\r\n\t/// </summary>\r\n\t/// <param name=\"path\">Relative path for the file (e.g. 'code/Components/Health.cs').</param>\r\n\t/// <param name=\"content\">The full file content to write.</param>\r\n\t[McpTool( \"write_file\" )]\r\n\tpublic static Task<object> WriteFile( string path, string content )\r\n\t\t=> McpGate.Run( \"write_file\", McpGate.Args( ( \"path\", path ), ( \"content\", content ) ) );\r\n}\r\n"
},
{
"Ident": "sboxskinsgg.claudebridge",
"Path": "Editor/Mcp/BridgeValidationTools.cs",
"FileName": "BridgeValidationTools.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 335526,
"Code": "// AUTO-GENERATED by scripts/emit-mcp-wrappers.mjs \u2014 DO NOT EDIT.\r\n// Regenerate: node scripts/extract-manifest.mjs && node scripts/emit-mcp-wrappers.mjs\r\n// Source of truth: sbox-mcp-server/src/tools/ (zod schemas) \u2192 scripts/tools-manifest.json\r\n\r\nusing System.Text.Json.Nodes;\r\nusing System.Threading.Tasks;\r\nusing Editor.Mcp;\r\n\r\n/// <summary>\r\n/// Lint and validate the project: networking footguns, sandbox whitelist violations, Razor\r\n/// transpiler footguns, scene setup issues, publishing readiness, save-file inspection, and\r\n/// networked-object state dumps.\r\n/// </summary>\r\n[McpToolset( \"bridge_validation\", \"Lint and validate the project: networking footguns, sandbox whitelist violations, Razor transpiler footguns, scene setup issues, publishing readiness, save-file inspection, and networked-object state dumps.\" )]\r\npublic static class BridgeValidationTools\r\n{\r\n\t/// <summary>\r\n\t/// Scan the project for broken references, two layers in one call: (1) every GameObject in the open\r\n\t/// scene \u2014 renderers with no Model (missing_model), component properties pointing at DESTROYED\r\n\t/// GameObjects/Components (dead_gameobject_ref / dead_component_ref), null component entries whose\r\n\t/// type no longer exists (missing_component); (2) every .scene/.prefab FILE \u2014 prefab references to\r\n\t/// deleted/renamed files (missing_prefab_file). Returns { total, showing, truncated,\r\n\t/// objectsScanned, filesScanned, issues } \u2014 each issue has { id, name, component, kind, detail }\r\n\t/// (file-level issues carry the file path in name). Fix missing models with assign_model, dead refs\r\n\t/// with set_property/set_component_reference, missing prefab files by fixing the path or recreating\r\n\t/// via create_prefab. Read-only; safe any time. Results cap at `limit` (default 100, max 500).\r\n\t/// </summary>\r\n\t/// <param name=\"limit\">Max issues to return (default 100, max 500). total still counts everything.</param>\r\n\t/// <param name=\"scanFiles\">Include the .scene/.prefab file scan for missing prefab references. Default true.</param>\r\n\t[McpTool.ReadOnly( \"find_broken_references\" )]\r\n\tpublic static Task<object> FindBrokenReferences( int? limit = null, bool? scanFiles = null )\r\n\t\t=> McpGate.Run( \"find_broken_references\", McpGate.Args( ( \"limit\", limit ), ( \"scanFiles\", scanFiles ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Inspect the live networking contract of a GameObject. Returns {id, name, network: {active,\r\n\t/// isProxy, isOwner, isCreator, ownerId, ownerSteamId, ownerTransfer, orphaned, flags}, components:\r\n\t/// [{component, fields: [{name, type, isSync, syncFlags, value}]}]} \u2014 by default only [Sync]-marked\r\n\t/// fields are listed (components with none are omitted). Unlike get_network_status (session-only),\r\n\t/// this is per-object \u2014 the way to verify a host-authoritative or ownership change actually\r\n\t/// replicated; works in edit or play mode. Follow up with set_ownership to change the owner, or\r\n\t/// networking_lint to find the code-level cause of a bad [Sync] value.\r\n\t/// </summary>\r\n\t/// <param name=\"id\">GUID of the GameObject to inspect.</param>\r\n\t/// <param name=\"allProps\">Include all component properties, not just [Sync]-marked ones.</param>\r\n\t[McpTool.ReadOnly( \"inspect_networked_object\" )]\r\n\tpublic static Task<object> InspectNetworkedObject( string id, bool allProps = false )\r\n\t\t=> McpGate.Run( \"inspect_networked_object\", McpGate.Args( ( \"id\", id ), ( \"allProps\", allProps ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Static-scan the project's C# for the highest-frequency networking/authority bugs: a mutator that\r\n\t/// writes a [Sync] field with no IsProxy/Networking.IsHost guard; money/health/score-shaped fields\r\n\t/// marked plain [Sync] (should be SyncFlags.FromHost); List<>/Dictionary<> marked\r\n\t/// [Sync] (should be NetList/NetDictionary); [Sync] fields typed Connection/GameObject (sync a Guid\r\n\t/// instead); [Rpc.Host] methods that mutate without re-checking Rpc.Caller; and component swaps /\r\n\t/// reflection writes missing Network.Refresh(). Returns findings with file:line + the suggested\r\n\t/// fix.\r\n\t/// </summary>\r\n\t/// <param name=\"path\">Optional sub-path under the project (e.g. 'Code/Player') to scope the scan; omit for the whole project.</param>\r\n\t[McpTool.ReadOnly( \"networking_lint\" )]\r\n\tpublic static Task<object> NetworkingLint( string path = null )\r\n\t\t=> McpGate.Run( \"networking_lint\", McpGate.Args( ( \"path\", path ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Static-scan .razor and .razor.scss files for the silent footguns that crash the Razor transpiler\r\n\t/// or stylesheet engine with no useful error message: switch expressions inside @code blocks (use\r\n\t/// if/else instead), non-ASCII/emoji inside @code (move to markup or a string constant),\r\n\t/// PanelComponent subclasses missing a BuildHash override (panel never re-renders), and root\r\n\t/// uppercase type-selector rules in .razor.scss (silently skipped -- use a class selector like\r\n\t/// .my-panel). Returns { scanned, findings: [{file, line, match, advice}], clean } matching the\r\n\t/// sandbox_lint shape.\r\n\t/// </summary>\r\n\t/// <param name=\"directory\">Subdirectory under the project root to scan (e.g. 'UI', 'Code'). Defaults to 'Code'.</param>\r\n\t[McpTool.ReadOnly( \"razor_lint\" )]\r\n\tpublic static Task<object> RazorLint( string directory = null )\r\n\t\t=> McpGate.Run( \"razor_lint\", McpGate.Args( ( \"directory\", directory ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Static-scan the project's C# for s&box sandbox whitelist violations BEFORE they cause\r\n\t/// compile errors: System.MathF (use MathX), System.Math (use MathX), Array.Clone() (use\r\n\t/// .ToArray()), System.Net / raw sockets (use Sandbox.Http), System.IO.File (use FileSystem.Data),\r\n\t/// and raw System.Threading.Thread (use async/Task or GameTask). Returns { scanned, findings:\r\n\t/// [{file, line, match, advice}], clean }. Scope to a subdirectory with the directory param.\r\n\t/// </summary>\r\n\t/// <param name=\"directory\">Subdirectory under the project root to scan (e.g. 'Code', 'Code/Player'). Defaults to 'Code'.</param>\r\n\t[McpTool.ReadOnly( \"sandbox_lint\" )]\r\n\tpublic static Task<object> SandboxLint( string directory = null )\r\n\t\t=> McpGate.Run( \"sandbox_lint\", McpGate.Args( ( \"directory\", directory ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Inspect the game's FileSystem.Data save files \u2014 the assistant is otherwise blind to persisted\r\n\t/// state. action='list' (default) returns `directories` and `files` [{name, path, size}] under\r\n\t/// `path` (omit path for the Data root); action='read' returns {path, length, content}, truncating\r\n\t/// content at 60,000 chars; action='diff' compares two save files key-by-key, returning `diffCount`\r\n\t/// and up to 200 `diffs` [{key, change: added|removed|changed}]. Use to verify a save actually\r\n\t/// wrote, debug a load/migration, or confirm a sanitize/clamp ran.\r\n\t/// </summary>\r\n\t/// <param name=\"action\">'list' (default) enumerates a folder; 'read' dumps one file's JSON; 'diff' compares `path` vs `pathB`. One of: list | read | diff. Default: \"list\".</param>\r\n\t/// <param name=\"path\">File or folder path under FileSystem.Data (e.g. 'lumber_corp2_progress' or '<folder>/steam_123.json').</param>\r\n\t/// <param name=\"pathB\">Second file path for action='diff'.</param>\r\n\t[McpTool.ReadOnly( \"save_inspect\" )]\r\n\tpublic static Task<object> SaveInspect( string action = \"list\", string path = null, string pathB = null )\r\n\t\t=> McpGate.Run( \"save_inspect\", McpGate.Args( ( \"action\", action ), ( \"path\", path ), ( \"pathB\", pathB ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Validate the active scene for the silent setup footguns that break controllers/physics/cameras:\r\n\t/// no CameraComponent, no player controller, multiple root Rigidbodies, a Rigidbody with\r\n\t/// MotionEnabled=false fighting a kinematic root, IsTrigger colliders that Scene.Trace will ignore,\r\n\t/// child Rigidbodies breaking collider binding, and missing required child anchors. Returns each\r\n\t/// issue with the GameObject and the exact fix.\r\n\t/// </summary>\r\n\t[McpTool.ReadOnly( \"scene_validate\" )]\r\n\tpublic static Task<object> SceneValidate()\r\n\t\t=> McpGate.Run( \"scene_validate\", McpGate.Args() );\r\n\r\n\t/// <summary>\r\n\t/// Read from Sandbox.Services \u2014 the cloud stats/leaderboard layer many games use as their real DB.\r\n\t/// action='stats' with `name` returns the local player's stat {ident, value, sum, min, max,\r\n\t/// lastValue, valueString}; without `name` it returns only the package ident plus a usage note (it\r\n\t/// does NOT list stat definitions). action='leaderboard' (name required) returns {board,\r\n\t/// displayName, totalEntries, count, entries} with at most `limit` entries (default 10). Read-only;\r\n\t/// use to verify a Stats.Increment/SetValue path or a leaderboard wired correctly.\r\n\t/// </summary>\r\n\t/// <param name=\"action\">'stats' (default) reads a local-player stat by `name`; 'leaderboard' fetches a board's top entries. One of: stats | leaderboard. Default: \"stats\".</param>\r\n\t/// <param name=\"name\">Stat name (action='stats') or leaderboard/board name (action='leaderboard').</param>\r\n\t/// <param name=\"limit\">Max leaderboard entries to return.</param>\r\n\t[McpTool.ReadOnly( \"services_query\" )]\r\n\tpublic static Task<object> ServicesQuery( string action = \"stats\", string name = null, int limit = 10 )\r\n\t\t=> McpGate.Run( \"services_query\", McpGate.Args( ( \"action\", action ), ( \"name\", name ), ( \"limit\", limit ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Validate that the project is ready for publishing. Runs four checks: .sbproj exists, at least\r\n\t/// one scene, project Ident set, project Title set. Returns { valid, issueCount, issues, checks } \u2014\r\n\t/// issues are human-readable problems and each checks entry has { check, pass, detail }; fix\r\n\t/// metadata gaps with set_project_config (it does NOT check compile errors \u2014 use get_compile_errors\r\n\t/// for that).\r\n\t/// </summary>\r\n\t[McpTool.ReadOnly( \"validate_project\" )]\r\n\tpublic static Task<object> ValidateProject()\r\n\t\t=> McpGate.Run( \"validate_project\", McpGate.Args() );\r\n}\r\n"
},
{
"Ident": "sboxskinsgg.claudebridge",
"Path": "Editor/NpcBrainHandlers.cs",
"FileName": "NpcBrainHandlers.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 335526,
"Code": "using Editor;\r\nusing Sandbox;\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.IO;\r\nusing System.Linq;\r\nusing System.Reflection;\r\nusing System.Text;\r\nusing System.Text.Json;\r\nusing System.Threading;\r\nusing System.Threading.Tasks;\r\n\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n// NPC Brains \u2014 Feature Wave #3 (Phase 1 + simulate_npc_perception)\r\n//\r\n// Compiles into the SAME editor assembly as MyEditorMenu.cs, so it can use the\r\n// shared helpers there directly: ClaudeBridge.TryResolveProjectPath /\r\n// SanitizeIdentifier / ParseVector3, SceneToolHelpers.*, and the IBridgeHandler\r\n// interface. These handlers run in the UNSANDBOXED editor (System.Math/MathF/IO\r\n// are all fine here).\r\n//\r\n// The C# *strings these handlers generate* run in the SANDBOX (the game). That\r\n// generated code is deliberately restricted to APIs already proven to compile in\r\n// the sandbox by the existing create_npc_controller / create_networked_player\r\n// generators: Component, [Property], [Sync], GetOrAddComponent<NavMeshAgent>(),\r\n// NavMeshAgent.MoveTo(Vector3), IsProxy, TimeSince, Vector3.Dot/.Normal/\r\n// .DistanceBetween, Scene.GetAllComponents<T>(), scene.Trace.Ray(a,b).Run(),\r\n// MathX.Clamp. MathX preferred in generated code; System.Math/MathF also compile on the current SDK (verified 2026-06-09). Array.Clone() still blocked.\r\n//\r\n// Tools in this file:\r\n// create_npc_brain (code-gen; scene-mutating)\r\n// place_patrol_route (scene-mutating)\r\n// assign_patrol_route (scene-mutating)\r\n// create_npc_spawner (code-gen; scene-mutating)\r\n// simulate_npc_perception (READ-ONLY; not scene-mutating)\r\n//\r\n// Register(...) lines + _sceneMutatingCommands additions are wired by the main\r\n// agent in MyEditorMenu.cs (see this wave's summary) to avoid a merge conflict.\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n\r\n/// <summary>\r\n/// Shared helpers for the NPC-brain generators. Kept internal to this file so it\r\n/// does not collide with anything in MyEditorMenu.cs.\r\n/// </summary>\r\ninternal static class NpcBrainHelpers\r\n{\r\n\t/// <summary>\r\n\t/// Read an optional float param, falling back to <paramref name=\"fallback\"/>.\r\n\t/// Tolerates the value arriving as a JSON number OR a numeric string.\r\n\t/// </summary>\r\n\tpublic static float Float( JsonElement p, string key, float fallback )\r\n\t{\r\n\t\tif ( !p.TryGetProperty( key, out var e ) ) return fallback;\r\n\t\tif ( e.ValueKind == JsonValueKind.Number && e.TryGetSingle( out var f ) ) return f;\r\n\t\tif ( e.ValueKind == JsonValueKind.String && float.TryParse( e.GetString(), out var fs ) ) return fs;\r\n\t\treturn fallback;\r\n\t}\r\n\r\n\tpublic static int Int( JsonElement p, string key, int fallback )\r\n\t{\r\n\t\tif ( !p.TryGetProperty( key, out var e ) ) return fallback;\r\n\t\tif ( e.ValueKind == JsonValueKind.Number && e.TryGetInt32( out var i ) ) return i;\r\n\t\tif ( e.ValueKind == JsonValueKind.String && int.TryParse( e.GetString(), out var iss ) ) return iss;\r\n\t\treturn fallback;\r\n\t}\r\n\r\n\tpublic static bool Bool( JsonElement p, string key, bool fallback )\r\n\t{\r\n\t\tif ( !p.TryGetProperty( key, out var e ) ) return fallback;\r\n\t\tif ( e.ValueKind == JsonValueKind.True ) return true;\r\n\t\tif ( e.ValueKind == JsonValueKind.False ) return false;\r\n\t\tif ( e.ValueKind == JsonValueKind.String && bool.TryParse( e.GetString(), out var b ) ) return b;\r\n\t\treturn fallback;\r\n\t}\r\n\r\n\tpublic static string Str( JsonElement p, string key, string fallback )\r\n\t{\r\n\t\tif ( p.TryGetProperty( key, out var e ) && e.ValueKind == JsonValueKind.String )\r\n\t\t{\r\n\t\t\tvar s = e.GetString();\r\n\t\t\tif ( !string.IsNullOrWhiteSpace( s ) ) return s;\r\n\t\t}\r\n\t\treturn fallback;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Format a float as an invariant-culture C# literal with an 'f' suffix, e.g.\r\n\t/// 130 -> \"130f\", 0.25 -> \"0.25f\". Invariant culture matters so a comma-decimal\r\n\t/// locale on the editor machine cannot emit \"0,25f\" and break compilation.\r\n\t/// </summary>\r\n\tpublic static string F( float v )\r\n\t{\r\n\t\tvar s = v.ToString( \"0.0###\", System.Globalization.CultureInfo.InvariantCulture );\r\n\t\treturn s + \"f\";\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Escape a user string for safe embedding inside a C# double-quoted verbatim\r\n\t/// string ( @\"\" ), where the only escape needed is doubling the quote char.\r\n\t/// TargetTag is also identifier-ish but tags can legitimately contain symbols,\r\n\t/// so we keep it a string literal rather than sanitizing it to an identifier.\r\n\t/// </summary>\r\n\tpublic static string EscVerbatim( string raw ) => ( raw ?? \"\" ).Replace( \"\\\"\", \"\\\"\\\"\" );\r\n\r\n\t/// <summary>\r\n\t/// cos( fovDegrees / 2 ) computed in the EDITOR (MathF is legal here). Baked as\r\n\t/// the default of the generated CosFovThreshold property so the sandbox brain\r\n\t/// never needs trig. Clamped to a sane FOV range first.\r\n\t/// </summary>\r\n\tpublic static float CosHalfFov( float fovDegrees )\r\n\t{\r\n\t\tvar fov = Math.Clamp( fovDegrees, 1f, 360f );\r\n\t\tvar halfRad = ( fov * 0.5f ) * ( MathF.PI / 180f );\r\n\t\treturn MathF.Cos( halfRad );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Resolve the component on <paramref name=\"go\"/> that exposes a property named\r\n\t/// <paramref name=\"property\"/>, and SET that property to <paramref name=\"value\"/>.\r\n\t/// Preferred match is a component literally named \"NpcBrain\"; otherwise the first\r\n\t/// component whose TypeLibrary description has that property. Returns the matched\r\n\t/// component (so the caller can report its name), or null if none matched.\r\n\t///\r\n\t/// We deliberately do the find+set inside one method so this file never has to\r\n\t/// name the reflection types (TypeDescription / PropertyDescription) \u2014 the rest\r\n\t/// of the addon always uses `var` for them, which means their namespace is not\r\n\t/// guaranteed to be importable here. Keeping it all behind `var` mirrors the\r\n\t/// proven SetPrefabRefHandler pattern exactly.\r\n\t/// </summary>\r\n\tpublic static Component SetComponentProperty( GameObject go, string property, object value )\r\n\t{\r\n\t\tComponent fallbackComp = null;\r\n\r\n\t\t// Pass 1: prefer an NpcBrain. Pass 2: any component exposing the property.\r\n\t\tforeach ( var c in go.Components.GetAll() )\r\n\t\t{\r\n\t\t\tvar td = Game.TypeLibrary.GetType( c.GetType().Name );\r\n\t\t\tvar pd = td?.Properties.FirstOrDefault( pp => pp.Name == property );\r\n\t\t\tif ( pd == null ) continue;\r\n\r\n\t\t\tif ( c.GetType().Name.Equals( \"NpcBrain\", StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\t{\r\n\t\t\t\tpd.SetValue( c, value );\r\n\t\t\t\treturn c;\r\n\t\t\t}\r\n\r\n\t\t\tfallbackComp = fallbackComp ?? c;\r\n\t\t}\r\n\r\n\t\tif ( fallbackComp != null )\r\n\t\t{\r\n\t\t\tvar td = Game.TypeLibrary.GetType( fallbackComp.GetType().Name );\r\n\t\t\tvar pd = td?.Properties.FirstOrDefault( pp => pp.Name == property );\r\n\t\t\tpd?.SetValue( fallbackComp, value );\r\n\t\t}\r\n\r\n\t\treturn fallbackComp;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Find the \"perception brain\" component on <paramref name=\"go\"/> \u2014 the component\r\n\t/// simulate_npc_perception should read SightRange/FovDegrees/EyeHeight/TargetTag from.\r\n\t///\r\n\t/// Why not just match the type name \"NpcBrain\": a custom-named brain (e.g. BigfootBrain,\r\n\t/// generated via create_npc_brain with name=\"BigfootBrain\") exposes the same perception\r\n\t/// [Property] surface but a different type name, so a literal name match silently falls\r\n\t/// back to spec defaults. We match by CAPABILITY instead:\r\n\t/// 1. a component literally named \"NpcBrain\" (the default), else\r\n\t/// 2. a component whose TypeLibrary description exposes BOTH SightRange and FovDegrees\r\n\t/// (the perception contract), else\r\n\t/// 3. a component whose type name ends with \"Brain\".\r\n\t/// Returns null if none match (caller then uses defaults / explicit overrides).\r\n\t/// </summary>\r\n\tpublic static Component FindPerceptionBrain( GameObject go )\r\n\t{\r\n\t\tif ( go == null ) return null;\r\n\r\n\t\tComponent byProps = null;\r\n\t\tComponent byName = null;\r\n\r\n\t\tforeach ( var c in go.Components.GetAll() )\r\n\t\t{\r\n\t\t\tvar typeName = c.GetType().Name;\r\n\r\n\t\t\t// 1. Exact \"NpcBrain\" wins immediately (the generated default).\r\n\t\t\tif ( typeName.Equals( \"NpcBrain\", StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\t\treturn c;\r\n\r\n\t\t\t// 2. Capability match: exposes the perception property contract.\r\n\t\t\tif ( byProps == null )\r\n\t\t\t{\r\n\t\t\t\tvar td = Game.TypeLibrary.GetType( typeName );\r\n\t\t\t\tif ( td != null\r\n\t\t\t\t\t&& td.Properties.Any( pp => pp.Name == \"SightRange\" )\r\n\t\t\t\t\t&& td.Properties.Any( pp => pp.Name == \"FovDegrees\" ) )\r\n\t\t\t\t{\r\n\t\t\t\t\tbyProps = c;\r\n\t\t\t\t}\r\n\t\t\t}\r\n\r\n\t\t\t// 3. Name heuristic: \"...Brain\".\r\n\t\t\tif ( byName == null && typeName.EndsWith( \"Brain\", StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\t\tbyName = c;\r\n\t\t}\r\n\r\n\t\treturn byProps ?? byName;\r\n\t}\r\n}\r\n\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n// 1. create_npc_brain (code-gen; scene-mutating)\r\n// Generates an NpcBrain Component: a finite-state machine (Idle/Patrol/\r\n// Wander/Chase/Search/Flee/Ambush) driven by occlusion-aware perception\r\n// (FOV cone + range + LOS trace + hearing) with last-known-position memory.\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\npublic class CreateNpcBrainHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar name = NpcBrainHelpers.Str( p, \"name\", \"NpcBrain\" );\r\n\t\t\tvar directory = NpcBrainHelpers.Str( p, \"directory\", \"Code\" );\r\n\r\n\t\t\tvar fileName = name.EndsWith( \".cs\" ) ? name : $\"{name}.cs\";\r\n\t\t\tif ( !ClaudeBridge.TryResolveProjectPath( Path.Combine( directory, fileName ), out var fullPath, out var pathErr ) )\r\n\t\t\t\treturn Task.FromResult<object>( new { error = pathErr } );\r\n\r\n\t\t\tif ( File.Exists( fullPath ) )\r\n\t\t\t\treturn Task.FromResult<object>( new { error = $\"File already exists: {directory}/{fileName}\" } );\r\n\r\n\t\t\tvar className = ClaudeBridge.SanitizeIdentifier( Path.GetFileNameWithoutExtension( fileName ) );\r\n\r\n\t\t\t// \u2500\u2500 Preset \u2192 defaults. The generated file is identical shape; the preset\r\n\t\t\t// only changes [Property] defaults (StartState, CanFlee).\r\n\t\t\tvar behavior = NpcBrainHelpers.Str( p, \"behavior\", \"hunter\" ).ToLowerInvariant();\r\n\t\t\tstring startState;\r\n\t\t\tbool presetCanFlee;\r\n\t\t\tswitch ( behavior )\r\n\t\t\t{\r\n\t\t\t\tcase \"patrol\": startState = \"Patrol\"; presetCanFlee = false; break;\r\n\t\t\t\tcase \"guard\": startState = \"Ambush\"; presetCanFlee = false; break;\r\n\t\t\t\tcase \"swarm\": startState = \"Wander\"; presetCanFlee = false; break;\r\n\t\t\t\tcase \"skittish\": startState = \"Patrol\"; presetCanFlee = true; break;\r\n\t\t\t\tcase \"hunter\":\r\n\t\t\t\tdefault: behavior = \"hunter\"; startState = \"Patrol\"; presetCanFlee = false; break;\r\n\t\t\t}\r\n\r\n\t\t\t// \u2500\u2500 Tunables (params override preset/spec defaults). \u2500\u2500\r\n\t\t\tvar moveSpeed = NpcBrainHelpers.Float( p, \"moveSpeed\", 130f );\r\n\t\t\tvar chaseSpeed = NpcBrainHelpers.Float( p, \"chaseSpeed\", 200f );\r\n\t\t\tvar sightRange = NpcBrainHelpers.Float( p, \"sightRange\", 1500f );\r\n\t\t\tvar fovDegrees = NpcBrainHelpers.Float( p, \"fovDegrees\", 110f );\r\n\t\t\tvar eyeHeight = NpcBrainHelpers.Float( p, \"eyeHeight\", 64f );\r\n\t\t\tvar hearingRadius = NpcBrainHelpers.Float( p, \"hearingRadius\", 600f );\r\n\t\t\tvar giveUpTime = NpcBrainHelpers.Float( p, \"giveUpTime\", 6f );\r\n\t\t\tvar searchRadius = NpcBrainHelpers.Float( p, \"searchRadius\", 400f );\r\n\t\t\tvar waypointStop = NpcBrainHelpers.Float( p, \"waypointStopDistance\", 80f );\r\n\t\t\tvar canFlee = NpcBrainHelpers.Bool( p, \"canFlee\", presetCanFlee );\r\n\t\t\tvar fleeHealth = NpcBrainHelpers.Float( p, \"fleeHealthFrac\", 0.25f );\r\n\t\t\tvar networked = NpcBrainHelpers.Bool( p, \"networked\", true );\r\n\t\t\tvar targetTag = NpcBrainHelpers.Str( p, \"targetTag\", \"player\" );\r\n\t\t\t// Citizen locomotion animation: when on (default), the generated brain caches a\r\n\t\t\t// SkinnedModelRenderer + CitizenAnimationHelper in OnStart and drives walk/run/idle\r\n\t\t\t// from the NavMeshAgent each frame (so the NPC and every spawner clone animate\r\n\t\t\t// instead of sliding in bind pose). Proven approach ported from BigfootBrain.cs.\r\n\t\t\tvar animate = NpcBrainHelpers.Bool( p, \"animate\", true );\r\n\r\n\t\t\tvar cosFov = NpcBrainHelpers.CosHalfFov( fovDegrees );\r\n\r\n\t\t\tvar code = BuildSource(\r\n\t\t\t\tclassName, startState, networked, animate,\r\n\t\t\t\tNpcBrainHelpers.EscVerbatim( targetTag ),\r\n\t\t\t\tmoveSpeed, chaseSpeed, sightRange, fovDegrees, cosFov, eyeHeight,\r\n\t\t\t\thearingRadius, giveUpTime, searchRadius, waypointStop, canFlee, fleeHealth );\r\n\r\n\t\t\tDirectory.CreateDirectory( Path.GetDirectoryName( fullPath ) );\r\n\t\t\tFile.WriteAllText( fullPath, code );\r\n\r\n\t\t\tvar states = new[] { \"Idle\", \"Patrol\", \"Wander\", \"Chase\", \"Search\", \"Flee\", \"Ambush\" };\r\n\t\t\tvar props = new[]\r\n\t\t\t{\r\n\t\t\t\t\"StartState\",\"MoveSpeed\",\"ChaseSpeed\",\"SightRange\",\"FovDegrees\",\"CosFovThreshold\",\r\n\t\t\t\t\"EyeHeight\",\"HearingRadius\",\"TargetTag\",\"GiveUpTime\",\"SearchRadius\",\"WaypointStopDistance\",\r\n\t\t\t\t\"PingPong\",\"CanFlee\",\"FleeHealthFrac\",\"CurrentHealthFrac\",\"Waypoints\",\"CurrentState\"\r\n\t\t\t};\r\n\r\n\t\t\treturn Task.FromResult<object>( new\r\n\t\t\t{\r\n\t\t\t\tcreated = true,\r\n\t\t\t\tpath = $\"{directory}/{fileName}\",\r\n\t\t\t\tclassName,\r\n\t\t\t\tbehavior,\r\n\t\t\t\tnetworked,\r\n\t\t\t\tanimate,\r\n\t\t\t\tstatesIncluded = states,\r\n\t\t\t\tpropertyNames = props,\r\n\t\t\t\tnote = \"NavMeshAgent is added automatically via GetOrAddComponent in OnStart. \" +\r\n\t\t\t\t \"Requires bake_navmesh + a navmesh-walkable scene for movement. \" +\r\n\t\t\t\t \"Assign a patrol route with place_patrol_route + assign_patrol_route. \" +\r\n\t\t\t\t \"Verify perception in EDIT mode with simulate_npc_perception; verify chase/search by entering play mode \" +\r\n\t\t\t\t \"(get_runtime_property CurrentState + timed screenshot_from). \" +\r\n\t\t\t\t ( animate\r\n\t\t\t\t ? \"Locomotion animation ON: caches a SkinnedModelRenderer + CitizenAnimationHelper in OnStart and drives walk/run/idle from the NavMeshAgent each frame \u2014 attach this brain to a GameObject with a Citizen (or any SkinnedModel) renderer (on it or a child) and it animates while moving instead of sliding. Spawner clones inherit it (each runs its own OnStart). Pass animate:false to disable. \"\r\n\t\t\t\t : \"Locomotion animation OFF (animate:false): the NPC slides in bind pose; drive a CitizenAnimationHelper yourself if you want walk/run anims. \" ) +\r\n\t\t\t\t ( networked\r\n\t\t\t\t ? \"Networked: host-authoritative (if(IsProxy)return) + [Sync] CurrentState \u2014 needs a host session; a no-session solo playtest makes everything a proxy so the brain won't think (use networked:false to iterate solo).\"\r\n\t\t\t\t : \"Solo/edit build: no IsProxy guard, so it ticks in a single-machine playtest.\" )\r\n\t\t\t} );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"create_npc_brain failed: {ex.Message}\" } );\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Build the NpcBrain component source. Everything here must be SANDBOX-LEGAL.\r\n\t/// Movement uses only the confirmed NavMeshAgent.MoveTo(Vector3); perception\r\n\t/// uses only Vector3.Dot/.Normal + scene.Trace.Ray(a,b).Run() + Scene.GetAllComponents.\r\n\t/// FOV uses a baked cosine threshold (no trig in the sandbox).\r\n\t/// When <paramref name=\"animate\"/> is true the generated brain also caches a\r\n\t/// CitizenAnimationHelper (off a SkinnedModelRenderer) and feeds it the NavMeshAgent\r\n\t/// velocity each frame \u2014 sandbox-legal locomotion ported from BigfootBrain.cs (uses\r\n\t/// Sandbox.Citizen + MathX, never System.Math).\r\n\t/// </summary>\r\n\tprivate static string BuildSource(\r\n\t\tstring className, string startState, bool networked, bool animate, string targetTagLiteral,\r\n\t\tfloat moveSpeed, float chaseSpeed, float sightRange, float fovDegrees, float cosFov,\r\n\t\tfloat eyeHeight, float hearingRadius, float giveUpTime, float searchRadius,\r\n\t\tfloat waypointStop, bool canFlee, float fleeHealth )\r\n\t{\r\n\t\tstring F( float v ) => NpcBrainHelpers.F( v );\r\n\r\n\t\t// Host-authority guard line (networked) vs none (solo). The [Sync] on\r\n\t\t// CurrentState lets proxies read the host's state for client-side animation.\r\n\t\tvar proxyGuard = networked ? \"\\t\\tif ( IsProxy ) return; // host-authoritative \u2014 only the host thinks\\n\" : \"\";\r\n\t\tvar stateAttr = networked ? \"[Sync] \" : \"\";\r\n\t\tvar headerNote = networked\r\n\t\t\t? \"// Host-authoritative AI brain. Only the host runs the FSM; CurrentState is [Sync]'d\\n// so proxy clients can animate the NPC. Needs an active network session (a no-session\\n// solo playtest makes everything a proxy \u2014 generate with networked:false to iterate solo).\\n\"\r\n\t\t\t: \"// Solo / edit-scene AI brain (no networking guard). Ticks in a single-machine playtest.\\n\";\r\n\r\n\t\t// \u2500\u2500 Citizen locomotion animation (ported verbatim from the proven BigfootBrain.cs).\r\n\t\t// Everything here is sandbox-legal: Sandbox.Citizen + GetOrAddComponent + the\r\n\t\t// NavMeshAgent's own Velocity/WishVelocity, no System.Math. When animate:false these\r\n\t\t// fragments are empty strings, so the generated brain is byte-for-byte the old one.\r\n\t\tvar animUsing = animate ? \"using Sandbox.Citizen;\\n\" : \"\";\r\n\t\tvar animFields = animate\r\n\t\t\t? \"\\n\\t// Citizen locomotion. Drives the anim helper from the agent's velocity each frame so the\\n\" +\r\n\t\t\t \"\\t// NPC walks/runs/idles instead of sliding in bind pose. Cached off the SkinnedModelRenderer\\n\" +\r\n\t\t\t \"\\t// in OnStart (works for the source NPC AND its spawner clones \u2014 they each run OnStart).\\n\" +\r\n\t\t\t \"\\tprivate CitizenAnimationHelper _anim;\\n\" +\r\n\t\t\t \"\\tprivate SkinnedModelRenderer _renderer;\\n\"\r\n\t\t\t: \"\";\r\n\t\t// OnStart wiring. Wiring _anim.Target avoids a WithWishVelocity NRE (see SBOX_KNOWLEDGE.md).\r\n\t\tvar animOnStart = animate\r\n\t\t\t? \"\\n\\t\\t// Locomotion animation. Find the SkinnedModelRenderer (this GO or a child), then\\n\" +\r\n\t\t\t \"\\t\\t// get-or-add a CitizenAnimationHelper and wire its Target \u2014 the helper NREs in\\n\" +\r\n\t\t\t \"\\t\\t// WithWishVelocity if Target is null. A Citizen .vmdl already has the locomotion\\n\" +\r\n\t\t\t \"\\t\\t// anim-graph, so once fed velocity it walks/runs/idles on its own.\\n\" +\r\n\t\t\t \"\\t\\t_renderer = GetComponent<SkinnedModelRenderer>() ?? GetComponentInChildren<SkinnedModelRenderer>();\\n\" +\r\n\t\t\t \"\\t\\tif ( _renderer.IsValid() )\\n\" +\r\n\t\t\t \"\\t\\t{\\n\" +\r\n\t\t\t \"\\t\\t\\t_anim = GetOrAddComponent<CitizenAnimationHelper>();\\n\" +\r\n\t\t\t \"\\t\\t\\t_anim.Target = _renderer;\\n\" +\r\n\t\t\t \"\\t\\t}\\n\"\r\n\t\t\t: \"\";\r\n\t\t// Per-frame drive call (placed at the end of OnUpdate) + the method body.\r\n\t\tvar animUpdateCall = animate ? \"\\t\\tDriveAnimation();\\n\" : \"\";\r\n\t\tvar animMethod = animate\r\n\t\t\t? \"\\n\\t// \u2500\u2500 Locomotion animation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\n\" +\r\n\t\t\t \"\\t/// <summary>Feed the Citizen anim helper from the NavMeshAgent each frame so the NPC\\n\" +\r\n\t\t\t \"\\t/// plays walk/run/idle instead of sliding in bind pose. WithVelocity drives the\\n\" +\r\n\t\t\t \"\\t/// locomotion blend; WithWishVelocity drives lean/start-stop; IsGrounded keeps it out\\n\" +\r\n\t\t\t \"\\t/// of the fall pose. Glance toward the chased target, else toward travel direction.</summary>\\n\" +\r\n\t\t\t \"\\tprivate void DriveAnimation()\\n\" +\r\n\t\t\t \"\\t{\\n\" +\r\n\t\t\t \"\\t\\tif ( _anim == null || !_anim.IsValid() ) return;\\n\" +\r\n\t\t\t \"\\n\" +\r\n\t\t\t \"\\t\\tvar velocity = _agent.Velocity;\\n\" +\r\n\t\t\t \"\\t\\t_anim.WithVelocity( velocity );\\n\" +\r\n\t\t\t \"\\t\\t_anim.WithWishVelocity( _agent.WishVelocity );\\n\" +\r\n\t\t\t \"\\t\\t_anim.IsGrounded = true;\\n\" +\r\n\t\t\t \"\\n\" +\r\n\t\t\t \"\\t\\tVector3 lookDir;\\n\" +\r\n\t\t\t \"\\t\\tif ( CurrentState == BrainState.Chase && _target.IsValid() )\\n\" +\r\n\t\t\t \"\\t\\t\\tlookDir = ( _target.WorldPosition - WorldPosition ).WithZ( 0f );\\n\" +\r\n\t\t\t \"\\t\\telse\\n\" +\r\n\t\t\t \"\\t\\t\\tlookDir = velocity.WithZ( 0f );\\n\" +\r\n\t\t\t \"\\n\" +\r\n\t\t\t \"\\t\\tif ( lookDir.Length > 1f )\\n\" +\r\n\t\t\t \"\\t\\t\\t_anim.WithLook( lookDir.Normal, 1f, 0.6f, 0.2f );\\n\" +\r\n\t\t\t \"\\t}\\n\"\r\n\t\t\t: \"\";\r\n\r\n\t\treturn\r\n$@\"using Sandbox;\r\n{animUsing}using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\n\r\n{headerNote}public sealed class {className} : Component\r\n{{\r\n\tpublic enum BrainState {{ Idle, Patrol, Wander, Chase, Search, Flee, Ambush }}\r\n\r\n\t// \u2500\u2500 Tunables (all [Property] so the bridge can set_property / tune later) \u2500\u2500\r\n\t[Property] public BrainState StartState {{ get; set; }} = BrainState.{startState};\r\n\t[Property] public float MoveSpeed {{ get; set; }} = {F( moveSpeed )};\r\n\t[Property] public float ChaseSpeed {{ get; set; }} = {F( chaseSpeed )};\r\n\r\n\t// Perception\r\n\t[Property] public float SightRange {{ get; set; }} = {F( sightRange )};\r\n\t// FovDegrees is the human-readable full cone angle. The actual gate compares a\r\n\t// dot product against CosFovThreshold = cos(FovDegrees/2), which is baked here so\r\n\t// the sandbox needs no trig. If you change FovDegrees at runtime, also update\r\n\t// CosFovThreshold (tune_npc_perception / set_property), or call SetFov(...) below.\r\n\t[Property] public float FovDegrees {{ get; set; }} = {F( fovDegrees )};\r\n\t[Property] public float CosFovThreshold {{ get; set; }} = {F( cosFov )};\r\n\t[Property] public float EyeHeight {{ get; set; }} = {F( eyeHeight )};\r\n\t[Property] public float HearingRadius {{ get; set; }} = {F( hearingRadius )};\r\n\t[Property] public string TargetTag {{ get; set; }} = @\"\"{targetTagLiteral}\"\";\r\n\r\n\t// Memory / timing\r\n\t[Property] public float GiveUpTime {{ get; set; }} = {F( giveUpTime )};\r\n\t[Property] public float SearchRadius {{ get; set; }} = {F( searchRadius )};\r\n\t[Property] public float WaypointStopDistance {{ get; set; }} = {F( waypointStop )};\r\n\t[Property] public bool PingPong {{ get; set; }} = false;\r\n\r\n\t// Flee (health source is generic: the game sets CurrentHealthFrac 0..1, or\r\n\t// override ShouldFlee() in a partial/subclass \u2014 no hard coupling to any HP comp).\r\n\t[Property] public bool CanFlee {{ get; set; }} = {( canFlee ? \"true\" : \"false\" )};\r\n\t[Property] public float FleeHealthFrac {{ get; set; }} = {F( fleeHealth )};\r\n\t[Property] public float CurrentHealthFrac {{ get; set; }} = 1f;\r\n\r\n\t// Patrol route (placed + wired by assign_patrol_route, or hand-set in editor).\r\n\t[Property] public List<GameObject> Waypoints {{ get; set; }} = new();\r\n\r\n\t// \u2500\u2500 Runtime state \u2500\u2500\r\n\t{stateAttr}public BrainState CurrentState {{ get; private set; }}\r\n\tprivate GameObject _target;\r\n\tprivate Vector3 _lastKnownPos;\r\n\tprivate TimeSince _timeSinceSeen;\r\n\tprivate Vector3 _wanderTarget;\r\n\tprivate TimeSince _timeSinceWanderPick;\r\n\tprivate int _waypointIndex;\r\n\tprivate int _waypointDir = 1;\r\n\tprivate NavMeshAgent _agent;\r\n{animFields}\r\n\tprotected override void OnStart()\r\n\t{{\r\n\t\t_agent = GetOrAddComponent<NavMeshAgent>();\r\n{animOnStart}\t\tCurrentState = StartState;\r\n\t\t_timeSinceSeen = 999f;\r\n\t\t_lastKnownPos = WorldPosition;\r\n\t\t_wanderTarget = WorldPosition;\r\n\t}}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{{\r\n{proxyGuard}\t\tif ( _agent == null ) return;\r\n\r\n\t\tPerceive();\r\n\t\tThink();\r\n\t\tAct();\r\n{animUpdateCall}\t}}\r\n{animMethod}\r\n\r\n\t/// <summary>Recompute the FOV cosine from a degree value at runtime (no trig in\r\n\t/// the sandbox: cos(x) via the half-angle identity from a normalized sweep is\r\n\t/// overkill, so we keep it simple \u2014 set both together).</summary>\r\n\tpublic void SetFov( float degrees, float cosThreshold )\r\n\t{{\r\n\t\tFovDegrees = degrees;\r\n\t\tCosFovThreshold = cosThreshold;\r\n\t}}\r\n\r\n\t// \u2500\u2500 Perception \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\tprivate void Perceive()\r\n\t{{\r\n\t\tvar eye = WorldPosition + Vector3.Up * EyeHeight;\r\n\t\tvar best = FindVisibleTarget( eye, out var sawSomething );\r\n\r\n\t\tif ( best.IsValid() )\r\n\t\t{{\r\n\t\t\t_target = best;\r\n\t\t\t_lastKnownPos = best.WorldPosition;\r\n\t\t\t_timeSinceSeen = 0f;\r\n\t\t\treturn;\r\n\t\t}}\r\n\r\n\t\t// Passive hearing: a candidate within HearingRadius is \"\"heard\"\" (sets a\r\n\t\t// last-known position to investigate) but is NOT treated as seen \u2014 so the\r\n\t\t// NPC investigates rather than instantly aggroing.\r\n\t\tvar heard = FindNearestCandidate( WorldPosition, HearingRadius );\r\n\t\tif ( heard.IsValid() )\r\n\t\t\t_lastKnownPos = heard.WorldPosition;\r\n\r\n\t\t// keep _target ref while it grows stale; _timeSinceSeen advances on its own.\r\n\t}}\r\n\r\n\t/// <summary>Pick the nearest candidate that passes range + FOV cone + LOS.</summary>\r\n\tprivate GameObject FindVisibleTarget( Vector3 eye, out bool any )\r\n\t{{\r\n\t\tany = false;\r\n\t\tGameObject bestGo = null;\r\n\t\tfloat bestDist = float.MaxValue;\r\n\r\n\t\tforeach ( var cand in Candidates() )\r\n\t\t{{\r\n\t\t\tvar to = cand.WorldPosition - eye;\r\n\t\t\tfloat dist = to.Length;\r\n\t\t\tif ( dist > SightRange ) continue;\r\n\t\t\tif ( dist < 0.01f ) continue;\r\n\r\n\t\t\tvar dir = to.Normal;\r\n\t\t\t// FOV cone gate (cheap): dot >= cos(half-fov). No trig needed.\r\n\t\t\tif ( Vector3.Dot( WorldRotation.Forward, dir ) < CosFovThreshold ) continue;\r\n\r\n\t\t\t// Occlusion trace from the eye to the candidate. IgnoreGameObjectHierarchy\r\n\t\t\t// excludes the NPC's own colliders so it can't \"\"see\"\" itself. Clear when the\r\n\t\t\t// ray hits the candidate directly, hits nothing, or the first hit is\r\n\t\t\t// essentially at the candidate (a child collider) \u2014 a distance test that\r\n\t\t\t// needs no extra API. Anything blocking earlier (a tree/wall) fails LOS.\r\n\t\t\tvar tr = Scene.Trace.Ray( eye, cand.WorldPosition ).IgnoreGameObjectHierarchy( GameObject ).Run();\r\n\t\t\tbool clear = !tr.Hit || tr.GameObject == cand || tr.Distance >= dist - 8f;\r\n\t\t\tif ( !clear ) continue;\r\n\r\n\t\t\tany = true;\r\n\t\t\tif ( dist < bestDist ) {{ bestDist = dist; bestGo = cand; }}\r\n\t\t}}\r\n\r\n\t\treturn bestGo;\r\n\t}}\r\n\r\n\tprivate GameObject FindNearestCandidate( Vector3 from, float maxDist )\r\n\t{{\r\n\t\tGameObject best = null;\r\n\t\tfloat bestDist = maxDist;\r\n\t\tforeach ( var cand in Candidates() )\r\n\t\t{{\r\n\t\t\tfloat d = Vector3.DistanceBetween( from, cand.WorldPosition );\r\n\t\t\tif ( d <= bestDist ) {{ bestDist = d; best = cand; }}\r\n\t\t}}\r\n\t\treturn best;\r\n\t}}\r\n\r\n\t/// <summary>Candidate targets = GameObjects tagged TargetTag, excluding self.\r\n\t/// Uses Scene.GetAllComponents to enumerate, then filters by tag.</summary>\r\n\tprivate IEnumerable<GameObject> Candidates()\r\n\t{{\r\n\t\tforeach ( var c in Scene.GetAllComponents<Collider>() )\r\n\t\t{{\r\n\t\t\tvar go = c.GameObject;\r\n\t\t\tif ( go == null || go == GameObject ) continue;\r\n\t\t\tif ( !go.Tags.Has( TargetTag ) ) continue;\r\n\t\t\tyield return go;\r\n\t\t}}\r\n\t}}\r\n\r\n\t// \u2500\u2500 Transition table \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\tprivate void Think()\r\n\t{{\r\n\t\tbool canSee = _target.IsValid() && _timeSinceSeen < 0.1f;\r\n\r\n\t\tif ( CanFlee && ShouldFlee() ) {{ CurrentState = BrainState.Flee; return; }}\r\n\r\n\t\tswitch ( CurrentState )\r\n\t\t{{\r\n\t\t\tcase BrainState.Idle:\r\n\t\t\tcase BrainState.Patrol:\r\n\t\t\tcase BrainState.Wander:\r\n\t\t\tcase BrainState.Ambush:\r\n\t\t\t\tif ( canSee ) CurrentState = BrainState.Chase;\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase BrainState.Chase:\r\n\t\t\t\tif ( !canSee && _timeSinceSeen > 0.25f ) CurrentState = BrainState.Search;\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase BrainState.Search:\r\n\t\t\t\tif ( canSee ) CurrentState = BrainState.Chase;\r\n\t\t\t\telse if ( _timeSinceSeen > GiveUpTime ) {{ _target = null; CurrentState = StartState; }}\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase BrainState.Flee:\r\n\t\t\t\tif ( !ShouldFlee() ) CurrentState = StartState;\r\n\t\t\t\tbreak;\r\n\t\t}}\r\n\t}}\r\n\r\n\t// \u2500\u2500 Action per state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\tprivate void Act()\r\n\t{{\r\n\t\t// Apply the desired locomotion speed (chase is faster). NavMeshAgent.MaxSpeed\r\n\t\t// is the agent's speed cap (verified in the navmesh docs).\r\n\t\t_agent.MaxSpeed = ( CurrentState == BrainState.Chase || CurrentState == BrainState.Flee ) ? ChaseSpeed : MoveSpeed;\r\n\r\n\t\tswitch ( CurrentState )\r\n\t\t{{\r\n\t\t\tcase BrainState.Idle:\r\n\t\t\tcase BrainState.Ambush:\r\n\t\t\t\t// Stand still and watch (perception still runs every tick).\r\n\t\t\t\t_agent.Stop();\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase BrainState.Patrol:\r\n\t\t\t\tPatrolStep();\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase BrainState.Wander:\r\n\t\t\t\tWanderStep( WorldPosition, SearchRadius );\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase BrainState.Chase:\r\n\t\t\t\tif ( _target.IsValid() )\r\n\t\t\t\t\t_agent.MoveTo( _target.WorldPosition );\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase BrainState.Search:\r\n\t\t\t\tif ( Vector3.DistanceBetween( WorldPosition, _lastKnownPos ) > WaypointStopDistance )\r\n\t\t\t\t\t_agent.MoveTo( _lastKnownPos );\r\n\t\t\t\telse\r\n\t\t\t\t\tWanderStep( _lastKnownPos, SearchRadius );\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase BrainState.Flee:\r\n\t\t\t\tFleeStep();\r\n\t\t\t\tbreak;\r\n\t\t}}\r\n\t}}\r\n\r\n\tprivate void PatrolStep()\r\n\t{{\r\n\t\tif ( Waypoints == null || Waypoints.Count == 0 ) return;\r\n\t\t_waypointIndex = (int)MathX.Clamp( _waypointIndex, 0, Waypoints.Count - 1 );\r\n\r\n\t\tvar wp = Waypoints[_waypointIndex];\r\n\t\tif ( !wp.IsValid() ) {{ AdvanceWaypoint(); return; }}\r\n\r\n\t\tif ( Vector3.DistanceBetween( WorldPosition, wp.WorldPosition ) <= WaypointStopDistance )\r\n\t\t\tAdvanceWaypoint();\r\n\t\telse\r\n\t\t\t_agent.MoveTo( wp.WorldPosition );\r\n\t}}\r\n\r\n\tprivate void AdvanceWaypoint()\r\n\t{{\r\n\t\tif ( Waypoints == null || Waypoints.Count <= 1 ) return;\r\n\r\n\t\tif ( PingPong )\r\n\t\t{{\r\n\t\t\tif ( _waypointIndex + _waypointDir >= Waypoints.Count || _waypointIndex + _waypointDir < 0 )\r\n\t\t\t\t_waypointDir = -_waypointDir;\r\n\t\t\t_waypointIndex += _waypointDir;\r\n\t\t}}\r\n\t\telse\r\n\t\t{{\r\n\t\t\t_waypointIndex = ( _waypointIndex + 1 ) % Waypoints.Count;\r\n\t\t}}\r\n\t}}\r\n\r\n\tprivate void WanderStep( Vector3 home, float radius )\r\n\t{{\r\n\t\tbool reached = Vector3.DistanceBetween( WorldPosition, _wanderTarget ) <= WaypointStopDistance;\r\n\t\tif ( reached || _timeSinceWanderPick > 4f )\r\n\t\t{{\r\n\t\t\t// Pick a fresh point near home. Uses only confirmed APIs (Random.Shared\r\n\t\t\t// + Vector3). The agent paths toward the nearest reachable point, so an\r\n\t\t\t// occasional off-mesh pick is harmless. (For strictly-on-mesh wander,\r\n\t\t\t// swap to Scene.NavMesh.GetRandomPoint(home, radius) once its return type\r\n\t\t\t// is confirmed via describe_type.)\r\n\t\t\tvar off = new Vector3(\r\n\t\t\t\tRandom.Shared.Float( -radius, radius ),\r\n\t\t\t\tRandom.Shared.Float( -radius, radius ),\r\n\t\t\t\t0f );\r\n\t\t\t_wanderTarget = home + off;\r\n\t\t\t_timeSinceWanderPick = 0f;\r\n\t\t}}\r\n\t\t_agent.MoveTo( _wanderTarget );\r\n\t}}\r\n\r\n\tprivate void FleeStep()\r\n\t{{\r\n\t\t// Move directly away from the last-known threat position.\r\n\t\tvar away = ( WorldPosition - _lastKnownPos ).Normal;\r\n\t\tif ( away.Length < 0.01f ) away = WorldRotation.Forward;\r\n\t\t_agent.MoveTo( WorldPosition + away * MathX.Clamp( SearchRadius, 100f, 2000f ) );\r\n\t}}\r\n\r\n\t/// <summary>Generic flee predicate. Driven by CurrentHealthFrac (the game sets\r\n\t/// it 0..1). Override in a subclass/partial for game-specific logic (e.g. a\r\n\t/// bomb-timer panic in RUN, or a camper-HP check in Sasquatched).</summary>\r\n\tpublic bool ShouldFlee()\r\n\t{{\r\n\t\treturn CanFlee && CurrentHealthFrac <= FleeHealthFrac;\r\n\t}}\r\n\r\n\t// \u2500\u2500 Noise hook (pure C#; the game calls this where a noise happens) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\t// Example: NpcBrain.ReportNoise(flashlightPos, 800f) when a camper clicks a\r\n\t// flashlight, or a gunshot in RUN. NPCs within radius investigate (Search).\r\n\tpublic static void ReportNoise( Scene scene, Vector3 pos, float radius )\r\n\t{{\r\n\t\tif ( scene == null ) return;\r\n\t\tforeach ( var brain in scene.GetAllComponents<{className}>() )\r\n\t\t\tbrain.HearNoise( pos, radius );\r\n\t}}\r\n\r\n\tpublic void HearNoise( Vector3 pos, float radius )\r\n\t{{\r\n\t\tif ( Vector3.DistanceBetween( WorldPosition, pos ) > radius ) return;\r\n\t\t_lastKnownPos = pos;\r\n\t\tif ( CurrentState != BrainState.Chase )\r\n\t\t\tCurrentState = BrainState.Search;\r\n\t}}\r\n}}\r\n\";\r\n\t}\r\n}\r\n\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n// 2. place_patrol_route (scene-mutating)\r\n// Create N waypoint empties (tagged), grouped under a parent route object,\r\n// optionally snapped to the ground so they sit on the navmesh.\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\npublic class PlacePatrolRouteHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\tvar scene = SceneEditorSession.Active?.Scene;\r\n\t\tif ( scene == null )\r\n\t\t\treturn Task.FromResult<object>( new { error = \"No active scene\" } );\r\n\r\n\t\tif ( !p.TryGetProperty( \"points\", out var pts ) || pts.ValueKind != JsonValueKind.Array )\r\n\t\t\treturn Task.FromResult<object>( new { error = \"points (Vector3[]) is required\" } );\r\n\r\n\t\tvar rawPoints = new List<Vector3>();\r\n\t\tforeach ( var e in pts.EnumerateArray() )\r\n\t\t\trawPoints.Add( ClaudeBridge.ParseVector3( e ) );\r\n\r\n\t\tif ( rawPoints.Count < 2 )\r\n\t\t\treturn Task.FromResult<object>( new { error = \"Provide at least 2 points for a patrol route\" } );\r\n\r\n\t\tvar routeName = NpcBrainHelpers.Str( p, \"name\", \"PatrolRoute\" );\r\n\t\tvar tag = NpcBrainHelpers.Str( p, \"tag\", \"waypoint\" );\r\n\t\tvar snap = NpcBrainHelpers.Bool( p, \"snapToGround\", true );\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\t// Resolve or create the route parent.\r\n\t\t\tGameObject route = null;\r\n\t\t\tif ( p.TryGetProperty( \"parentId\", out var pid ) && Guid.TryParse( pid.GetString(), out var parentGuid ) )\r\n\t\t\t\troute = scene.Directory.FindByGuid( parentGuid );\r\n\r\n\t\t\tif ( route == null )\r\n\t\t\t{\r\n\t\t\t\troute = scene.CreateObject( true );\r\n\t\t\t\troute.Name = routeName;\r\n\t\t\t\t// Place the parent at the centroid for a tidy hierarchy + easy framing.\r\n\t\t\t\tvar centroid = Vector3.Zero;\r\n\t\t\t\tforeach ( var pt in rawPoints ) centroid += pt;\r\n\t\t\t\troute.WorldPosition = centroid / rawPoints.Count;\r\n\t\t\t}\r\n\r\n\t\t\tvar waypointIds = new List<string>( rawPoints.Count );\r\n\t\t\tint i = 0;\r\n\t\t\tforeach ( var pt in rawPoints )\r\n\t\t\t{\r\n\t\t\t\tvar pos = pt;\r\n\t\t\t\tif ( snap )\r\n\t\t\t\t{\r\n\t\t\t\t\ttry\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tvar tr = scene.Trace.Ray( pos + Vector3.Up * 2000f, pos + Vector3.Down * 20000f ).Run();\r\n\t\t\t\t\t\tif ( tr.Hit ) pos = new Vector3( pos.x, pos.y, tr.HitPosition.z );\r\n\t\t\t\t\t}\r\n\t\t\t\t\tcatch { /* keep the raw point on trace failure */ }\r\n\t\t\t\t}\r\n\r\n\t\t\t\tvar wp = scene.CreateObject( true );\r\n\t\t\t\twp.Name = $\"{routeName}_WP{i}\";\r\n\t\t\t\twp.WorldPosition = pos;\r\n\t\t\t\twp.Tags.Add( tag );\r\n\t\t\t\twp.SetParent( route, keepWorldPosition: true );\r\n\t\t\t\twaypointIds.Add( wp.Id.ToString() );\r\n\t\t\t\ti++;\r\n\t\t\t}\r\n\r\n\t\t\treturn Task.FromResult<object>( new\r\n\t\t\t{\r\n\t\t\t\tplaced = true,\r\n\t\t\t\trouteId = route.Id.ToString(),\r\n\t\t\t\trouteName = route.Name,\r\n\t\t\t\twaypointIds,\r\n\t\t\t\tcount = waypointIds.Count,\r\n\t\t\t\tsnappedToGround = snap,\r\n\t\t\t\tnote = \"Wire these into an NpcBrain with assign_patrol_route (pass routeId or waypointIds). \" +\r\n\t\t\t\t \"Validate connectivity with get_navmesh_path between consecutive waypoints (catches a point in a wall).\"\r\n\t\t\t} );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"place_patrol_route failed: {ex.Message}\" } );\r\n\t\t}\r\n\t}\r\n}\r\n\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n// 3. assign_patrol_route (scene-mutating)\r\n// Wire a placed route (or an arbitrary GUID list) into a List<GameObject>\r\n// property (default \"Waypoints\") on a target NPC's component. This is the\r\n// list-of-GameObject-refs case plain set_property can't express.\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\npublic class AssignPatrolRouteHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\tvar scene = SceneEditorSession.Active?.Scene;\r\n\t\tif ( scene == null )\r\n\t\t\treturn Task.FromResult<object>( new { error = \"No active scene\" } );\r\n\r\n\t\tif ( !p.TryGetProperty( \"npcId\", out var npcEl ) || !Guid.TryParse( npcEl.GetString(), out var npcGuid ) )\r\n\t\t\treturn Task.FromResult<object>( new { error = \"npcId (GameObject GUID holding the NpcBrain) is required\" } );\r\n\r\n\t\tvar npc = scene.Directory.FindByGuid( npcGuid );\r\n\t\tif ( npc == null )\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"NPC GameObject not found: {npcEl.GetString()}\" } );\r\n\r\n\t\tvar property = NpcBrainHelpers.Str( p, \"property\", \"Waypoints\" );\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\t// \u2500\u2500 Gather the ordered waypoint GameObjects: explicit waypointIds win,\r\n\t\t\t// else the children (hierarchy order) of routeId.\r\n\t\t\tvar waypoints = new List<GameObject>();\r\n\r\n\t\t\tif ( p.TryGetProperty( \"waypointIds\", out var wpArr ) && wpArr.ValueKind == JsonValueKind.Array )\r\n\t\t\t{\r\n\t\t\t\tforeach ( var e in wpArr.EnumerateArray() )\r\n\t\t\t\t\tif ( Guid.TryParse( e.GetString(), out var g ) )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tvar go = scene.Directory.FindByGuid( g );\r\n\t\t\t\t\t\tif ( go != null ) waypoints.Add( go );\r\n\t\t\t\t\t}\r\n\t\t\t}\r\n\t\t\telse if ( p.TryGetProperty( \"routeId\", out var routeEl ) && Guid.TryParse( routeEl.GetString(), out var routeGuid ) )\r\n\t\t\t{\r\n\t\t\t\tvar route = scene.Directory.FindByGuid( routeGuid );\r\n\t\t\t\tif ( route == null )\r\n\t\t\t\t\treturn Task.FromResult<object>( new { error = $\"Route GameObject not found: {routeEl.GetString()}\" } );\r\n\t\t\t\tforeach ( var child in route.Children )\r\n\t\t\t\t\twaypoints.Add( child );\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\treturn Task.FromResult<object>( new { error = \"Provide waypointIds (GUID[]) or routeId (route parent GUID)\" } );\r\n\t\t\t}\r\n\r\n\t\t\tif ( waypoints.Count == 0 )\r\n\t\t\t\treturn Task.FromResult<object>( new { error = \"No valid waypoints resolved from the given ids/route\" } );\r\n\r\n\t\t\t// \u2500\u2500 Resolve the component + property and set the List<GameObject>.\r\n\t\t\t// SetValue accepts a List<GameObject>; we hand it the concrete list\r\n\t\t\t// (matches how the editor serializes [Property] lists of refs).\r\n\t\t\tvar comp = NpcBrainHelpers.SetComponentProperty( npc, property, waypoints );\r\n\t\t\tif ( comp == null )\r\n\t\t\t\treturn Task.FromResult<object>( new { error = $\"No component on the NPC exposes a '{property}' property (expected an NpcBrain with a List<GameObject> {property})\" } );\r\n\r\n\t\t\treturn Task.FromResult<object>( new\r\n\t\t\t{\r\n\t\t\t\tassigned = true,\r\n\t\t\t\tnpcId = npcEl.GetString(),\r\n\t\t\t\tcomponent = comp.GetType().Name,\r\n\t\t\t\tproperty,\r\n\t\t\t\tcount = waypoints.Count,\r\n\t\t\t\tnote = \"List<GameObject> refs may read back as handles/GUIDs via get_property \u2014 trust this count, or confirm patrol in play mode.\"\r\n\t\t\t} );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"assign_patrol_route failed: {ex.Message}\" } );\r\n\t\t}\r\n\t}\r\n}\r\n\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n// 4. create_npc_spawner (code-gen; scene-mutating)\r\n// Generate a spawner Component that clones an NPC prefab over time / in\r\n// escalating waves at spawn points, capped by maxAlive. Host-authoritative\r\n// when networked (NetworkSpawn, guarded).\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\npublic class CreateNpcSpawnerHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar name = NpcBrainHelpers.Str( p, \"name\", \"NpcSpawner\" );\r\n\t\t\tvar directory = NpcBrainHelpers.Str( p, \"directory\", \"Code\" );\r\n\r\n\t\t\tvar fileName = name.EndsWith( \".cs\" ) ? name : $\"{name}.cs\";\r\n\t\t\tif ( !ClaudeBridge.TryResolveProjectPath( Path.Combine( directory, fileName ), out var fullPath, out var pathErr ) )\r\n\t\t\t\treturn Task.FromResult<object>( new { error = pathErr } );\r\n\r\n\t\t\tif ( File.Exists( fullPath ) )\r\n\t\t\t\treturn Task.FromResult<object>( new { error = $\"File already exists: {directory}/{fileName}\" } );\r\n\r\n\t\t\tvar className = ClaudeBridge.SanitizeIdentifier( Path.GetFileNameWithoutExtension( fileName ) );\r\n\r\n\t\t\tvar mode = NpcBrainHelpers.Str( p, \"mode\", \"waves\" ).ToLowerInvariant();\r\n\t\t\tif ( mode != \"continuous\" && mode != \"waves\" && mode != \"burst\" ) mode = \"waves\";\r\n\t\t\tvar modeEnum = mode == \"continuous\" ? \"Continuous\" : ( mode == \"burst\" ? \"Burst\" : \"Waves\" );\r\n\r\n\t\t\tvar count = NpcBrainHelpers.Int( p, \"count\", 5 );\r\n\t\t\tvar interval = NpcBrainHelpers.Float( p, \"interval\", 8f );\r\n\t\t\tvar waveCount = NpcBrainHelpers.Int( p, \"waveCount\", 3 );\r\n\t\t\tvar waveGrowth = NpcBrainHelpers.Float( p, \"waveGrowth\", 1f );\r\n\t\t\tvar radius = NpcBrainHelpers.Float( p, \"radius\", 200f );\r\n\t\t\tvar maxAlive = NpcBrainHelpers.Int( p, \"maxAlive\", 12 );\r\n\t\t\tvar networked = NpcBrainHelpers.Bool( p, \"networked\", true );\r\n\r\n\t\t\tvar code = BuildSpawnerSource( className, modeEnum, networked,\r\n\t\t\t\tcount, interval, waveCount, waveGrowth, radius, maxAlive );\r\n\r\n\t\t\tDirectory.CreateDirectory( Path.GetDirectoryName( fullPath ) );\r\n\t\t\tFile.WriteAllText( fullPath, code );\r\n\r\n\t\t\tvar props = new[]\r\n\t\t\t{\r\n\t\t\t\t\"NpcPrefab\",\"SpawnPoints\",\"Mode\",\"Count\",\"Interval\",\"WaveCount\",\r\n\t\t\t\t\"WaveGrowth\",\"Radius\",\"MaxAlive\",\"AutoStart\"\r\n\t\t\t};\r\n\r\n\t\t\treturn Task.FromResult<object>( new\r\n\t\t\t{\r\n\t\t\t\tcreated = true,\r\n\t\t\t\tpath = $\"{directory}/{fileName}\",\r\n\t\t\t\tclassName,\r\n\t\t\t\tmode,\r\n\t\t\t\tnetworked,\r\n\t\t\t\tpropertyNames = props,\r\n\t\t\t\tnote = \"Set NpcPrefab via set_prefab_ref. Add spawn points by reusing place_patrol_route (a route of empties) then \" +\r\n\t\t\t\t \"assign_patrol_route with property=\\\"SpawnPoints\\\", or set SpawnPoints by hand. \" +\r\n\t\t\t\t ( networked\r\n\t\t\t\t ? \"Networked spawns use NetworkSpawn() and are host-only (guarded) \u2014 needs a host session.\"\r\n\t\t\t\t : \"Solo build: plain Clone() (no NetworkSpawn).\" ) +\r\n\t\t\t\t \" Verify by watching GameObject count over time in play mode (get_scene_hierarchy deltas).\"\r\n\t\t\t} );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"create_npc_spawner failed: {ex.Message}\" } );\r\n\t\t}\r\n\t}\r\n\r\n\tprivate static string BuildSpawnerSource(\r\n\t\tstring className, string modeEnum, bool networked,\r\n\t\tint count, float interval, int waveCount, float waveGrowth, float radius, int maxAlive )\r\n\t{\r\n\t\tstring F( float v ) => NpcBrainHelpers.F( v );\r\n\r\n\t\tvar proxyGuard = networked ? \"\\t\\tif ( IsProxy ) return; // host spawns authoritatively\\n\" : \"\";\r\n\t\tvar headerNote = networked\r\n\t\t\t? \"// Host-authoritative spawner. Only the host spawns (NetworkSpawn so clients see the\\n// NPCs). Needs an active network session.\\n\"\r\n\t\t\t: \"// Solo / edit-scene spawner (plain Clone, no networking).\\n\";\r\n\r\n\t\t// Spawn idiom: clone the prefab, place it, and (networked) NetworkSpawn in a\r\n\t\t// try/catch \u2014 the verified solo-safe idiom (NetworkSpawn throws with no session).\r\n\t\tvar spawnBody = networked\r\n\t\t\t?\r\n@\"\t\tvar go = NpcPrefab.Clone( pos );\r\n\t\ttry { go.NetworkSpawn(); } catch { /* no session \u2014 fall back to a local object */ }\r\n\t\t_alive.Add( go );\"\r\n\t\t\t:\r\n@\"\t\tvar go = NpcPrefab.Clone( pos );\r\n\t\t_alive.Add( go );\";\r\n\r\n\t\treturn\r\n$@\"using Sandbox;\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\n\r\n{headerNote}public sealed class {className} : Component\r\n{{\r\n\tpublic enum SpawnMode {{ Continuous, Waves, Burst }}\r\n\r\n\t[Property] public GameObject NpcPrefab {{ get; set; }}\r\n\t[Property] public List<GameObject> SpawnPoints {{ get; set; }} = new();\r\n\r\n\t[Property] public SpawnMode Mode {{ get; set; }} = SpawnMode.{modeEnum};\r\n\t[Property] public int Count {{ get; set; }} = {count}; // per-wave (Waves) or total (Burst/Continuous batch)\r\n\t[Property] public float Interval {{ get; set; }} = {F( interval )}; // seconds between spawns (Continuous) or waves (Waves)\r\n\t[Property] public int WaveCount {{ get; set; }} = {waveCount};\r\n\t[Property] public float WaveGrowth {{ get; set; }} = {F( waveGrowth )}; // multiply Count each wave (>1 = escalating)\r\n\t[Property] public float Radius {{ get; set; }} = {F( radius )}; // random scatter around a spawn point\r\n\t[Property] public int MaxAlive {{ get; set; }} = {maxAlive}; // concurrency cap\r\n\t[Property] public bool AutoStart {{ get; set; }} = true;\r\n\r\n\tprivate readonly List<GameObject> _alive = new();\r\n\tprivate TimeSince _timeSinceSpawn;\r\n\tprivate int _wavesDone;\r\n\tprivate float _currentWaveCount;\r\n\tprivate bool _started;\r\n\r\n\tprotected override void OnStart()\r\n\t{{\r\n\t\t_currentWaveCount = Count;\r\n\t\t_timeSinceSpawn = Interval; // fire promptly on the first eligible tick\r\n\t\tif ( AutoStart ) _started = true;\r\n\t}}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{{\r\n{proxyGuard}\t\tif ( !_started || NpcPrefab == null ) return;\r\n\r\n\t\t// Drop dead/destroyed NPCs from the live list so MaxAlive is accurate.\r\n\t\t_alive.RemoveAll( g => !g.IsValid() );\r\n\r\n\t\tswitch ( Mode )\r\n\t\t{{\r\n\t\t\tcase SpawnMode.Burst:\r\n\t\t\t\tSpawnBatch( (int)_currentWaveCount );\r\n\t\t\t\t_started = false; // one-shot\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase SpawnMode.Continuous:\r\n\t\t\t\tif ( _timeSinceSpawn >= Interval )\r\n\t\t\t\t{{\r\n\t\t\t\t\t_timeSinceSpawn = 0f;\r\n\t\t\t\t\tTrySpawnOne();\r\n\t\t\t\t}}\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase SpawnMode.Waves:\r\n\t\t\t\tif ( _wavesDone >= WaveCount ) {{ _started = false; break; }}\r\n\t\t\t\tif ( _timeSinceSpawn >= Interval )\r\n\t\t\t\t{{\r\n\t\t\t\t\t_timeSinceSpawn = 0f;\r\n\t\t\t\t\tSpawnBatch( (int)_currentWaveCount );\r\n\t\t\t\t\t_wavesDone++;\r\n\t\t\t\t\t_currentWaveCount = MathX.Clamp( _currentWaveCount * WaveGrowth, 1f, 9999f );\r\n\t\t\t\t}}\r\n\t\t\t\tbreak;\r\n\t\t}}\r\n\t}}\r\n\r\n\tprivate void SpawnBatch( int n )\r\n\t{{\r\n\t\tfor ( int i = 0; i < n; i++ )\r\n\t\t\tif ( !TrySpawnOne() ) break;\r\n\t}}\r\n\r\n\tprivate bool TrySpawnOne()\r\n\t{{\r\n\t\tif ( _alive.Count >= MaxAlive ) return false;\r\n\r\n\t\tvar pos = PickSpawnPos();\r\n{spawnBody}\r\n\t\treturn true;\r\n\t}}\r\n\r\n\tprivate Vector3 PickSpawnPos()\r\n\t{{\r\n\t\tvar basePos = WorldPosition;\r\n\t\tif ( SpawnPoints != null && SpawnPoints.Count > 0 )\r\n\t\t{{\r\n\t\t\tvar pick = SpawnPoints[Random.Shared.Next( 0, SpawnPoints.Count )];\r\n\t\t\tif ( pick.IsValid() ) basePos = pick.WorldPosition;\r\n\t\t}}\r\n\r\n\t\tvar off = new Vector3(\r\n\t\t\tRandom.Shared.Float( -Radius, Radius ),\r\n\t\t\tRandom.Shared.Float( -Radius, Radius ),\r\n\t\t\t0f );\r\n\t\treturn basePos + off;\r\n\t}}\r\n}}\r\n\";\r\n\t}\r\n}\r\n\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n// 5. simulate_npc_perception (READ-ONLY \u2014 NOT scene-mutating)\r\n// Run the EXACT LOS check an NpcBrain would, in edit mode, without play.\r\n// FOV cone (dot vs CosFovThreshold) + range + occlusion trace. Reports the\r\n// result AND why \u2014 the keystone edit-mode verifier for the perception layer.\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\npublic class SimulateNpcPerceptionHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\tvar scene = SceneEditorSession.Active?.Scene;\r\n\t\tif ( scene == null )\r\n\t\t\treturn Task.FromResult<object>( new { error = \"No active scene\" } );\r\n\r\n\t\tif ( !p.TryGetProperty( \"npcId\", out var npcEl ) || !Guid.TryParse( npcEl.GetString(), out var npcGuid ) )\r\n\t\t\treturn Task.FromResult<object>( new { error = \"npcId (GameObject GUID with an NpcBrain) is required\" } );\r\n\r\n\t\tvar npc = scene.Directory.FindByGuid( npcGuid );\r\n\t\tif ( npc == null )\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"NPC GameObject not found: {npcEl.GetString()}\" } );\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\t// \u2500\u2500 Read perception params from the NPC's brain if present, else fall back\r\n\t\t\t// to spec defaults / explicit overrides in the call. Matches the brain by\r\n\t\t\t// CAPABILITY (exposes SightRange+FovDegrees) or a \"...Brain\" type name \u2014 NOT\r\n\t\t\t// just the literal type name \"NpcBrain\" \u2014 so a custom-named brain\r\n\t\t\t// (e.g. BigfootBrain) is read instead of silently using defaults.\r\n\t\t\tvar brain = NpcBrainHelpers.FindPerceptionBrain( npc );\r\n\t\t\t// `var` (never name TypeDescription) \u2014 its namespace isn't guaranteed importable here.\r\n\t\t\tvar brainTd = brain != null ? Game.TypeLibrary.GetType( brain.GetType().Name ) : null;\r\n\r\n\t\t\tfloat ReadBrainFloat( string name, float fallback )\r\n\t\t\t{\r\n\t\t\t\tif ( brain == null || brainTd == null ) return fallback;\r\n\t\t\t\tvar pd = brainTd.Properties.FirstOrDefault( x => x.Name == name );\r\n\t\t\t\tif ( pd == null ) return fallback;\r\n\t\t\t\ttry\r\n\t\t\t\t{\r\n\t\t\t\t\tvar v = pd.GetValue( brain );\r\n\t\t\t\t\tif ( v is float f ) return f;\r\n\t\t\t\t\tif ( v != null && float.TryParse( v.ToString(), out var fp ) ) return fp;\r\n\t\t\t\t}\r\n\t\t\t\tcatch { }\r\n\t\t\t\treturn fallback;\r\n\t\t\t}\r\n\t\t\tstring ReadBrainString( string name, string fallback )\r\n\t\t\t{\r\n\t\t\t\tif ( brain == null || brainTd == null ) return fallback;\r\n\t\t\t\tvar pd = brainTd.Properties.FirstOrDefault( x => x.Name == name );\r\n\t\t\t\ttry { return pd?.GetValue( brain )?.ToString() ?? fallback; } catch { return fallback; }\r\n\t\t\t}\r\n\r\n\t\t\t// Explicit overrides take precedence over brain-read values.\r\n\t\t\tfloat sightRange = NpcBrainHelpers.Float( p, \"sightRange\", ReadBrainFloat( \"SightRange\", 1500f ) );\r\n\t\t\tfloat fovDegrees = NpcBrainHelpers.Float( p, \"fovDegrees\", ReadBrainFloat( \"FovDegrees\", 110f ) );\r\n\t\t\tfloat eyeHeight = NpcBrainHelpers.Float( p, \"eyeHeight\", ReadBrainFloat( \"EyeHeight\", 64f ) );\r\n\t\t\tstring targetTag = NpcBrainHelpers.Str( p, \"targetTag\", ReadBrainString( \"TargetTag\", \"player\" ) );\r\n\r\n\t\t\t// Use the brain's baked CosFovThreshold if available (keeps this query in\r\n\t\t\t// lockstep with the generated component); else compute it here.\r\n\t\t\tfloat cosFov = ReadBrainFloat( \"CosFovThreshold\", float.NaN );\r\n\t\t\tif ( float.IsNaN( cosFov ) ) cosFov = NpcBrainHelpers.CosHalfFov( fovDegrees );\r\n\r\n\t\t\t// \u2500\u2500 Resolve the target point: explicit targetId or a raw point.\r\n\t\t\tGameObject targetGo = null;\r\n\t\t\tVector3 targetPos;\r\n\t\t\tif ( p.TryGetProperty( \"targetId\", out var tEl ) && Guid.TryParse( tEl.GetString(), out var tGuid ) )\r\n\t\t\t{\r\n\t\t\t\ttargetGo = scene.Directory.FindByGuid( tGuid );\r\n\t\t\t\tif ( targetGo == null )\r\n\t\t\t\t\treturn Task.FromResult<object>( new { error = $\"Target GameObject not found: {tEl.GetString()}\" } );\r\n\t\t\t\ttargetPos = targetGo.WorldPosition;\r\n\t\t\t}\r\n\t\t\telse if ( p.TryGetProperty( \"point\", out var ptEl ) )\r\n\t\t\t{\r\n\t\t\t\ttargetPos = ClaudeBridge.ParseVector3( ptEl );\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\treturn Task.FromResult<object>( new { error = \"Provide targetId (GameObject GUID) or point (Vector3)\" } );\r\n\t\t\t}\r\n\r\n\t\t\tvar eye = npc.WorldPosition + Vector3.Up * eyeHeight;\r\n\t\t\tvar to = targetPos - eye;\r\n\t\t\tfloat distance = to.Length;\r\n\r\n\t\t\t// Degenerate: target is essentially at the eye.\r\n\t\t\tif ( distance < 0.01f )\r\n\t\t\t{\r\n\t\t\t\treturn Task.FromResult<object>( new\r\n\t\t\t\t{\r\n\t\t\t\t\tcanSee = true, inRange = true, inFov = true, losBlocked = false,\r\n\t\t\t\t\tdistance, angleDeg = 0.0,\r\n\t\t\t\t\teye = new { eye.x, eye.y, eye.z },\r\n\t\t\t\t\tnote = \"Target coincides with the NPC eye position.\"\r\n\t\t\t\t} );\r\n\t\t\t}\r\n\r\n\t\t\tvar dir = to.Normal;\r\n\t\t\tfloat dot = Vector3.Dot( npc.WorldRotation.Forward, dir );\r\n\r\n\t\t\t// angle (degrees) for human-readable output. MathF is fine here (editor).\r\n\t\t\tfloat angleDeg = MathF.Acos( Math.Clamp( dot, -1f, 1f ) ) * ( 180f / MathF.PI );\r\n\r\n\t\t\tbool inRange = distance <= sightRange;\r\n\t\t\tbool inFov = dot >= cosFov;\r\n\r\n\t\t\t// Occlusion trace from the eye toward the target. IgnoreGameObjectHierarchy\r\n\t\t\t// drops the NPC's own colliders (confirmed builder), so any hit is an\r\n\t\t\t// external object. It blocks LOS only if it's clearly before the target\r\n\t\t\t// (hit on the target itself, or a hit at/after the target distance, is not\r\n\t\t\t// a blocker). Distance test only \u2014 no GameObject.Root needed.\r\n\t\t\tbool losBlocked = false;\r\n\t\t\tobject blockedBy = null;\r\n\t\t\tvar tr = scene.Trace.Ray( eye, targetPos ).IgnoreGameObjectHierarchy( npc ).Run();\r\n\t\t\tif ( tr.Hit )\r\n\t\t\t{\r\n\t\t\t\tbool hitIsTarget = ( targetGo != null && tr.GameObject == targetGo )\r\n\t\t\t\t\t|| tr.Distance >= distance - 8f; // a hit at/after the target point isn't a blocker\r\n\t\t\t\tif ( !hitIsTarget )\r\n\t\t\t\t{\r\n\t\t\t\t\tlosBlocked = true;\r\n\t\t\t\t\tblockedBy = new { id = tr.GameObject?.Id.ToString(), name = tr.GameObject?.Name };\r\n\t\t\t\t}\r\n\t\t\t}\r\n\r\n\t\t\tbool tagMatch = targetGo == null || targetGo.Tags.Has( targetTag );\r\n\t\t\tbool canSee = inRange && inFov && !losBlocked && tagMatch;\r\n\r\n\t\t\treturn Task.FromResult<object>( new\r\n\t\t\t{\r\n\t\t\t\tcanSee,\r\n\t\t\t\tinRange,\r\n\t\t\t\tinFov,\r\n\t\t\t\tlosBlocked,\r\n\t\t\t\tblockedBy,\r\n\t\t\t\ttagMatch,\r\n\t\t\t\tdistance,\r\n\t\t\t\tangleDeg = (double)angleDeg,\r\n\t\t\t\tfovHalfAngleDeg = (double)( fovDegrees * 0.5f ),\r\n\t\t\t\tsightRange,\r\n\t\t\t\ttargetTag,\r\n\t\t\t\teye = new { eye.x, eye.y, eye.z },\r\n\t\t\t\tbrainComponent = brain?.GetType().Name,\r\n\t\t\t\tnote = brain == null\r\n\t\t\t\t\t? \"No perception brain found on this GameObject \u2014 used spec defaults / call overrides for the perception params.\"\r\n\t\t\t\t\t: $\"Read perception params from the '{brain.GetType().Name}' component's own SightRange/FovDegrees/EyeHeight/TargetTag (call params override). canSee mirrors what the generated brain computes.\"\r\n\t\t\t} );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"simulate_npc_perception failed: {ex.Message}\" } );\r\n\t\t}\r\n\t}\r\n}\r\n"
},
{
"Ident": "sboxskinsgg.claudebridge",
"Path": "Editor/PlaytestHandler.cs",
"FileName": "PlaytestHandler.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 335526,
"Code": "using Editor;\r\nusing Sandbox;\r\nusing System;\r\nusing System.Collections;\r\nusing System.Collections.Generic;\r\nusing System.Globalization;\r\nusing System.Linq;\r\nusing System.Text.Json;\r\nusing System.Threading.Tasks;\r\n\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n// PLAYTEST HARNESS \u2014 playtest / playtest_status (the gameplay-verification frontier)\r\n//\r\n// Same assembly as MyEditorMenu.cs (reuses IBridgeHandler + ClaudeBridge helpers).\r\n// Unsandboxed editor code \u2192 System.Math / System.Reflection are fine here.\r\n//\r\n// WHY AN IN-ADDON RUNNER (not TS round-trips):\r\n// Verifying a gameplay LOOP needs input + state-reads + assertions that time-align\r\n// with the game's frames. Two facts (proven live on the Gravehold player) force this:\r\n// 1. The facepunch PlayerController reads Input.AnalogMove each frame and OVERWRITES\r\n// a WishVelocity you set \u2014 UNLESS you set `UseInputControls=false` first. With it\r\n// off, setting WishVelocity moved the player 0\u2192526u. So a move step must flip that\r\n// toggle, drive WishVelocity per frame, and ZERO it after (it persists otherwise).\r\n// 2. Transient state (a jump's z-velocity) is gone by the time a SEPARATE bridge call\r\n// lands \u2014 so assertions must be evaluated IN-FRAME, inside the editor frame loop.\r\n// => one async job, ticked by [EditorEvent.Frame], runs a step list and records a\r\n// pass/fail transcript. TS only starts it (playtest) and polls it (playtest_status).\r\n//\r\n// Step verbs: move \u00b7 look \u00b7 lookDelta \u00b7 action \u00b7 jump \u00b7 set \u00b7 wait \u00b7 capture \u00b7 assert\r\n// { \"move\": {\"x\":1}, \"frames\":60 } analog move (auto UseInputControls=false)\r\n// { \"look\": {\"pitch\":0,\"yaw\":90,\"roll\":0} } set EyeAngles\r\n// { \"lookDelta\": {\"yaw\":2}, \"frames\":30 } sweep EyeAngles\r\n// { \"action\": \"use\", \"frames\":20 } hold a named input action (rising-edge safe)\r\n// { \"jump\": \"0,0,400\" } invoke the controller's Jump(velocity)\r\n// { \"set\": {\"component\":\"PlayerController\",\"property\":\"UseInputControls\",\"to\":\"false\"} }\r\n// { \"wait\": 10 } advance N frames\r\n// { \"capture\": \"after-jump\" } screenshot the live player POV \u2192 path in transcript\r\n// { \"assert\": {\"read\":\"Displacement\",\"op\":\">\",\"value\":50,\"desc\":\"moved >50u from start\"} }\r\n//\r\n// assert.read = \"WorldPosition[.x|.y|.z]\" (the controller's GameObject), \"Displacement\"\r\n// (scalar distance moved from job start \u2014 the facing-independent movement proof), OR\r\n// \"<Component>.<Property>[.x|.y|.z|.Count]\" (a component on the player).\r\n// assert.op = > < >= <= == != changed (changed = differs from the value at job start)\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n\r\ninternal static class PlaytestRunner\r\n{\r\n\tinternal class StepSpec\r\n\t{\r\n\t\tpublic string Kind;\r\n\t\tpublic int Frames = 1;\r\n\t\tpublic Vector2 Move;\r\n\t\tpublic Angles Look; public bool HasLook;\r\n\t\tpublic Angles LookDelta;\r\n\t\tpublic string Action;\r\n\t\tpublic Vector3 JumpVel;\r\n\t\tpublic string SetComponent, SetProperty, SetValue;\r\n\t\tpublic string AssertRead, AssertOp, AssertValue, AssertDesc;\r\n\t\tpublic string CaptureLabel;\r\n\t\tpublic float MoveSpeed = 160f;\r\n\t}\r\n\r\n\tinternal class Job\r\n\t{\r\n\t\tpublic Guid TargetId;\r\n\t\tpublic string ComponentType;\r\n\t\tpublic Component Controller; // resolved once\r\n\t\tpublic GameObject Anchor; // controller.GameObject \u2014 the player object\r\n\t\tpublic Vector3 StartPos; // Anchor.WorldPosition at job start (for the \"Displacement\" read)\r\n\t\tpublic List<StepSpec> Steps;\r\n\t\tpublic int Index;\r\n\t\tpublic int FrameInStep;\r\n\t\tpublic List<object> Transcript = new();\r\n\t\tpublic int Passed, Failed;\r\n\t\tpublic bool DisabledInput; // we flipped UseInputControls=false \u2192 restore at teardown\r\n\t\tpublic string HeldAction; // currently-held action (release at step exit / teardown)\r\n\t\tpublic Dictionary<string, string> Baselines = new(); // read-key \u2192 value at job start (for \"changed\")\r\n\t\tpublic bool Done;\r\n\t\tpublic string EndReason;\r\n\t\tpublic bool Started;\r\n\t}\r\n\r\n\tprivate static Job _job;\r\n\tprivate static readonly object _lock = new();\r\n\tprivate static object _lastSummary;\r\n\r\n\tinternal static void Start( Job job ) { lock ( _lock ) { _job = job; _lastSummary = null; } }\r\n\tinternal static object ConsumeSummary() { lock ( _lock ) { return _lastSummary; } }\r\n\tinternal static bool IsActive() { lock ( _lock ) { return _job != null; } }\r\n\r\n\t/// <summary>Stop the running job NOW: teardown (restore input state) + summarize as aborted.</summary>\r\n\tinternal static object Abort()\r\n\t{\r\n\t\tlock ( _lock )\r\n\t\t{\r\n\t\t\tif ( _job == null )\r\n\t\t\t\treturn new { aborted = false, note = \"No playtest job is running. playtest_status shows the last summary.\" };\r\n\t\t\tvar j = _job;\r\n\t\t\tTeardown( j );\r\n\t\t\t_lastSummary = Summarize( j, \"aborted via playtest_abort\" );\r\n\t\t\t_job = null;\r\n\t\t\treturn new\r\n\t\t\t{\r\n\t\t\t\taborted = true,\r\n\t\t\t\tstepsRun = j.Index,\r\n\t\t\t\tpassed = j.Passed,\r\n\t\t\t\tfailed = j.Failed,\r\n\t\t\t\tnote = \"Job stopped, input state restored. The partial transcript is available via playtest_status.\"\r\n\t\t\t};\r\n\t\t}\r\n\t}\r\n\r\n\tinternal static object LiveSnapshot()\r\n\t{\r\n\t\tlock ( _lock )\r\n\t\t{\r\n\t\t\tif ( _job == null ) return null;\r\n\t\t\treturn new { active = true, step = _job.Index, totalSteps = _job.Steps.Count, passed = _job.Passed, failed = _job.Failed };\r\n\t\t}\r\n\t}\r\n\r\n\t[EditorEvent.Frame]\r\n\tpublic static void OnFrame()\r\n\t{\r\n\t\tJob j;\r\n\t\tlock ( _lock ) { j = _job; }\r\n\t\tif ( j == null ) return;\r\n\r\n\t\tif ( !Game.IsPlaying )\r\n\t\t{\r\n\t\t\tTeardown( j );\r\n\t\t\tlock ( _lock ) { _lastSummary = Summarize( j, \"play mode ended before completion\" ); _job = null; }\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\t// Resolve the controller + anchor once.\r\n\t\t\tif ( !j.Started )\r\n\t\t\t{\r\n\t\t\t\tResolveAnchor( j );\r\n\t\t\t\tCaptureBaselines( j );\r\n\t\t\t\tj.Started = true;\r\n\t\t\t}\r\n\r\n\t\t\tif ( j.Index >= j.Steps.Count )\r\n\t\t\t{\r\n\t\t\t\tTeardown( j );\r\n\t\t\t\tlock ( _lock ) { _lastSummary = Summarize( j, \"completed\" ); _job = null; }\r\n\t\t\t\treturn;\r\n\t\t\t}\r\n\r\n\t\t\tvar step = j.Steps[j.Index];\r\n\t\t\tif ( j.FrameInStep == 0 ) StepEnter( j, step );\r\n\t\t\tStepTick( j, step );\r\n\t\t\tj.FrameInStep++;\r\n\r\n\t\t\tif ( j.FrameInStep >= System.Math.Max( 1, step.Frames ) )\r\n\t\t\t{\r\n\t\t\t\tStepExit( j, step );\r\n\t\t\t\tj.Index++;\r\n\t\t\t\tj.FrameInStep = 0;\r\n\t\t\t}\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\t// Never let the ticker throw (it'd spam every frame). Record + stop.\r\n\t\t\tj.Transcript.Add( new { step = j.Index, kind = j.Index < j.Steps.Count ? j.Steps[j.Index].Kind : \"?\", error = ex.Message } );\r\n\t\t\tTeardown( j );\r\n\t\t\tlock ( _lock ) { _lastSummary = Summarize( j, $\"runner error: {ex.Message}\" ); _job = null; }\r\n\t\t}\r\n\t}\r\n\r\n\t// \u2500\u2500 Step lifecycle \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\tstatic void StepEnter( Job j, StepSpec s )\r\n\t{\r\n\t\tswitch ( s.Kind )\r\n\t\t{\r\n\t\t\tcase \"move\":\r\n\t\t\t\tEnsureInputDisabled( j ); // so WishVelocity isn't overwritten by the controller\r\n\t\t\t\tbreak;\r\n\t\t\tcase \"jump\":\r\n\t\t\t\tDoJump( j, s );\r\n\t\t\t\tbreak;\r\n\t\t\tcase \"set\":\r\n\t\t\t\tDoSet( j, s );\r\n\t\t\t\tbreak;\r\n\t\t\tcase \"assert\":\r\n\t\t\t\tDoAssert( j, s );\r\n\t\t\t\tbreak;\r\n\t\t\tcase \"capture\":\r\n\t\t\t\tDoCapture( j, s );\r\n\t\t\t\tbreak;\r\n\t\t}\r\n\t}\r\n\r\n\tstatic void StepTick( Job j, StepSpec s )\r\n\t{\r\n\t\tswitch ( s.Kind )\r\n\t\t{\r\n\t\t\tcase \"move\":\r\n\t\t\t{\r\n\t\t\t\tif ( j.Controller == null ) return;\r\n\t\t\t\tvar td = Game.TypeLibrary.GetType( j.Controller.GetType() );\r\n\t\t\t\tvar yaw = ( ReadAngles( j.Controller, td, \"EyeAngles\" ) ?? j.Controller.WorldRotation.Angles() ).yaw;\r\n\t\t\t\tvar rot = Rotation.From( 0f, yaw, 0f );\r\n\t\t\t\tvar wish = rot.Forward * s.Move.x + rot.Left * s.Move.y;\r\n\t\t\t\tif ( wish.Length > 1f ) wish = wish.Normal;\r\n\t\t\t\twish *= s.MoveSpeed;\r\n\t\t\t\tTrySetVector3( j.Controller, td, \"WishVelocity\", wish );\r\n\t\t\t\tbreak;\r\n\t\t\t}\r\n\t\t\tcase \"look\":\r\n\t\t\t{\r\n\t\t\t\tif ( j.Controller == null ) return;\r\n\t\t\t\tvar td = Game.TypeLibrary.GetType( j.Controller.GetType() );\r\n\t\t\t\tvar a = s.Look; a.pitch = System.Math.Clamp( a.pitch, -89f, 89f );\r\n\t\t\t\tTrySetAngles( j.Controller, td, \"EyeAngles\", a );\r\n\t\t\t\tbreak;\r\n\t\t\t}\r\n\t\t\tcase \"lookDelta\":\r\n\t\t\t{\r\n\t\t\t\tif ( j.Controller == null ) return;\r\n\t\t\t\tvar td = Game.TypeLibrary.GetType( j.Controller.GetType() );\r\n\t\t\t\tvar cur = ReadAngles( j.Controller, td, \"EyeAngles\" ) ?? new Angles();\r\n\t\t\t\tcur.pitch = System.Math.Clamp( cur.pitch + s.LookDelta.pitch, -89f, 89f );\r\n\t\t\t\tcur.yaw += s.LookDelta.yaw;\r\n\t\t\t\tcur.roll += s.LookDelta.roll;\r\n\t\t\t\tTrySetAngles( j.Controller, td, \"EyeAngles\", cur );\r\n\t\t\t\tbreak;\r\n\t\t\t}\r\n\t\t\tcase \"action\":\r\n\t\t\t\ttry { Sandbox.Input.SetAction( s.Action, true ); } catch { }\r\n\t\t\t\tj.HeldAction = s.Action;\r\n\t\t\t\tbreak;\r\n\t\t}\r\n\t}\r\n\r\n\tstatic void StepExit( Job j, StepSpec s )\r\n\t{\r\n\t\tswitch ( s.Kind )\r\n\t\t{\r\n\t\t\tcase \"move\":\r\n\t\t\t\tif ( j.Controller != null )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar td = Game.TypeLibrary.GetType( j.Controller.GetType() );\r\n\t\t\t\t\tTrySetVector3( j.Controller, td, \"WishVelocity\", Vector3.Zero ); // stop \u2014 WishVelocity persists otherwise\r\n\t\t\t\t}\r\n\t\t\t\tj.Transcript.Add( new { step = j.Index, kind = \"move\", frames = s.Frames, move = $\"{s.Move.x},{s.Move.y}\" } );\r\n\t\t\t\tbreak;\r\n\t\t\tcase \"action\":\r\n\t\t\t\ttry { Sandbox.Input.SetAction( s.Action, false ); } catch { }\r\n\t\t\t\tj.HeldAction = null;\r\n\t\t\t\tj.Transcript.Add( new { step = j.Index, kind = \"action\", action = s.Action, frames = s.Frames } );\r\n\t\t\t\tbreak;\r\n\t\t\tcase \"look\":\r\n\t\t\t\tj.Transcript.Add( new { step = j.Index, kind = \"look\", look = $\"{s.Look.pitch},{s.Look.yaw},{s.Look.roll}\" } );\r\n\t\t\t\tbreak;\r\n\t\t\tcase \"lookDelta\":\r\n\t\t\t\tj.Transcript.Add( new { step = j.Index, kind = \"lookDelta\", frames = s.Frames } );\r\n\t\t\t\tbreak;\r\n\t\t\tcase \"wait\":\r\n\t\t\t\tj.Transcript.Add( new { step = j.Index, kind = \"wait\", frames = s.Frames } );\r\n\t\t\t\tbreak;\r\n\t\t\t// jump/set/assert already recorded their result in StepEnter.\r\n\t\t}\r\n\t}\r\n\r\n\t// \u2500\u2500 Actions \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\tstatic void DoJump( Job j, StepSpec s )\r\n\t{\r\n\t\tif ( j.Controller == null )\r\n\t\t{\r\n\t\t\tj.Transcript.Add( new { step = j.Index, kind = \"jump\", ok = false, error = \"no controller\" } );\r\n\t\t\tj.Failed++;\r\n\t\t\treturn;\r\n\t\t}\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar m = j.Controller.GetType().GetMethod( \"Jump\", new[] { typeof( Vector3 ) } );\r\n\t\t\tif ( m == null )\r\n\t\t\t{\r\n\t\t\t\tj.Transcript.Add( new { step = j.Index, kind = \"jump\", ok = false, error = \"controller has no Jump(Vector3)\" } );\r\n\t\t\t\tj.Failed++;\r\n\t\t\t\treturn;\r\n\t\t\t}\r\n\t\t\tm.Invoke( j.Controller, new object[] { s.JumpVel } );\r\n\t\t\tj.Transcript.Add( new { step = j.Index, kind = \"jump\", ok = true, velocity = $\"{s.JumpVel.x},{s.JumpVel.y},{s.JumpVel.z}\" } );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\tj.Transcript.Add( new { step = j.Index, kind = \"jump\", ok = false, error = ex.Message } );\r\n\t\t\tj.Failed++;\r\n\t\t}\r\n\t}\r\n\r\n\tstatic void DoSet( Job j, StepSpec s )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar comp = FindComponent( j, s.SetComponent );\r\n\t\t\tif ( comp == null )\r\n\t\t\t{\r\n\t\t\t\tj.Transcript.Add( new { step = j.Index, kind = \"set\", ok = false, error = $\"component '{s.SetComponent}' not found\" } );\r\n\t\t\t\tj.Failed++; return;\r\n\t\t\t}\r\n\t\t\tvar td = Game.TypeLibrary.GetType( comp.GetType() );\r\n\t\t\tvar pd = td?.Properties.FirstOrDefault( pp => pp.Name == s.SetProperty );\r\n\t\t\tif ( pd == null )\r\n\t\t\t{\r\n\t\t\t\tj.Transcript.Add( new { step = j.Index, kind = \"set\", ok = false, error = $\"property '{s.SetProperty}' not found\" } );\r\n\t\t\t\tj.Failed++; return;\r\n\t\t\t}\r\n\t\t\tobject typed = CoerceTo( pd.PropertyType, s.SetValue );\r\n\t\t\tpd.SetValue( comp, typed );\r\n\t\t\tj.Transcript.Add( new { step = j.Index, kind = \"set\", ok = true, target = $\"{s.SetComponent}.{s.SetProperty}\", to = s.SetValue } );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\tj.Transcript.Add( new { step = j.Index, kind = \"set\", ok = false, error = ex.Message } );\r\n\t\t\tj.Failed++;\r\n\t\t}\r\n\t}\r\n\r\n\tstatic void DoAssert( Job j, StepSpec s )\r\n\t{\r\n\t\tstring actual = null;\r\n\t\tbool ok = false;\r\n\t\tstring err = null;\r\n\t\ttry\r\n\t\t{\r\n\t\t\tobject val = ResolveRead( j, s.AssertRead, out err );\r\n\t\t\tif ( err == null )\r\n\t\t\t{\r\n\t\t\t\tactual = ValueToString( val );\r\n\t\t\t\tok = Compare( j, s.AssertRead, val, s.AssertOp, s.AssertValue, out err );\r\n\t\t\t}\r\n\t\t}\r\n\t\tcatch ( Exception ex ) { err = ex.Message; }\r\n\r\n\t\tif ( ok ) j.Passed++; else j.Failed++;\r\n\t\tj.Transcript.Add( new\r\n\t\t{\r\n\t\t\tstep = j.Index,\r\n\t\t\tkind = \"assert\",\r\n\t\t\tok,\r\n\t\t\tdesc = s.AssertDesc,\r\n\t\t\tread = s.AssertRead,\r\n\t\t\top = s.AssertOp,\r\n\t\t\texpected = s.AssertValue,\r\n\t\t\tactual,\r\n\t\t\terror = err,\r\n\t\t} );\r\n\t}\r\n\r\n\t// \u2500\u2500 Capture: screenshot the live player-POV camera (diagnostic, never pass/fail) \u2500\u2500\r\n\tstatic void DoCapture( Job j, StepSpec s )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar scene = Game.ActiveScene;\r\n\t\t\tvar cam = scene != null ? VisualHelpers.FindMainCamera( scene ) : null;\r\n\t\t\tif ( cam == null )\r\n\t\t\t{\r\n\t\t\t\tj.Transcript.Add( new { step = j.Index, kind = \"capture\", ok = false, label = s.CaptureLabel, error = \"no main camera in the running scene\" } );\r\n\t\t\t\treturn;\r\n\t\t\t}\r\n\t\t\tusing var bmp = new Bitmap( 1280, 720 );\r\n\t\t\tcam.RenderToBitmap( bmp, true ); // renderUI=true \u2192 the running game incl. HUD\r\n\t\t\tstring path = System.IO.Path.Combine( System.IO.Path.GetTempPath(), $\"bridge_playtest_{System.Guid.NewGuid():N}.png\" );\r\n\t\t\tSystem.IO.File.WriteAllBytes( path, bmp.ToPng() );\r\n\t\t\tj.Transcript.Add( new { step = j.Index, kind = \"capture\", ok = true, label = s.CaptureLabel, path } );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\tj.Transcript.Add( new { step = j.Index, kind = \"capture\", ok = false, label = s.CaptureLabel, error = ex.Message } );\r\n\t\t}\r\n\t}\r\n\r\n\t// \u2500\u2500 Read resolution: \"WorldPosition.x\" | \"<Component>.<Prop>[.sub]\" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\tstatic object ResolveRead( Job j, string read, out string err )\r\n\t{\r\n\t\terr = null;\r\n\t\tif ( string.IsNullOrEmpty( read ) ) { err = \"empty read\"; return null; }\r\n\t\tvar parts = read.Split( '.' );\r\n\t\tobject cur;\r\n\t\tint sub;\r\n\r\n\t\tvar head = parts[0];\r\n\t\tif ( head == \"Displacement\" )\r\n\t\t{\r\n\t\t\tif ( j.Anchor == null ) { err = \"no player object resolved\"; return null; }\r\n\t\t\treturn (object) ( j.Anchor.WorldPosition - j.StartPos ).Length; // scalar \u2014 facing-independent movement proof\r\n\t\t}\r\n\t\tif ( head == \"WorldPosition\" || head == \"LocalPosition\" || head == \"WorldRotation\" || head == \"WorldScale\" )\r\n\t\t{\r\n\t\t\tif ( j.Anchor == null ) { err = \"no player object resolved\"; return null; }\r\n\t\t\tcur = head switch\r\n\t\t\t{\r\n\t\t\t\t\"WorldPosition\" => (object) j.Anchor.WorldPosition,\r\n\t\t\t\t\"LocalPosition\" => j.Anchor.LocalPosition,\r\n\t\t\t\t\"WorldRotation\" => j.Anchor.WorldRotation.Angles(),\r\n\t\t\t\t\"WorldScale\" => j.Anchor.WorldScale,\r\n\t\t\t\t_ => null,\r\n\t\t\t};\r\n\t\t\tsub = 1;\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\tif ( parts.Length < 2 ) { err = $\"read '{read}' needs <Component>.<Property>\"; return null; }\r\n\t\t\tvar comp = FindComponent( j, head );\r\n\t\t\tif ( comp == null ) { err = $\"component '{head}' not found on player\"; return null; }\r\n\t\t\tvar td = Game.TypeLibrary.GetType( comp.GetType() );\r\n\t\t\tvar pd = td?.Properties.FirstOrDefault( pp => pp.Name == parts[1] );\r\n\t\t\tif ( pd == null ) { err = $\"property '{head}.{parts[1]}' not found\"; return null; }\r\n\t\t\tcur = pd.GetValue( comp );\r\n\t\t\tsub = 2;\r\n\t\t}\r\n\r\n\t\tfor ( int i = sub; i < parts.Length && cur != null; i++ )\r\n\t\t\tcur = SubAccess( cur, parts[i] );\r\n\r\n\t\treturn cur;\r\n\t}\r\n\r\n\tstatic object SubAccess( object v, string sub )\r\n\t{\r\n\t\tif ( v is Vector3 v3 ) return sub switch { \"x\" => v3.x, \"y\" => v3.y, \"z\" => v3.z, _ => null };\r\n\t\tif ( v is Vector2 v2 ) return sub switch { \"x\" => v2.x, \"y\" => v2.y, _ => null };\r\n\t\tif ( v is Angles an ) return sub switch { \"pitch\" => an.pitch, \"yaw\" => an.yaw, \"roll\" => an.roll, _ => null };\r\n\t\tif ( sub == \"Count\" )\r\n\t\t{\r\n\t\t\tif ( v is ICollection col ) return col.Count;\r\n\t\t\tif ( v is IEnumerable en ) return en.Cast<object>().Count();\r\n\t\t}\r\n\t\t// generic property fallback\r\n\t\ttry { return v.GetType().GetProperty( sub )?.GetValue( v ); } catch { return null; }\r\n\t}\r\n\r\n\tstatic bool Compare( Job j, string readKey, object actual, string op, string expected, out string err )\r\n\t{\r\n\t\terr = null;\r\n\t\tif ( op == \"changed\" )\r\n\t\t\treturn j.Baselines.TryGetValue( readKey, out var b ) ? ValueToString( actual ) != b : true;\r\n\r\n\t\t// numeric comparison when both sides are numbers\r\n\t\tif ( TryNum( actual, out var an ) && float.TryParse( expected, NumberStyles.Float, CultureInfo.InvariantCulture, out var en ) )\r\n\t\t{\r\n\t\t\treturn op switch\r\n\t\t\t{\r\n\t\t\t\t\">\" => an > en, \"<\" => an < en, \">=\" => an >= en, \"<=\" => an <= en,\r\n\t\t\t\t\"==\" => System.Math.Abs( an - en ) < 0.0001f, \"!=\" => System.Math.Abs( an - en ) >= 0.0001f,\r\n\t\t\t\t_ => SetErr( out err, $\"bad numeric op '{op}'\" ),\r\n\t\t\t};\r\n\t\t}\r\n\r\n\t\t// bool / string equality\r\n\t\tvar astr = ValueToString( actual );\r\n\t\treturn op switch\r\n\t\t{\r\n\t\t\t\"==\" => string.Equals( astr, expected, StringComparison.OrdinalIgnoreCase ),\r\n\t\t\t\"!=\" => !string.Equals( astr, expected, StringComparison.OrdinalIgnoreCase ),\r\n\t\t\t_ => SetErr( out err, $\"op '{op}' needs numeric operands (got '{astr}' vs '{expected}')\" ),\r\n\t\t};\r\n\t}\r\n\r\n\tstatic bool SetErr( out string err, string msg ) { err = msg; return false; }\r\n\r\n\tstatic bool TryNum( object v, out float f )\r\n\t{\r\n\t\tf = 0f;\r\n\t\tswitch ( v )\r\n\t\t{\r\n\t\t\tcase float ff: f = ff; return true;\r\n\t\t\tcase double dd: f = (float) dd; return true;\r\n\t\t\tcase int ii: f = ii; return true;\r\n\t\t\tcase long ll: f = ll; return true;\r\n\t\t\tcase short ss: f = ss; return true;\r\n\t\t\tcase byte bb: f = bb; return true;\r\n\t\t\tdefault: return false;\r\n\t\t}\r\n\t}\r\n\r\n\tstatic string ValueToString( object v )\r\n\t{\r\n\t\tif ( v == null ) return \"null\";\r\n\t\tif ( v is bool b ) return b ? \"True\" : \"False\";\r\n\t\tif ( v is Vector3 v3 ) return $\"{v3.x},{v3.y},{v3.z}\";\r\n\t\tif ( v is float f ) return f.ToString( CultureInfo.InvariantCulture );\r\n\t\treturn v.ToString();\r\n\t}\r\n\r\n\t// \u2500\u2500 Setup / teardown \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\tstatic void ResolveAnchor( Job j )\r\n\t{\r\n\t\tvar scene = Game.ActiveScene;\r\n\t\tif ( scene == null ) return;\r\n\t\tComponent c = null;\r\n\r\n\t\tif ( j.TargetId != Guid.Empty )\r\n\t\t{\r\n\t\t\tvar go = ClaudeBridge.ResolveGameObject( scene, j.TargetId.ToString() );\r\n\t\t\tif ( go != null ) c = FindControllerOn( go, j.ComponentType );\r\n\t\t}\r\n\t\tif ( c == null )\r\n\t\t{\r\n\t\t\tforeach ( var obj in scene.GetAllObjects( true ) )\r\n\t\t\t{\r\n\t\t\t\tc = FindControllerOn( obj, j.ComponentType );\r\n\t\t\t\tif ( c != null ) break;\r\n\t\t\t}\r\n\t\t}\r\n\t\tj.Controller = c;\r\n\t\tj.Anchor = c?.GameObject;\r\n\t}\r\n\r\n\tstatic void CaptureBaselines( Job j )\r\n\t{\r\n\t\t// Anchor position at job start \u2014 the origin for the \"Displacement\" read.\r\n\t\tif ( j.Anchor != null ) j.StartPos = j.Anchor.WorldPosition;\r\n\t\t// Record the initial value of every \"changed\" read so we can diff later.\r\n\t\tforeach ( var s in j.Steps.Where( x => x.Kind == \"assert\" && x.AssertOp == \"changed\" ) )\r\n\t\t{\r\n\t\t\tvar v = ResolveRead( j, s.AssertRead, out var e );\r\n\t\t\tif ( e == null ) j.Baselines[s.AssertRead] = ValueToString( v );\r\n\t\t}\r\n\t}\r\n\r\n\tstatic void EnsureInputDisabled( Job j )\r\n\t{\r\n\t\tif ( j.DisabledInput || j.Controller == null ) return;\r\n\t\tvar td = Game.TypeLibrary.GetType( j.Controller.GetType() );\r\n\t\tvar pd = td?.Properties.FirstOrDefault( pp => pp.Name == \"UseInputControls\" );\r\n\t\tif ( pd != null && pd.PropertyType == typeof( bool ) )\r\n\t\t{\r\n\t\t\tpd.SetValue( j.Controller, false );\r\n\t\t\tj.DisabledInput = true;\r\n\t\t}\r\n\t}\r\n\r\n\tstatic void Teardown( Job j )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tif ( !string.IsNullOrEmpty( j.HeldAction ) )\r\n\t\t\t\ttry { Sandbox.Input.SetAction( j.HeldAction, false ); } catch { }\r\n\r\n\t\t\tif ( j.Controller != null && j.Controller.IsValid() )\r\n\t\t\t{\r\n\t\t\t\tvar td = Game.TypeLibrary.GetType( j.Controller.GetType() );\r\n\t\t\t\tTrySetVector3( j.Controller, td, \"WishVelocity\", Vector3.Zero );\r\n\t\t\t\tif ( j.DisabledInput )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar pd = td?.Properties.FirstOrDefault( pp => pp.Name == \"UseInputControls\" );\r\n\t\t\t\t\tpd?.SetValue( j.Controller, true );\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t\tcatch { }\r\n\t}\r\n\r\n\tstatic object Summarize( Job j, string reason )\r\n\t{\r\n\t\treturn new\r\n\t\t{\r\n\t\t\tfinished = true,\r\n\t\t\treason,\r\n\t\t\tverdict = j.Failed == 0 ? \"PASS\" : \"FAIL\",\r\n\t\t\tpassed = j.Passed,\r\n\t\t\tfailed = j.Failed,\r\n\t\t\tstepsRun = j.Index,\r\n\t\t\ttotalSteps = j.Steps.Count,\r\n\t\t\tcontroller = j.Controller?.GetType().Name,\r\n\t\t\tcontrollerResolved = j.Controller != null,\r\n\t\t\ttranscript = j.Transcript,\r\n\t\t};\r\n\t}\r\n\r\n\t// \u2500\u2500 Reflection helpers (self-contained; mirror PlayInputDriver's idiom) \u2500\u2500\u2500\u2500\u2500\u2500\r\n\tinternal static Component FindControllerOn( GameObject go, string componentType )\r\n\t{\r\n\t\tif ( go == null ) return null;\r\n\t\tvar all = go.Components.GetAll().ToList();\r\n\t\tif ( !string.IsNullOrEmpty( componentType ) )\r\n\t\t\treturn all.FirstOrDefault( c => c.GetType().Name.Equals( componentType, StringComparison.OrdinalIgnoreCase ) );\r\n\t\tvar exact = all.FirstOrDefault( c => c.GetType().Name == \"PlayerController\" );\r\n\t\tif ( exact != null ) return exact;\r\n\t\treturn all.FirstOrDefault( c =>\r\n\t\t{\r\n\t\t\tvar n = c.GetType().Name;\r\n\t\t\tif ( !n.EndsWith( \"Controller\", StringComparison.OrdinalIgnoreCase ) ) return false;\r\n\t\t\tvar td = Game.TypeLibrary.GetType( c.GetType() );\r\n\t\t\treturn td != null && td.Properties.Any( pp => pp.Name == \"EyeAngles\" || pp.Name == \"WishVelocity\" );\r\n\t\t} );\r\n\t}\r\n\r\n\tstatic Component FindComponent( Job j, string typeName )\r\n\t{\r\n\t\tif ( j.Anchor == null || string.IsNullOrEmpty( typeName ) ) return null;\r\n\t\treturn j.Anchor.Components.GetAll().FirstOrDefault( c => c.GetType().Name.Equals( typeName, StringComparison.OrdinalIgnoreCase ) );\r\n\t}\r\n\r\n\tstatic Angles? ReadAngles( Component c, TypeDescription td, string member )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar pd = td?.Properties.FirstOrDefault( pp => pp.Name == member );\r\n\t\t\tif ( pd == null ) return null;\r\n\t\t\tvar v = pd.GetValue( c );\r\n\t\t\tif ( v is Angles a ) return a;\r\n\t\t\tif ( v is Rotation r ) return r.Angles();\r\n\t\t}\r\n\t\tcatch { }\r\n\t\treturn null;\r\n\t}\r\n\r\n\tstatic bool TrySetAngles( Component c, TypeDescription td, string member, Angles value )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar pd = td?.Properties.FirstOrDefault( pp => pp.Name == member );\r\n\t\t\tif ( pd == null ) return false;\r\n\t\t\tif ( pd.PropertyType == typeof( Angles ) ) { pd.SetValue( c, value ); return true; }\r\n\t\t\tif ( pd.PropertyType == typeof( Rotation ) ) { pd.SetValue( c, Rotation.From( value ) ); return true; }\r\n\t\t}\r\n\t\tcatch { }\r\n\t\treturn false;\r\n\t}\r\n\r\n\tstatic bool TrySetVector3( Component c, TypeDescription td, string member, Vector3 value )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar pd = td?.Properties.FirstOrDefault( pp => pp.Name == member );\r\n\t\t\tif ( pd == null || pd.PropertyType != typeof( Vector3 ) ) return false;\r\n\t\t\tpd.SetValue( c, value );\r\n\t\t\treturn true;\r\n\t\t}\r\n\t\tcatch { return false; }\r\n\t}\r\n\r\n\tstatic object CoerceTo( Type t, string raw )\r\n\t{\r\n\t\tif ( t == typeof( bool ) ) return raw == \"true\" || raw == \"True\" || raw == \"1\";\r\n\t\tif ( t == typeof( float ) ) return float.Parse( raw, NumberStyles.Float, CultureInfo.InvariantCulture );\r\n\t\tif ( t == typeof( int ) ) return (int) float.Parse( raw, NumberStyles.Float, CultureInfo.InvariantCulture );\r\n\t\tif ( t == typeof( Vector3 ) ) return ClaudeBridge.ParseVector3Flexible( ParseElement( raw ) );\r\n\t\treturn raw;\r\n\t}\r\n\r\n\tstatic JsonElement ParseElement( string raw )\r\n\t{\r\n\t\t// Wrap a bare \"x,y,z\" or scalar as a JSON string element for ParseVector3Flexible.\r\n\t\tusing var doc = JsonDocument.Parse( JsonSerializer.Serialize( raw ) );\r\n\t\treturn doc.RootElement.Clone();\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// playtest \u2014 run a scripted gameplay-verification sequence in play mode (async, in the\r\n/// editor frame loop) and record a pass/fail transcript. Requires start_play first.\r\n/// </summary>\r\npublic class PlaytestHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\tif ( !Game.IsPlaying )\r\n\t\t\treturn Task.FromResult<object>( new { error = \"playtest requires play mode \u2014 call start_play first\" } );\r\n\t\tif ( PlaytestRunner.IsActive() )\r\n\t\t\treturn Task.FromResult<object>( new { error = \"a playtest is already running \u2014 poll playtest_status until it finishes\" } );\r\n\t\tif ( !p.TryGetProperty( \"steps\", out var stepsEl ) || stepsEl.ValueKind != JsonValueKind.Array )\r\n\t\t\treturn Task.FromResult<object>( new { error = \"steps (an array of step objects) is required\" } );\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar job = new PlaytestRunner.Job { Steps = new List<PlaytestRunner.StepSpec>() };\r\n\r\n\t\t\tif ( p.TryGetProperty( \"id\", out var idEl ) && idEl.ValueKind == JsonValueKind.String\r\n\t\t\t\t && Guid.TryParse( idEl.GetString(), out var gid ) )\r\n\t\t\t\tjob.TargetId = gid;\r\n\t\t\tif ( p.TryGetProperty( \"component\", out var compEl ) && compEl.ValueKind == JsonValueKind.String )\r\n\t\t\t\tjob.ComponentType = compEl.GetString();\r\n\r\n\t\t\tint idx = 0;\r\n\t\t\tforeach ( var stepEl in stepsEl.EnumerateArray() )\r\n\t\t\t{\r\n\t\t\t\tvar spec = ParseStep( stepEl, idx, out var perr );\r\n\t\t\t\tif ( spec == null )\r\n\t\t\t\t\treturn Task.FromResult<object>( new { error = $\"step {idx}: {perr}\" } );\r\n\t\t\t\tjob.Steps.Add( spec );\r\n\t\t\t\tidx++;\r\n\t\t\t}\r\n\t\t\tif ( job.Steps.Count == 0 )\r\n\t\t\t\treturn Task.FromResult<object>( new { error = \"steps is empty\" } );\r\n\r\n\t\t\tPlaytestRunner.Start( job );\r\n\t\t\treturn Task.FromResult<object>( new\r\n\t\t\t{\r\n\t\t\t\tstarted = true,\r\n\t\t\t\tsteps = job.Steps.Count,\r\n\t\t\t\tnote = \"Playtest running ASYNC in the editor frame loop. Poll playtest_status until finished:true, then read the transcript (pass/fail per step).\",\r\n\t\t\t} );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"playtest failed: {ex.Message}\" } );\r\n\t\t}\r\n\t}\r\n\r\n\tstatic PlaytestRunner.StepSpec ParseStep( JsonElement e, int idx, out string err )\r\n\t{\r\n\t\terr = null;\r\n\t\tif ( e.ValueKind != JsonValueKind.Object ) { err = \"not an object\"; return null; }\r\n\t\tvar s = new PlaytestRunner.StepSpec();\r\n\t\tint? framesOverride = ( e.TryGetProperty( \"frames\", out var fEl ) && fEl.TryGetInt32( out var fi ) )\r\n\t\t\t? System.Math.Clamp( fi, 1, 1800 ) : (int?) null;\r\n\t\tif ( e.TryGetProperty( \"moveSpeed\", out var msEl ) && msEl.TryGetSingle( out var ms ) ) s.MoveSpeed = ms;\r\n\r\n\t\tif ( e.TryGetProperty( \"move\", out var mEl ) )\r\n\t\t{\r\n\t\t\ts.Kind = \"move\"; s.Move = ParseMove( mEl ); s.Frames = framesOverride ?? 30;\r\n\t\t}\r\n\t\telse if ( e.TryGetProperty( \"look\", out var lEl ) )\r\n\t\t{\r\n\t\t\ts.Kind = \"look\"; s.Look = ParseAngles( lEl ); s.HasLook = true; s.Frames = framesOverride ?? 1;\r\n\t\t}\r\n\t\telse if ( e.TryGetProperty( \"lookDelta\", out var ldEl ) )\r\n\t\t{\r\n\t\t\ts.Kind = \"lookDelta\"; s.LookDelta = ParseAngles( ldEl ); s.Frames = framesOverride ?? 30;\r\n\t\t}\r\n\t\telse if ( e.TryGetProperty( \"action\", out var aEl ) && aEl.ValueKind == JsonValueKind.String )\r\n\t\t{\r\n\t\t\ts.Kind = \"action\"; s.Action = aEl.GetString(); s.Frames = framesOverride ?? 20;\r\n\t\t}\r\n\t\telse if ( e.TryGetProperty( \"jump\", out var jEl ) )\r\n\t\t{\r\n\t\t\ts.Kind = \"jump\"; s.JumpVel = ClaudeBridge.ParseVector3Flexible( jEl ); s.Frames = 1;\r\n\t\t}\r\n\t\telse if ( e.TryGetProperty( \"set\", out var setEl ) && setEl.ValueKind == JsonValueKind.Object )\r\n\t\t{\r\n\t\t\ts.Kind = \"set\"; s.Frames = 1;\r\n\t\t\ts.SetComponent = GetStr( setEl, \"component\" );\r\n\t\t\ts.SetProperty = GetStr( setEl, \"property\" );\r\n\t\t\ts.SetValue = GetStr( setEl, \"to\" ) ?? GetStr( setEl, \"value\" );\r\n\t\t\tif ( s.SetComponent == null || s.SetProperty == null ) { err = \"set needs {component, property, to}\"; return null; }\r\n\t\t}\r\n\t\telse if ( e.TryGetProperty( \"wait\", out var wEl ) && wEl.TryGetInt32( out var wf ) )\r\n\t\t{\r\n\t\t\ts.Kind = \"wait\"; s.Frames = System.Math.Clamp( wf, 1, 1800 );\r\n\t\t}\r\n\t\telse if ( e.TryGetProperty( \"capture\", out var capEl ) )\r\n\t\t{\r\n\t\t\ts.Kind = \"capture\"; s.Frames = 1;\r\n\t\t\ts.CaptureLabel = capEl.ValueKind == JsonValueKind.String ? capEl.GetString() : null;\r\n\t\t}\r\n\t\telse if ( e.TryGetProperty( \"assert\", out var asEl ) && asEl.ValueKind == JsonValueKind.Object )\r\n\t\t{\r\n\t\t\ts.Kind = \"assert\"; s.Frames = 1;\r\n\t\t\ts.AssertRead = GetStr( asEl, \"read\" );\r\n\t\t\ts.AssertOp = GetStr( asEl, \"op\" ) ?? \"==\";\r\n\t\t\ts.AssertDesc = GetStr( asEl, \"desc\" );\r\n\t\t\tif ( asEl.TryGetProperty( \"value\", out var vEl ) )\r\n\t\t\t\ts.AssertValue = vEl.ValueKind == JsonValueKind.String ? vEl.GetString() : vEl.GetRawText();\r\n\t\t\tif ( s.AssertRead == null ) { err = \"assert needs {read, op, value}\"; return null; }\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\terr = \"unknown step (expected one of: move, look, lookDelta, action, jump, set, wait, capture, assert)\";\r\n\t\t\treturn null;\r\n\t\t}\r\n\t\treturn s;\r\n\t}\r\n\r\n\tstatic string GetStr( JsonElement o, string key )\r\n\t\t=> o.TryGetProperty( key, out var v ) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;\r\n\r\n\tstatic Vector2 ParseMove( JsonElement el )\r\n\t{\r\n\t\tfloat x = 0f, y = 0f;\r\n\t\tif ( el.ValueKind == JsonValueKind.Object )\r\n\t\t{\r\n\t\t\tif ( el.TryGetProperty( \"x\", out var xp ) && xp.TryGetSingle( out var xf ) ) x = xf;\r\n\t\t\tif ( el.TryGetProperty( \"y\", out var yp ) && yp.TryGetSingle( out var yf ) ) y = yf;\r\n\t\t}\r\n\t\telse if ( el.ValueKind == JsonValueKind.String )\r\n\t\t{\r\n\t\t\tvar pr = ( el.GetString() ?? \"\" ).Split( ',' );\r\n\t\t\tif ( pr.Length > 0 ) float.TryParse( pr[0], NumberStyles.Float, CultureInfo.InvariantCulture, out x );\r\n\t\t\tif ( pr.Length > 1 ) float.TryParse( pr[1], NumberStyles.Float, CultureInfo.InvariantCulture, out y );\r\n\t\t}\r\n\t\tvar v = new Vector2( x, y );\r\n\t\tif ( v.Length > 1f ) v = v.Normal;\r\n\t\treturn v;\r\n\t}\r\n\r\n\tstatic Angles ParseAngles( JsonElement el )\r\n\t{\r\n\t\tfloat pitch = 0f, yaw = 0f, roll = 0f;\r\n\t\tif ( el.ValueKind == JsonValueKind.Object )\r\n\t\t{\r\n\t\t\tif ( el.TryGetProperty( \"pitch\", out var pp ) && pp.TryGetSingle( out var pf ) ) pitch = pf;\r\n\t\t\tif ( el.TryGetProperty( \"yaw\", out var yp ) && yp.TryGetSingle( out var yf ) ) yaw = yf;\r\n\t\t\tif ( el.TryGetProperty( \"roll\", out var rp ) && rp.TryGetSingle( out var rf ) ) roll = rf;\r\n\t\t}\r\n\t\telse if ( el.ValueKind == JsonValueKind.String )\r\n\t\t{\r\n\t\t\tvar pr = ( el.GetString() ?? \"\" ).Split( ',' );\r\n\t\t\tif ( pr.Length > 0 ) float.TryParse( pr[0], NumberStyles.Float, CultureInfo.InvariantCulture, out pitch );\r\n\t\t\tif ( pr.Length > 1 ) float.TryParse( pr[1], NumberStyles.Float, CultureInfo.InvariantCulture, out yaw );\r\n\t\t\tif ( pr.Length > 2 ) float.TryParse( pr[2], NumberStyles.Float, CultureInfo.InvariantCulture, out roll );\r\n\t\t}\r\n\t\treturn new Angles( pitch, yaw, roll );\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// playtest_status \u2014 poll the running/finished playtest: live progress while running,\r\n/// or the full pass/fail transcript once finished.\r\n/// </summary>\r\npublic class PlaytestStatusHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\tvar summary = PlaytestRunner.ConsumeSummary();\r\n\t\tif ( summary != null )\r\n\t\t\treturn Task.FromResult<object>( summary );\r\n\r\n\t\tvar live = PlaytestRunner.LiveSnapshot();\r\n\t\tif ( live != null )\r\n\t\t\treturn Task.FromResult<object>( live );\r\n\r\n\t\treturn Task.FromResult<object>( new { active = false, finished = false, note = \"No playtest has run yet.\" } );\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// playtest_abort \u2014 stop the running playtest immediately, restoring input state.\r\n/// The partial transcript stays available via playtest_status.\r\n/// </summary>\r\npublic class PlaytestAbortHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t\t=> Task.FromResult<object>( PlaytestRunner.Abort() );\r\n}\r\n"
},
{
"Ident": "sboxskinsgg.claudebridge",
"Path": "Editor/VehicleHandlers.cs",
"FileName": "VehicleHandlers.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 335526,
"Code": "using Editor;\r\nusing Sandbox;\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing System.Text.Json;\r\nusing System.Threading.Tasks;\r\n\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n// Batch 54 \u2014 bridge_vehicle (v2 wave 4): the corpus vehicles theme.\r\n// create_vehicle_controller \u2014 make any Rigidbody prop drivable (raycast car\r\n// with suspension, engine, steering, grip + built-in driver seat)\r\n// create_seat_system \u2014 standalone generic seat (enter/exit/safe-exit)\r\n// tune_vehicle \u2014 apply arcade/drift/offroad/race presets\r\n// create_physics_grab_tool \u2014 physgun-style spring grab + throw\r\n// Generated code APIs verified live: Rigidbody.ApplyForceAt/GetVelocityAtPoint/\r\n// ApplyTorque/Velocity/Mass (describe_type, 2026-07-09). Driving FEEL needs a\r\n// human playtest \u2014 compiles+runs \u2260 fun (BRIDGE_GOTCHAS #1).\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n\r\n/// <summary>create_vehicle_controller \u2014 scaffold a drivable raycast-car component.</summary>\r\npublic class CreateVehicleControllerHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tif ( !ScaffoldHelpers.PrepareCodeFile( p, \"VehicleController\", out var fullPath, out var relPath, out var className, out var err ) )\r\n\t\t\t\treturn Task.FromResult<object>( err );\r\n\r\n\t\t\tfloat engine = p.TryGetProperty( \"engineForce\", out var ef ) && ef.TryGetSingle( out var eff ) ? eff : 900f;\r\n\t\t\tfloat steer = p.TryGetProperty( \"steerStrength\", out var ss ) && ss.TryGetSingle( out var ssf ) ? ssf : 2.0f;\r\n\t\t\tfloat grip = p.TryGetProperty( \"grip\", out var g ) && g.TryGetSingle( out var gf ) ? gf : 0.85f;\r\n\r\n\t\t\tScaffoldHelpers.WriteCode( fullPath, BuildCode( className, engine, steer, grip ) );\r\n\r\n\t\t\treturn Task.FromResult<object>( new\r\n\t\t\t{\r\n\t\t\t\tcreated = true,\r\n\t\t\t\tpath = relPath,\r\n\t\t\t\tclassName,\r\n\t\t\t\tnextSteps = new[]\r\n\t\t\t\t{\r\n\t\t\t\t\t\"trigger_hotload, then check compile_status\",\r\n\t\t\t\t\t$\"Attach {className} + a Rigidbody + a collider to your vehicle prop (batch_add_component works)\",\r\n\t\t\t\t\t\"Enter play mode and press E on the vehicle to drive (WASD; E again to exit)\",\r\n\t\t\t\t\t\"tune_vehicle applies arcade/drift/offroad/race presets to the attached component\",\r\n\t\t\t\t\t\"HUMAN PLAYTEST REQUIRED for feel \u2014 tune EngineForce/SteerStrength/GripFactor from the inspector while playing\"\r\n\t\t\t\t}\r\n\t\t\t} );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"create_vehicle_controller failed: {ex.Message}\" } );\r\n\t\t}\r\n\t}\r\n\r\n\tstatic string BuildCode( string className, float engine, float steer, float grip )\r\n\t{\r\n\t\tvar ci = System.Globalization.CultureInfo.InvariantCulture;\r\n\t\treturn $@\"using Sandbox;\r\nusing System;\r\n\r\n/// <summary>\r\n/// {className} \u2014 makes a Rigidbody prop drivable: a 4-corner raycast car with\r\n/// spring/damper suspension, engine force, yaw steering, and lateral grip\r\n/// (lower grip = drift). Built-in driver seat: press E (use) to enter \u2014 the\r\n/// driver is hidden while driving (no controller transform fights), the host\r\n/// assigns them vehicle ownership, and a chase camera follows \u2014 E to exit.\r\n/// Requires a Rigidbody + collider on the same GameObject. Tune from the\r\n/// inspector while playing; tune_vehicle applies ready-made presets.\r\n/// </summary>\r\npublic sealed class {className} : Component, Component.IPressable\r\n{{\r\n\t[Property, Group( \"\"Engine\"\" )] public float EngineForce {{ get; set; }} = {engine.ToString( ci )}f;\r\n\t[Property, Group( \"\"Engine\"\" )] public float MaxSpeed {{ get; set; }} = 800f;\r\n\t[Property, Group( \"\"Steering\"\" )] public float SteerStrength {{ get; set; }} = {steer.ToString( ci )}f; // yaw rate, rad/s at full speed factor\r\n\t[Property, Group( \"\"Handling\"\" ), Range( 0f, 1f )] public float GripFactor {{ get; set; }} = {grip.ToString( ci )}f;\r\n\t[Property, Group( \"\"Suspension\"\" )] public float SuspensionRest {{ get; set; }} = 24f;\r\n\t[Property, Group( \"\"Suspension\"\" )] public float SuspensionStrength {{ get; set; }} = 90f;\r\n\t[Property, Group( \"\"Suspension\"\" )] public float SuspensionDamping {{ get; set; }} = 8f;\r\n\t[Property, Group( \"\"Seat\"\" )] public Vector3 ExitOffset {{ get; set; }} = new( 0, 80, 20 );\r\n\t[Property, Group( \"\"Camera\"\" )] public float CameraDistance {{ get; set; }} = 260f;\r\n\t[Property, Group( \"\"Camera\"\" )] public float CameraHeight {{ get; set; }} = 110f;\r\n\r\n\t[Sync] public Guid DriverId {{ get; set; }}\r\n\r\n\tpublic bool HasDriver => DriverId != Guid.Empty;\r\n\tpublic static event Action<GameObject, bool> OnDriverChanged; // (vehicle, entered)\r\n\r\n\tRigidbody _rb;\r\n\tVector3[] _corners;\r\n\tTimeSince _sinceEnter;\r\n\r\n\tprotected override void OnStart()\r\n\t{{\r\n\t\t_rb = GetComponent<Rigidbody>();\r\n\t\tif ( _rb == null )\r\n\t\t{{\r\n\t\t\tLog.Warning( $\"\"{className} needs a Rigidbody on {{GameObject.Name}}\"\" );\r\n\t\t\tEnabled = false;\r\n\t\t\treturn;\r\n\t\t}}\r\n\t\tvar bounds = GameObject.GetBounds();\r\n\t\tvar ext = ( bounds.Size * 0.4f ).WithZ( 0 );\r\n\t\t_corners = new[]\r\n\t\t{{\r\n\t\t\tnew Vector3( ext.x, ext.y, 0 ), new Vector3( ext.x, -ext.y, 0 ),\r\n\t\t\tnew Vector3( -ext.x, ext.y, 0 ), new Vector3( -ext.x, -ext.y, 0 ),\r\n\t\t}};\r\n\t}}\r\n\r\n\t// \u2500\u2500 Seat (IPressable) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\tpublic bool Press( Component.IPressable.Event e )\r\n\t{{\r\n\t\tvar presser = e.Source?.GameObject;\r\n\t\tif ( presser == null ) return false;\r\n\t\tRequestSeat( presser.Id );\r\n\t\treturn true;\r\n\t}}\r\n\r\n\t[Rpc.Host]\r\n\tvoid RequestSeat( Guid pressGuid )\r\n\t{{\r\n\t\tvar presser = Scene.Directory.FindByGuid( pressGuid );\r\n\t\tif ( presser == null ) return;\r\n\t\tif ( HasDriver && DriverId != pressGuid ) return;\r\n\r\n\t\tif ( DriverId == pressGuid )\r\n\t\t{{\r\n\t\t\tExit( presser );\r\n\t\t\treturn;\r\n\t\t}}\r\n\r\n\t\t// Enter: hide the player entirely while driving \u2014 parenting a live\r\n\t\t// PlayerController to a moving vehicle makes two systems fight over the\r\n\t\t// transform (the classic seat jitter). Hidden driver + chase camera instead.\r\n\t\tDriverId = pressGuid;\r\n\t\t_sinceEnter = 0;\r\n\t\tvar owner = presser.Network.Owner;\r\n\t\tif ( owner != null ) GameObject.Network.AssignOwnership( owner );\r\n\t\tpresser.Enabled = false;\r\n\t\tOnDriverChanged?.Invoke( GameObject, true );\r\n\t}}\r\n\r\n\tvoid Exit( GameObject driver )\r\n\t{{\r\n\t\tDriverId = Guid.Empty;\r\n\t\tif ( driver != null )\r\n\t\t{{\r\n\t\t\tdriver.WorldPosition = WorldPosition + WorldRotation * ExitOffset;\r\n\t\t\tdriver.Enabled = true; // their controller re-takes the camera next frame\r\n\t\t}}\r\n\t\tGameObject.Network.DropOwnership();\r\n\t\tOnDriverChanged?.Invoke( GameObject, false );\r\n\t}}\r\n\r\n\t// Chase camera while driving (runs on the driver's client \u2014 they own the vehicle).\r\n\tprotected override void OnPreRender()\r\n\t{{\r\n\t\tif ( IsProxy || !HasDriver ) return;\r\n\t\tvar cam = Scene.Camera;\r\n\t\tif ( cam == null ) return;\r\n\r\n\t\tvar targetPos = WorldPosition - WorldRotation.Forward.WithZ( 0 ).Normal * CameraDistance + Vector3.Up * CameraHeight;\r\n\t\tcam.WorldPosition = cam.WorldPosition.LerpTo( targetPos, MathX.Clamp( Time.Delta * 6f, 0f, 1f ) );\r\n\t\tcam.WorldRotation = Rotation.LookAt( ( WorldPosition + Vector3.Up * 30f - cam.WorldPosition ).Normal, Vector3.Up );\r\n\t}}\r\n\r\n\t// \u2500\u2500 Driving (vehicle owner only) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\tprotected override void OnFixedUpdate()\r\n\t{{\r\n\t\tif ( _rb == null || IsProxy || !HasDriver ) return;\r\n\r\n\t\t// E again to exit (edge-guarded so the entering press cannot instantly exit).\r\n\t\tif ( _sinceEnter > 0.4f && Input.Pressed( \"\"use\"\" ) )\r\n\t\t{{\r\n\t\t\tRequestSeat( DriverId );\r\n\t\t\treturn;\r\n\t\t}}\r\n\r\n\t\tvar dt = Time.Delta;\r\n\t\tvar input = Input.AnalogMove; // x = forward/back, y = left/right\r\n\t\tint grounded = 0;\r\n\r\n\t\t// Suspension: 4 corner rays, spring + damper applied at each corner.\r\n\t\tforeach ( var corner in _corners )\r\n\t\t{{\r\n\t\t\tvar worldCorner = WorldPosition + WorldRotation * corner;\r\n\t\t\tvar tr = Scene.Trace.Ray( worldCorner, worldCorner + Vector3.Down * SuspensionRest * 2f )\r\n\t\t\t\t.IgnoreGameObjectHierarchy( GameObject )\r\n\t\t\t\t.Run();\r\n\t\t\tif ( !tr.Hit ) continue;\r\n\t\t\tgrounded++;\r\n\t\t\tvar compression = 1f - ( tr.Distance / ( SuspensionRest * 2f ) );\r\n\t\t\tvar pointVel = _rb.GetVelocityAtPoint( worldCorner );\r\n\t\t\tvar force = Vector3.Up * ( compression * SuspensionStrength - pointVel.z * SuspensionDamping ) * _rb.Mass * dt * 50f;\r\n\t\t\t_rb.ApplyForceAt( worldCorner, force );\r\n\t\t}}\r\n\r\n\t\tif ( grounded == 0 ) return; // airborne \u2014 no engine/steer/grip\r\n\r\n\t\tvar forward = WorldRotation.Forward.WithZ( 0 ).Normal;\r\n\t\tvar speed = _rb.Velocity.WithZ( 0 ).Length;\r\n\r\n\t\t// Engine (mass-scaled so feel survives different props).\r\n\t\tif ( MathF.Abs( input.x ) > 0.01f && speed < MaxSpeed )\r\n\t\t\t_rb.ApplyForce( forward * input.x * EngineForce * _rb.Mass );\r\n\r\n\t\t// Steering: set yaw angular velocity directly \u2014 arcade-reliable, immune to the\r\n\t\t// prop's moment of inertia (torque was far too weak on heavy boxes \u2014 playtested).\r\n\t\tvar steerFactor = MathX.Clamp( speed / 150f, 0.25f, 1f );\r\n\t\tvar direction = _rb.Velocity.Dot( forward ) < -10f ? -1f : 1f; // reverse steers mirrored\r\n\t\tvar yawRate = MathF.Abs( input.y ) > 0.01f\r\n\t\t\t? input.y * SteerStrength * steerFactor * direction\r\n\t\t\t: 0f;\r\n\t\t_rb.AngularVelocity = _rb.AngularVelocity.WithZ( MathX.Lerp( _rb.AngularVelocity.z, yawRate, MathX.Clamp( dt * 12f, 0f, 1f ) ) );\r\n\r\n\t\t// Lateral grip: kill a fraction of sideways velocity each tick. Low grip = drift.\r\n\t\tvar right = WorldRotation.Right.WithZ( 0 ).Normal;\r\n\t\tvar lateral = right * _rb.Velocity.Dot( right );\r\n\t\t_rb.Velocity -= lateral * GripFactor * MathX.Clamp( dt * 10f, 0f, 1f );\r\n\t}}\r\n}}\r\n\";\r\n\t}\r\n}\r\n\r\n/// <summary>create_seat_system \u2014 scaffold a standalone enter/exit seat component.</summary>\r\npublic class CreateSeatSystemHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tif ( !ScaffoldHelpers.PrepareCodeFile( p, \"Seat\", out var fullPath, out var relPath, out var className, out var err ) )\r\n\t\t\t\treturn Task.FromResult<object>( err );\r\n\r\n\t\t\tScaffoldHelpers.WriteCode( fullPath, BuildCode( className ) );\r\n\r\n\t\t\treturn Task.FromResult<object>( new\r\n\t\t\t{\r\n\t\t\t\tcreated = true,\r\n\t\t\t\tpath = relPath,\r\n\t\t\t\tclassName,\r\n\t\t\t\tnextSteps = new[]\r\n\t\t\t\t{\r\n\t\t\t\t\t\"trigger_hotload, then check compile_status\",\r\n\t\t\t\t\t$\"Attach {className} to any prop (chair, bench, turret mount) \u2014 press E to sit, E to stand\",\r\n\t\t\t\t\t\"SeatOffset positions the occupant; exit tries ExitOffsets in order and takes the first clear spot\",\r\n\t\t\t\t\t$\"Subscribe to {className}.OnOccupantChanged for camera/UI logic\"\r\n\t\t\t\t}\r\n\t\t\t} );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"create_seat_system failed: {ex.Message}\" } );\r\n\t\t}\r\n\t}\r\n\r\n\tstatic string BuildCode( string className )\r\n\t{\r\n\t\treturn $@\"using Sandbox;\r\nusing System;\r\nusing System.Linq;\r\n\r\n/// <summary>\r\n/// {className} \u2014 a networked one-occupant seat: press E (use) to sit, E again\r\n/// to stand. Claims route through the host so two players can't share a seat;\r\n/// the occupant is parented to the seat with their controller input disabled\r\n/// (UseInputControls=false, restored on exit). Exit tries each ExitOffsets\r\n/// entry and takes the first spot with clearance. Works for chairs, benches,\r\n/// turret mounts \u2014 anything sittable.\r\n/// </summary>\r\npublic sealed class {className} : Component, Component.IPressable\r\n{{\r\n\t[Property] public Vector3 SeatOffset {{ get; set; }} = new( 0, 0, 10 );\r\n\t[Property] public System.Collections.Generic.List<Vector3> ExitOffsets {{ get; set; }} = new()\r\n\t\t{{ new( 0, 60, 10 ), new( 0, -60, 10 ), new( 60, 0, 10 ), new( -60, 0, 10 ) }};\r\n\r\n\t[Sync] public Guid OccupantId {{ get; set; }}\r\n\r\n\tpublic bool IsOccupied => OccupantId != Guid.Empty;\r\n\tpublic static event Action<GameObject, GameObject, bool> OnOccupantChanged; // (seat, occupant, seated)\r\n\r\n\tpublic bool Press( Component.IPressable.Event e )\r\n\t{{\r\n\t\tvar presser = e.Source?.GameObject;\r\n\t\tif ( presser == null ) return false;\r\n\t\tRequestSeat( presser.Id );\r\n\t\treturn true;\r\n\t}}\r\n\r\n\t[Rpc.Host]\r\n\tvoid RequestSeat( Guid pressGuid )\r\n\t{{\r\n\t\tvar presser = Scene.Directory.FindByGuid( pressGuid );\r\n\t\tif ( presser == null ) return;\r\n\r\n\t\tif ( OccupantId == pressGuid )\r\n\t\t{{\r\n\t\t\tSetControls( presser, true );\r\n\t\t\tpresser.SetParent( null, true );\r\n\t\t\tpresser.WorldPosition = FindExitSpot( presser );\r\n\t\t\tOccupantId = Guid.Empty;\r\n\t\t\tOnOccupantChanged?.Invoke( GameObject, presser, false );\r\n\t\t\treturn;\r\n\t\t}}\r\n\t\tif ( IsOccupied ) return;\r\n\r\n\t\tOccupantId = pressGuid;\r\n\t\tpresser.SetParent( GameObject, true );\r\n\t\tpresser.LocalPosition = SeatOffset;\r\n\t\tSetControls( presser, false );\r\n\t\tOnOccupantChanged?.Invoke( GameObject, presser, true );\r\n\t}}\r\n\r\n\tVector3 FindExitSpot( GameObject occupant )\r\n\t{{\r\n\t\tforeach ( var offset in ExitOffsets )\r\n\t\t{{\r\n\t\t\tvar spot = WorldPosition + WorldRotation * offset;\r\n\t\t\tvar tr = Scene.Trace.Ray( spot + Vector3.Up * 32f, spot )\r\n\t\t\t\t.IgnoreGameObjectHierarchy( GameObject )\r\n\t\t\t\t.IgnoreGameObjectHierarchy( occupant )\r\n\t\t\t\t.Run();\r\n\t\t\tif ( !tr.Hit ) return spot;\r\n\t\t}}\r\n\t\treturn WorldPosition + Vector3.Up * 48f; // all blocked \u2014 pop up top\r\n\t}}\r\n\r\n\tstatic void SetControls( GameObject occupant, bool enabled )\r\n\t{{\r\n\t\tforeach ( var comp in occupant.Components.GetAll() )\r\n\t\t{{\r\n\t\t\tif ( comp is null ) continue;\r\n\t\t\tvar type = Game.TypeLibrary?.GetType( comp.GetType() );\r\n\t\t\tvar prop = type?.Properties?.FirstOrDefault( pr => pr.Name == \"\"UseInputControls\"\" );\r\n\t\t\tprop?.SetValue( comp, enabled );\r\n\t\t}}\r\n\t}}\r\n}}\r\n\";\r\n\t}\r\n}\r\n\r\n/// <summary>tune_vehicle \u2014 apply a handling preset to a vehicle controller component.</summary>\r\npublic class TuneVehicleHandler : IBridgeHandler\r\n{\r\n\tstatic readonly Dictionary<string, Dictionary<string, float>> Presets = new( StringComparer.OrdinalIgnoreCase )\r\n\t{\r\n\t\t[\"arcade\"] = new() { [\"EngineForce\"] = 900f, [\"MaxSpeed\"] = 800f, [\"SteerStrength\"] = 2.0f, [\"GripFactor\"] = 0.85f, [\"SuspensionStrength\"] = 90f, [\"SuspensionDamping\"] = 8f },\r\n\t\t[\"drift\"] = new() { [\"EngineForce\"] = 1100f, [\"MaxSpeed\"] = 900f, [\"SteerStrength\"] = 2.8f, [\"GripFactor\"] = 0.35f, [\"SuspensionStrength\"] = 80f, [\"SuspensionDamping\"] = 6f },\r\n\t\t[\"offroad\"] = new() { [\"EngineForce\"] = 750f, [\"MaxSpeed\"] = 600f, [\"SteerStrength\"] = 1.5f, [\"GripFactor\"] = 0.7f, [\"SuspensionStrength\"] = 130f, [\"SuspensionDamping\"] = 12f },\r\n\t\t[\"race\"] = new() { [\"EngineForce\"] = 1400f, [\"MaxSpeed\"] = 1400f, [\"SteerStrength\"] = 1.7f, [\"GripFactor\"] = 0.95f, [\"SuspensionStrength\"] = 110f, [\"SuspensionDamping\"] = 10f },\r\n\t};\r\n\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\tvar scene = SceneEditorSession.Active?.Scene;\r\n\t\tif ( scene == null )\r\n\t\t\treturn Task.FromResult<object>( new { error = \"No active scene\" } );\r\n\r\n\t\tvar id = p.TryGetProperty( \"id\", out var idEl ) ? idEl.GetString() : null;\r\n\t\tvar go = ClaudeBridge.ResolveGameObject( scene, id );\r\n\t\tif ( go == null )\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"GameObject not found: {id}\" } );\r\n\r\n\t\tvar presetName = p.TryGetProperty( \"preset\", out var pr ) ? pr.GetString() : null;\r\n\t\tif ( presetName == null || !Presets.TryGetValue( presetName, out var preset ) )\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"preset must be one of: {string.Join( \" | \", Presets.Keys )}\" } );\r\n\r\n\t\tvar compName = p.TryGetProperty( \"component\", out var cn ) ? cn.GetString() : null;\r\n\t\tvar component = go.Components.GetAll().FirstOrDefault( c => c != null &&\r\n\t\t\t( compName != null\r\n\t\t\t\t? c.GetType().Name.Equals( compName, StringComparison.OrdinalIgnoreCase )\r\n\t\t\t\t: c.GetType().Name.Contains( \"Vehicle\", StringComparison.OrdinalIgnoreCase ) ) );\r\n\t\tif ( component == null )\r\n\t\t\treturn Task.FromResult<object>( new { error = compName != null\r\n\t\t\t\t? $\"No '{compName}' component on the object\"\r\n\t\t\t\t: \"No component with 'Vehicle' in its type name found \u2014 pass component explicitly\" } );\r\n\r\n\t\tvar typeDesc = Game.TypeLibrary.GetType( component.GetType().Name );\r\n\t\tvar applied = new List<object>();\r\n\t\tvar missing = new List<string>();\r\n\t\tforeach ( var (propName, value) in preset )\r\n\t\t{\r\n\t\t\tvar propDesc = typeDesc?.Properties.FirstOrDefault( pp => pp.Name == propName );\r\n\t\t\tif ( propDesc == null ) { missing.Add( propName ); continue; }\r\n\t\t\ttry\r\n\t\t\t{\r\n\t\t\t\tpropDesc.SetValue( component, value );\r\n\t\t\t\tapplied.Add( new { property = propName, value } );\r\n\t\t\t}\r\n\t\t\tcatch ( Exception ex ) { missing.Add( $\"{propName} ({ex.Message})\" ); }\r\n\t\t}\r\n\r\n\t\treturn Task.FromResult<object>( new\r\n\t\t{\r\n\t\t\ttuned = applied.Count > 0,\r\n\t\t\tpreset = presetName.ToLowerInvariant(),\r\n\t\t\tcomponent = component.GetType().Name,\r\n\t\t\tapplied,\r\n\t\t\tmissing,\r\n\t\t\tnote = missing.Count > 0\r\n\t\t\t\t? \"Some preset properties don't exist on this component \u2014 presets target create_vehicle_controller scaffolds; others tune partially.\"\r\n\t\t\t\t: \"Preset applied. Enter play mode and drive to feel it; fine-tune the same properties with set_property.\"\r\n\t\t} );\r\n\t}\r\n}\r\n\r\n/// <summary>create_physics_grab_tool \u2014 scaffold a physgun-style spring grab + throw.</summary>\r\npublic class CreatePhysicsGrabToolHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tif ( !ScaffoldHelpers.PrepareCodeFile( p, \"PhysicsGrabTool\", out var fullPath, out var relPath, out var className, out var err ) )\r\n\t\t\t\treturn Task.FromResult<object>( err );\r\n\r\n\t\t\tScaffoldHelpers.WriteCode( fullPath, BuildCode( className ) );\r\n\r\n\t\t\treturn Task.FromResult<object>( new\r\n\t\t\t{\r\n\t\t\t\tcreated = true,\r\n\t\t\t\tpath = relPath,\r\n\t\t\t\tclassName,\r\n\t\t\t\tnextSteps = new[]\r\n\t\t\t\t{\r\n\t\t\t\t\t\"trigger_hotload, then check compile_status\",\r\n\t\t\t\t\t$\"Attach {className} to the player object (needs a camera child or PlayerController for aim)\",\r\n\t\t\t\t\t\"Hold attack2 (right mouse) on a Rigidbody prop to grab; scroll-free: it follows at grab distance; attack1 throws\",\r\n\t\t\t\t\t\"ensure_input_action if your project lacks attack1/attack2 bindings\"\r\n\t\t\t\t}\r\n\t\t\t} );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"create_physics_grab_tool failed: {ex.Message}\" } );\r\n\t\t}\r\n\t}\r\n\r\n\tstatic string BuildCode( string className )\r\n\t{\r\n\t\treturn $@\"using Sandbox;\r\nusing System;\r\nusing System.Linq;\r\n\r\n/// <summary>\r\n/// {className} \u2014 a physgun-lite for the player: hold GrabAction (default\r\n/// attack2) while looking at a Rigidbody prop to grab it; it spring-follows a\r\n/// point in front of your view (physics stays LIVE \u2014 it collides and swings,\r\n/// unlike a parented carry); press ThrowAction (default attack1) to launch it.\r\n/// Grab requests route through the host, which assigns the grabber network\r\n/// ownership of the prop. Owner-only logic; attach to the player object.\r\n/// </summary>\r\npublic sealed class {className} : Component\r\n{{\r\n\t[Property] public float Range {{ get; set; }} = 300f;\r\n\t[Property] public float SpringStrength {{ get; set; }} = 12f;\r\n\t[Property] public float ThrowForce {{ get; set; }} = 600f;\r\n\t[Property] public float MaxMass {{ get; set; }} = 2000f;\r\n\t[Property] public string GrabAction {{ get; set; }} = \"\"attack2\"\";\r\n\t[Property] public string ThrowAction {{ get; set; }} = \"\"attack1\"\";\r\n\r\n\tGameObject _held;\r\n\tfloat _holdDistance;\r\n\r\n\tpublic bool IsHolding => _held.IsValid();\r\n\tpublic static event Action<GameObject, GameObject, bool> OnGrabChanged; // (player, prop, grabbed)\r\n\r\n\tprotected override void OnFixedUpdate()\r\n\t{{\r\n\t\tif ( IsProxy ) return;\r\n\r\n\t\tvar eye = GetEye( out var dir );\r\n\r\n\t\tif ( IsHolding && Input.Pressed( ThrowAction ) )\r\n\t\t{{\r\n\t\t\tvar rb = _held.GetComponent<Rigidbody>();\r\n\t\t\trb?.ApplyImpulse( dir * ThrowForce * ( rb.Mass ) );\r\n\t\t\tRelease();\r\n\t\t\treturn;\r\n\t\t}}\r\n\r\n\t\tif ( Input.Down( GrabAction ) )\r\n\t\t{{\r\n\t\t\tif ( !IsHolding ) TryGrab( eye, dir );\r\n\t\t\telse Hold( eye, dir );\r\n\t\t}}\r\n\t\telse if ( IsHolding )\r\n\t\t{{\r\n\t\t\tRelease();\r\n\t\t}}\r\n\t}}\r\n\r\n\tVector3 GetEye( out Vector3 dir )\r\n\t{{\r\n\t\tvar cam = Scene.Camera;\r\n\t\tif ( cam != null )\r\n\t\t{{\r\n\t\t\tdir = cam.WorldRotation.Forward;\r\n\t\t\treturn cam.WorldPosition;\r\n\t\t}}\r\n\t\tdir = WorldRotation.Forward;\r\n\t\treturn WorldPosition + Vector3.Up * 64f;\r\n\t}}\r\n\r\n\tvoid TryGrab( Vector3 eye, Vector3 dir )\r\n\t{{\r\n\t\tvar tr = Scene.Trace.Ray( eye, eye + dir * Range )\r\n\t\t\t.IgnoreGameObjectHierarchy( GameObject )\r\n\t\t\t.Run();\r\n\t\tif ( !tr.Hit || tr.GameObject == null ) return;\r\n\r\n\t\tvar rb = tr.GameObject.GetComponent<Rigidbody>();\r\n\t\tif ( rb == null || rb.Mass > MaxMass ) return;\r\n\r\n\t\t_held = tr.GameObject;\r\n\t\t_holdDistance = MathX.Clamp( tr.Distance, 60f, Range );\r\n\t\tRequestGrabOwnership( _held.Id );\r\n\t\tOnGrabChanged?.Invoke( GameObject, _held, true );\r\n\t}}\r\n\r\n\tvoid Hold( Vector3 eye, Vector3 dir )\r\n\t{{\r\n\t\tif ( !_held.IsValid() ) {{ _held = null; return; }}\r\n\t\tvar rb = _held.GetComponent<Rigidbody>();\r\n\t\tif ( rb == null ) {{ Release(); return; }}\r\n\r\n\t\tvar target = eye + dir * _holdDistance;\r\n\t\t// Velocity-set spring: stiff, stable, still collides with the world.\r\n\t\trb.Velocity = ( target - _held.WorldPosition ) * SpringStrength;\r\n\t\trb.AngularVelocity = rb.AngularVelocity.LerpTo( Vector3.Zero, Time.Delta * 5f );\r\n\t}}\r\n\r\n\tvoid Release()\r\n\t{{\r\n\t\tif ( _held.IsValid() )\r\n\t\t\tOnGrabChanged?.Invoke( GameObject, _held, false );\r\n\t\t_held = null;\r\n\t}}\r\n\r\n\t[Rpc.Host]\r\n\tvoid RequestGrabOwnership( Guid propId )\r\n\t{{\r\n\t\tvar prop = Scene.Directory.FindByGuid( propId );\r\n\t\tvar caller = Rpc.Caller;\r\n\t\tif ( prop == null || caller is null ) return;\r\n\t\tprop.Network.AssignOwnership( caller );\r\n\t}}\r\n}}\r\n\";\r\n\t}\r\n}\r\n"
},
{
"Ident": "sboxskinsgg.claudebridge",
"Path": "Editor/GameFeelHandlers.cs",
"FileName": "GameFeelHandlers.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 335526,
"Code": "using Editor;\r\nusing Sandbox;\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.IO;\r\nusing System.Linq;\r\nusing System.Text;\r\nusing System.Text.Json;\r\nusing System.Threading;\r\nusing System.Threading.Tasks;\r\n\r\n// =============================================================================\r\n// Game Feel pack (v1.19.0) -- three \"juice\" scaffolds (code-gen; scene-mutating):\r\n//\r\n// create_camera_shake trauma-based Perlin camera shake component\r\n// add_flicker_light flicker/pulse animator for an existing light\r\n// create_floating_combat_text rising/fading world-space damage popups\r\n//\r\n// Compiles into the SAME editor assembly as MyEditorMenu.cs / ScaffoldHandlers.cs,\r\n// so it reuses the shared statics on ClaudeBridge (TryResolveProjectPath,\r\n// SanitizeIdentifier, SerializeGo) and ScaffoldHelpers (PrepareCodeFile /\r\n// WriteCode / Utf8NoBom). Handler code here is UNSANDBOXED editor code.\r\n//\r\n// The C# *strings these handlers WRITE TO DISK* are SANDBOXED game code and must\r\n// obey the s&box sandbox rules:\r\n// - MathX preferred; System.Math/MathF also compile on the current SDK.\r\n// Array.Clone() is still whitelist-blocked (not used here).\r\n// - only sandbox-proven APIs: Component, [Property], List<T>, TimeSince,\r\n// Game.Random.Float (compile-verified in create_weighted_loot_table),\r\n// Sandbox.Utility.Noise.Perlin (fully qualified to dodge a using),\r\n// new GameObject(...) for runtime spawns.\r\n// - all three generated components are LOCAL/visual-only -- no [Sync], no\r\n// RPCs. Multiplayer note lands in the nextSteps (wrap the calls in an\r\n// [Rpc.Broadcast] so every client sees the juice).\r\n//\r\n// Register(...) lines + the _sceneMutatingCommands additions live in\r\n// MyEditorMenu.cs (Batch 44) to keep the files decoupled.\r\n// =============================================================================\r\n\r\n// -----------------------------------------------------------------------------\r\n// create_camera_shake -- trauma-based camera shake (the corpus-standard model:\r\n// shake magnitude = Trauma^2, Perlin-driven offsets, decays over time).\r\n//\r\n// Applied in OnPreRender AFTER controllers have positioned the camera. The\r\n// un-apply guard (compare against what we last WROTE) makes it correct on both\r\n// a static camera (no accumulation) and a controller-driven one (no fighting).\r\n// -----------------------------------------------------------------------------\r\npublic class CreateCameraShakeHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar ci = System.Globalization.CultureInfo.InvariantCulture;\r\n\r\n\t\t\tif ( !ScaffoldHelpers.PrepareCodeFile( p, \"CameraShake\", out var fullPath, out var relPath, out var className, out var err ) )\r\n\t\t\t\treturn Task.FromResult<object>( err );\r\n\r\n\t\t\tfloat maxOffset = p.TryGetProperty( \"maxOffset\", out var ov ) && ov.TryGetSingle( out var of ) ? of : 6f;\r\n\t\t\tfloat maxAngle = p.TryGetProperty( \"maxAngle\", out var av ) && av.TryGetSingle( out var af ) ? af : 4f;\r\n\t\t\tfloat frequency = p.TryGetProperty( \"frequency\", out var fv ) && fv.TryGetSingle( out var ff ) ? ff : 10f;\r\n\t\t\tfloat decay = p.TryGetProperty( \"decayPerSecond\", out var dv ) && dv.TryGetSingle( out var df ) ? df : 1.5f;\r\n\r\n\t\t\t// Defensive clamps so a silly value can't emit a nauseating component.\r\n\t\t\tif ( maxOffset < 0f ) maxOffset = 0f;\r\n\t\t\tif ( maxAngle < 0f ) maxAngle = 0f;\r\n\t\t\tif ( frequency < 0.1f ) frequency = 0.1f;\r\n\t\t\tif ( decay < 0.05f ) decay = 0.05f;\r\n\r\n\t\t\tvar code = BuildCode( className, maxOffset, maxAngle, frequency, decay, ci );\r\n\t\t\tScaffoldHelpers.WriteCode( fullPath, code );\r\n\r\n\t\t\tobject placedOn = null; string note = null;\r\n\t\t\tif ( p.TryGetProperty( \"targetId\", out var tid ) && tid.ValueKind == JsonValueKind.String )\r\n\t\t\t\tplacedOn = GameFeelHelpers.PlaceOnTarget( tid.GetString(), className, out note );\r\n\r\n\t\t\treturn Task.FromResult<object>( new\r\n\t\t\t{\r\n\t\t\t\tcreated = true,\r\n\t\t\t\tpath = relPath,\r\n\t\t\t\tclassName,\r\n\t\t\t\tmaxOffset,\r\n\t\t\t\tmaxAngle,\r\n\t\t\t\tfrequency,\r\n\t\t\t\tdecayPerSecond = decay,\r\n\t\t\t\tplacedOn,\r\n\t\t\t\tnote,\r\n\t\t\t\tnextSteps = new[]\r\n\t\t\t\t{\r\n\t\t\t\t\t$\"trigger_hotload to compile {className} into the game assembly.\",\r\n\t\t\t\t\tplacedOn != null\r\n\t\t\t\t\t\t? $\"{className} was attached to the target GameObject.\"\r\n\t\t\t\t\t\t: $\"Attach it to the CAMERA GameObject: add_component_with_properties (component=\\\"{className}\\\") after the hotload, or re-run with targetId.\",\r\n\t\t\t\t\t$\"Fire a shake from any game code: {className}.Shake( 0.4f ) -- explosions ~0.6-1.0, hits ~0.2-0.4, footsteps ~0.05. Trauma stacks and clamps at 1.\",\r\n\t\t\t\t\t\"LOCAL-only: call it inside an [Rpc.Broadcast] handler if every client should feel the shake.\",\r\n\t\t\t\t\t\"Tune MaxOffset / MaxAngle / Frequency / DecayPerSecond with set_property, then verify in play mode: playtest with a capture step, or set_runtime_property Trauma=1 and take_screenshot.\"\r\n\t\t\t\t}\r\n\t\t\t} );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"create_camera_shake failed: {ex.Message}\" } );\r\n\t\t}\r\n\t}\r\n\r\n\tstatic string BuildCode( string className, float maxOffset, float maxAngle, float frequency, float decay, System.Globalization.CultureInfo ci )\r\n\t{\r\n\t\tstring mo = maxOffset.ToString( ci ) + \"f\";\r\n\t\tstring ma = maxAngle.ToString( ci ) + \"f\";\r\n\t\tstring fq = frequency.ToString( ci ) + \"f\";\r\n\t\tstring dc = decay.ToString( ci ) + \"f\";\r\n\r\n\t\treturn $@\"using Sandbox;\r\nusing System;\r\nusing System.Collections.Generic;\r\n\r\n/// <summary>\r\n/// {className} -- trauma-based camera shake. Attach to the camera GameObject.\r\n///\r\n/// The standard game-feel model: an event adds Trauma (0..1), shake magnitude\r\n/// is Trauma^2 (small hits barely register, big hits slam), offsets are smooth\r\n/// Perlin noise (not white-noise jitter), and Trauma decays every frame.\r\n///\r\n/// Usage from anywhere: {className}.Shake( 0.5f );\r\n/// LOCAL-only -- wrap the call in an [Rpc.Broadcast] if all clients should shake.\r\n/// </summary>\r\npublic sealed class {className} : Component\r\n{{\r\n\t/// Current shake energy, 0..1. Add via Shake(); decays by DecayPerSecond.\r\n\t[Property] public float Trauma {{ get; set; }}\r\n\r\n\t/// Positional shake at full trauma, in world units.\r\n\t[Property] public float MaxOffset {{ get; set; }} = {mo};\r\n\r\n\t/// Rotational shake at full trauma, in degrees (pitch/yaw/roll).\r\n\t[Property] public float MaxAngle {{ get; set; }} = {ma};\r\n\r\n\t/// Noise speed -- higher = more violent rattle, lower = drunken sway.\r\n\t[Property] public float Frequency {{ get; set; }} = {fq};\r\n\r\n\t/// How much trauma drains per second.\r\n\t[Property] public float DecayPerSecond {{ get; set; }} = {dc};\r\n\r\n\tprivate static readonly List<{className}> _active = new List<{className}>();\r\n\r\n\tprivate Vector3 _lastWrittenPos;\r\n\tprivate Rotation _lastWrittenRot;\r\n\tprivate Vector3 _appliedOffset;\r\n\tprivate Rotation _appliedRot = Rotation.Identity;\r\n\tprivate bool _hasApplied;\r\n\r\n\t/// <summary>Add trauma to every active {className} (usually the one on the local camera).</summary>\r\n\tpublic static void Shake( float trauma )\r\n\t{{\r\n\t\tforeach ( var s in _active )\r\n\t\t\ts.Trauma = MathX.Clamp( s.Trauma + trauma, 0f, 1f );\r\n\t}}\r\n\r\n\tprotected override void OnEnabled()\r\n\t{{\r\n\t\t_active.Add( this );\r\n\t}}\r\n\r\n\tprotected override void OnDisabled()\r\n\t{{\r\n\t\t_active.Remove( this );\r\n\t\tRemoveAppliedShake();\r\n\t}}\r\n\r\n\tprotected override void OnPreRender()\r\n\t{{\r\n\t\tvar go = GameObject;\r\n\r\n\t\t// Recover the unshaken base. If a controller re-wrote the camera since our\r\n\t\t// last write, ITS value is the new base and our old offset is already gone --\r\n\t\t// only un-apply when the transform still equals exactly what we wrote.\r\n\t\tvar basePos = go.WorldPosition;\r\n\t\tvar baseRot = go.WorldRotation;\r\n\t\tif ( _hasApplied && basePos == _lastWrittenPos ) basePos -= _appliedOffset;\r\n\t\tif ( _hasApplied && baseRot == _lastWrittenRot ) baseRot = baseRot * _appliedRot.Inverse;\r\n\t\t_hasApplied = false;\r\n\r\n\t\tTrauma = MathX.Clamp( Trauma - DecayPerSecond * Time.Delta, 0f, 1f );\r\n\t\tfloat shake = Trauma * Trauma;\r\n\r\n\t\tif ( shake < 0.0005f )\r\n\t\t{{\r\n\t\t\tgo.WorldPosition = basePos;\r\n\t\t\tgo.WorldRotation = baseRot;\r\n\t\t\treturn;\r\n\t\t}}\r\n\r\n\t\t// Smooth signed noise per axis (-1..1), decorrelated by row offset.\r\n\t\tfloat t = Time.Now * Frequency;\r\n\t\tfloat N( float row ) => (Sandbox.Utility.Noise.Perlin( t, row ) - 0.5f) * 2f;\r\n\r\n\t\t_appliedOffset = new Vector3( N( 0f ), N( 17f ), N( 31f ) ) * (MaxOffset * shake);\r\n\t\t_appliedRot = Rotation.From( N( 47f ) * MaxAngle * shake, N( 61f ) * MaxAngle * shake, N( 83f ) * MaxAngle * shake );\r\n\r\n\t\tgo.WorldPosition = basePos + _appliedOffset;\r\n\t\tgo.WorldRotation = baseRot * _appliedRot;\r\n\t\t_lastWrittenPos = go.WorldPosition;\r\n\t\t_lastWrittenRot = go.WorldRotation;\r\n\t\t_hasApplied = true;\r\n\t}}\r\n\r\n\tprivate void RemoveAppliedShake()\r\n\t{{\r\n\t\tif ( !_hasApplied ) return;\r\n\t\tvar go = GameObject;\r\n\t\tif ( go.WorldPosition == _lastWrittenPos ) go.WorldPosition -= _appliedOffset;\r\n\t\tif ( go.WorldRotation == _lastWrittenRot ) go.WorldRotation = go.WorldRotation * _appliedRot.Inverse;\r\n\t\t_hasApplied = false;\r\n\t}}\r\n}}\r\n\";\r\n\t}\r\n}\r\n\r\n// -----------------------------------------------------------------------------\r\n// add_flicker_light -- generate a light-flicker animator and (optionally) attach\r\n// it to an existing light GameObject. Presets: Candle, Fluorescent, Faulty,\r\n// Pulse, Lightning. Modulates Light.LightColor around a captured base color;\r\n// restores the base on disable.\r\n// -----------------------------------------------------------------------------\r\npublic class AddFlickerLightHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar ci = System.Globalization.CultureInfo.InvariantCulture;\r\n\r\n\t\t\tif ( !ScaffoldHelpers.PrepareCodeFile( p, \"FlickerLight\", out var fullPath, out var relPath, out var className, out var err ) )\r\n\t\t\t\treturn Task.FromResult<object>( err );\r\n\r\n\t\t\tvar style = p.TryGetProperty( \"style\", out var sv ) && !string.IsNullOrWhiteSpace( sv.GetString() )\r\n\t\t\t\t? sv.GetString() : \"Candle\";\r\n\t\t\t// Validate against the generated enum so a typo can't emit uncompilable code.\r\n\t\t\tvar validStyles = new[] { \"Candle\", \"Fluorescent\", \"Faulty\", \"Pulse\", \"Lightning\" };\r\n\t\t\tvar matched = validStyles.FirstOrDefault( s => s.Equals( style, StringComparison.OrdinalIgnoreCase ) );\r\n\t\t\tif ( matched == null )\r\n\t\t\t\treturn Task.FromResult<object>( new { error = $\"Unknown style '{style}'. Valid: {string.Join( \", \", validStyles )}\" } );\r\n\t\t\tstyle = matched;\r\n\r\n\t\t\tfloat intensity = p.TryGetProperty( \"intensity\", out var iv ) && iv.TryGetSingle( out var iff ) ? iff : 0.5f;\r\n\t\t\tfloat speed = p.TryGetProperty( \"speed\", out var spv ) && spv.TryGetSingle( out var spf ) ? spf : 1f;\r\n\t\t\tintensity = intensity < 0f ? 0f : intensity > 1f ? 1f : intensity;\r\n\t\t\tif ( speed < 0.05f ) speed = 0.05f;\r\n\r\n\t\t\tvar code = BuildCode( className, style, intensity, speed, ci );\r\n\t\t\tScaffoldHelpers.WriteCode( fullPath, code );\r\n\r\n\t\t\tobject placedOn = null; string note = null;\r\n\t\t\t// `lightId` is the ergonomic param name; `targetId` also accepted (sibling convention).\r\n\t\t\tstring target = null;\r\n\t\t\tif ( p.TryGetProperty( \"lightId\", out var lid ) && lid.ValueKind == JsonValueKind.String ) target = lid.GetString();\r\n\t\t\telse if ( p.TryGetProperty( \"targetId\", out var tid ) && tid.ValueKind == JsonValueKind.String ) target = tid.GetString();\r\n\t\t\tif ( target != null )\r\n\t\t\t\tplacedOn = GameFeelHelpers.PlaceOnTarget( target, className, out note );\r\n\r\n\t\t\treturn Task.FromResult<object>( new\r\n\t\t\t{\r\n\t\t\t\tcreated = true,\r\n\t\t\t\tpath = relPath,\r\n\t\t\t\tclassName,\r\n\t\t\t\tstyle,\r\n\t\t\t\tintensity,\r\n\t\t\t\tspeed,\r\n\t\t\t\tplacedOn,\r\n\t\t\t\tnote,\r\n\t\t\t\tnextSteps = new[]\r\n\t\t\t\t{\r\n\t\t\t\t\t$\"trigger_hotload to compile {className} into the game assembly.\",\r\n\t\t\t\t\tplacedOn != null\r\n\t\t\t\t\t\t? $\"{className} was attached to the target GameObject.\"\r\n\t\t\t\t\t\t: $\"Attach it to a GameObject that has a light component (PointLight / SpotLight / DirectionalLight): add_component_with_properties (component=\\\"{className}\\\") after the hotload, or re-run with lightId.\",\r\n\t\t\t\t\t\"The animator modulates the light's LightColor around its starting color and restores it on disable -- tune Style / Intensity / Speed with set_property.\",\r\n\t\t\t\t\t\"Verify in play mode: start_play, then take_screenshot twice ~a second apart and compare the light's brightness (or capture_view for a scene-only frame).\"\r\n\t\t\t\t}\r\n\t\t\t} );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"add_flicker_light failed: {ex.Message}\" } );\r\n\t\t}\r\n\t}\r\n\r\n\tstatic string BuildCode( string className, string style, float intensity, float speed, System.Globalization.CultureInfo ci )\r\n\t{\r\n\t\tstring it = intensity.ToString( ci ) + \"f\";\r\n\t\tstring sp = speed.ToString( ci ) + \"f\";\r\n\r\n\t\treturn $@\"using Sandbox;\r\nusing System;\r\nusing System.Collections.Generic;\r\n\r\n/// <summary>\r\n/// {className} -- flickers the light on this GameObject. Attach next to a\r\n/// PointLight / SpotLight / DirectionalLight; it modulates LightColor around\r\n/// the color it found on enable and restores it on disable.\r\n///\r\n/// Styles: Candle (soft organic sway), Fluorescent (mostly steady, random\r\n/// dips), Faulty (hard on/off cuts), Pulse (slow sine breathing), Lightning\r\n/// (dim baseline, rare bright flashes).\r\n/// </summary>\r\npublic sealed class {className} : Component\r\n{{\r\n\tpublic enum FlickerStyle {{ Candle, Fluorescent, Faulty, Pulse, Lightning }}\r\n\r\n\t[Property] public FlickerStyle Style {{ get; set; }} = FlickerStyle.{style};\r\n\r\n\t/// Flicker depth: 0 = steady, 1 = full blackouts / double-bright flashes.\r\n\t[Property] public float Intensity {{ get; set; }} = {it};\r\n\r\n\t/// Speed multiplier for the whole pattern.\r\n\t[Property] public float Speed {{ get; set; }} = {sp};\r\n\r\n\tprivate Light _light;\r\n\tprivate Color _baseColor;\r\n\tprivate float _seed;\r\n\tprivate float _mult = 1f;\r\n\r\n\tprotected override void OnEnabled()\r\n\t{{\r\n\t\t_light = GetComponent<Light>();\r\n\t\tif ( _light == null )\r\n\t\t{{\r\n\t\t\tLog.Warning( $\"\"{className}: no Light component on {{GameObject.Name}} -- disabling.\"\" );\r\n\t\t\tEnabled = false;\r\n\t\t\treturn;\r\n\t\t}}\r\n\t\t_baseColor = _light.LightColor;\r\n\t\t_seed = Game.Random.Float( 0f, 512f );\r\n\t}}\r\n\r\n\tprotected override void OnDisabled()\r\n\t{{\r\n\t\tif ( _light != null ) _light.LightColor = _baseColor;\r\n\t}}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{{\r\n\t\tif ( _light == null ) return;\r\n\r\n\t\tfloat t = (Time.Now + _seed) * Speed;\r\n\t\tfloat n = Sandbox.Utility.Noise.Perlin( t * 6f, _seed ); // smooth 0..1\r\n\r\n\t\tfloat target = Style switch\r\n\t\t{{\r\n\t\t\tFlickerStyle.Candle => MathX.Lerp( 1f - Intensity * 0.6f, 1f, n ),\r\n\t\t\tFlickerStyle.Fluorescent => n > 0.75f ? 1f - Intensity : 1f,\r\n\t\t\tFlickerStyle.Faulty => Sandbox.Utility.Noise.Perlin( t * 14f, _seed ) > 0.55f ? 1f : 1f - Intensity,\r\n\t\t\tFlickerStyle.Pulse => MathX.Lerp( 1f - Intensity, 1f, 0.5f + 0.5f * MathF.Sin( t * 4f ) ),\r\n\t\t\tFlickerStyle.Lightning => n > 0.92f ? 1f + Intensity * 2f : 1f - Intensity * 0.85f,\r\n\t\t\t_ => 1f\r\n\t\t}};\r\n\r\n\t\t// Smooth toward the target so hard styles read as a light, not strobe noise.\r\n\t\t_mult = MathX.Lerp( _mult, target, MathX.Clamp( Time.Delta * 24f, 0f, 1f ) );\r\n\t\t_light.LightColor = _baseColor * _mult;\r\n\t}}\r\n}}\r\n\";\r\n\t}\r\n}\r\n\r\n// -----------------------------------------------------------------------------\r\n// create_floating_combat_text -- rising/fading world-space text popups\r\n// (damage numbers, \"+10 gold\", pickup names). TextRenderer-based -- no Razor,\r\n// no WorldPanel, works with zero UI setup. The generated class IS the popup\r\n// behavior and carries a static Spawn() factory; nothing to place in the scene.\r\n// -----------------------------------------------------------------------------\r\npublic class CreateFloatingCombatTextHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar ci = System.Globalization.CultureInfo.InvariantCulture;\r\n\r\n\t\t\tif ( !ScaffoldHelpers.PrepareCodeFile( p, \"FloatingCombatText\", out var fullPath, out var relPath, out var className, out var err ) )\r\n\t\t\t\treturn Task.FromResult<object>( err );\r\n\r\n\t\t\tfloat riseSpeed = p.TryGetProperty( \"riseSpeed\", out var rv ) && rv.TryGetSingle( out var rf ) ? rf : 48f;\r\n\t\t\tfloat lifetime = p.TryGetProperty( \"lifetime\", out var lv ) && lv.TryGetSingle( out var lf ) ? lf : 1.1f;\r\n\t\t\tfloat fontSize = p.TryGetProperty( \"fontSize\", out var fv ) && fv.TryGetSingle( out var ff ) ? ff : 24f;\r\n\t\t\tif ( riseSpeed < 0f ) riseSpeed = 0f;\r\n\t\t\tif ( lifetime < 0.1f ) lifetime = 0.1f;\r\n\t\t\tif ( fontSize < 1f ) fontSize = 1f;\r\n\r\n\t\t\tvar code = BuildCode( className, riseSpeed, lifetime, fontSize, ci );\r\n\t\t\tScaffoldHelpers.WriteCode( fullPath, code );\r\n\r\n\t\t\treturn Task.FromResult<object>( new\r\n\t\t\t{\r\n\t\t\t\tcreated = true,\r\n\t\t\t\tpath = relPath,\r\n\t\t\t\tclassName,\r\n\t\t\t\triseSpeed,\r\n\t\t\t\tlifetime,\r\n\t\t\t\tfontSize,\r\n\t\t\t\tnextSteps = new[]\r\n\t\t\t\t{\r\n\t\t\t\t\t$\"trigger_hotload to compile {className} into the game assembly.\",\r\n\t\t\t\t\t$\"Nothing to place -- spawn popups from any game code: {className}.Spawn( hitPosition + Vector3.Up * 32f, \\\"-25\\\", Color.Red ) (optional 4th arg scales the text).\",\r\n\t\t\t\t\t$\"Pairs with create_health_system: call {className}.Spawn from the damage path so every hit prints its number.\",\r\n\t\t\t\t\t\"LOCAL-only: spawn inside an [Rpc.Broadcast] handler if every client should see the popup.\",\r\n\t\t\t\t\t\"Verify in play mode: execute a spawn (e.g. via invoke_method on a test component), then take_screenshot -- the text rises and fades over Lifetime seconds.\"\r\n\t\t\t\t}\r\n\t\t\t} );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"create_floating_combat_text failed: {ex.Message}\" } );\r\n\t\t}\r\n\t}\r\n\r\n\tstatic string BuildCode( string className, float riseSpeed, float lifetime, float fontSize, System.Globalization.CultureInfo ci )\r\n\t{\r\n\t\tstring rs = riseSpeed.ToString( ci ) + \"f\";\r\n\t\tstring lt = lifetime.ToString( ci ) + \"f\";\r\n\t\tstring fs = fontSize.ToString( ci ) + \"f\";\r\n\r\n\t\treturn $@\"using Sandbox;\r\nusing System;\r\nusing System.Collections.Generic;\r\n\r\n/// <summary>\r\n/// {className} -- a rising, fading world-space text popup (damage numbers,\r\n/// \"\"+10 gold\"\", pickup names). TextRenderer-based: no Razor, no panels.\r\n///\r\n/// Spawn from anywhere:\r\n/// {className}.Spawn( position, \"\"-25\"\", Color.Red );\r\n/// {className}.Spawn( position, \"\"+10 gold\"\", Color.Yellow, 1.5f );\r\n///\r\n/// The popup billboards to the camera, rises, fades out, and destroys itself.\r\n/// LOCAL-only -- spawn inside an [Rpc.Broadcast] if all clients should see it.\r\n/// </summary>\r\npublic sealed class {className} : Component\r\n{{\r\n\t/// World units risen per second.\r\n\t[Property] public float RiseSpeed {{ get; set; }} = {rs};\r\n\r\n\t/// Seconds until fully faded and destroyed.\r\n\t[Property] public float Lifetime {{ get; set; }} = {lt};\r\n\r\n\tprivate TextRenderer _text;\r\n\tprivate Color _startColor;\r\n\tprivate TimeSince _age;\r\n\r\n\t/// <summary>Spawn a popup at a world position. Returns the popup GameObject.</summary>\r\n\tpublic static GameObject Spawn( Vector3 position, string text, Color color, float size = 1f )\r\n\t{{\r\n\t\tvar go = new GameObject( true, \"\"FloatingText\"\" );\r\n\t\tgo.WorldPosition = position;\r\n\r\n\t\tvar tr = go.AddComponent<TextRenderer>();\r\n\t\ttr.Text = text;\r\n\t\ttr.Color = color;\r\n\t\ttr.FontSize = {fs} * size;\r\n\r\n\t\tgo.AddComponent<{className}>();\r\n\t\treturn go;\r\n\t}}\r\n\r\n\tprotected override void OnStart()\r\n\t{{\r\n\t\t_age = 0f;\r\n\t\t_text = GetComponent<TextRenderer>();\r\n\t\tif ( _text != null ) _startColor = _text.Color;\r\n\t}}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{{\r\n\t\tvar go = GameObject;\r\n\t\tgo.WorldPosition += Vector3.Up * (RiseSpeed * Time.Delta);\r\n\r\n\t\t// Billboard: face the same way the camera faces, mirrored toward it.\r\n\t\tvar cam = Scene?.Camera;\r\n\t\tif ( cam != null )\r\n\t\t\tgo.WorldRotation = Rotation.LookAt( -cam.WorldRotation.Forward );\r\n\r\n\t\tif ( _text != null )\r\n\t\t\t_text.Color = _startColor.WithAlpha( _startColor.a * MathX.Clamp( 1f - _age / Lifetime, 0f, 1f ) );\r\n\r\n\t\tif ( _age >= Lifetime )\r\n\t\t\tgo.Destroy();\r\n\t}}\r\n}}\r\n\";\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// Shared placement helper for the game-feel handlers -- mirrors the standard\r\n/// scaffold placement (create_weighted_loot_table / create_event_director).\r\n/// </summary>\r\ninternal static class GameFeelHelpers\r\n{\r\n\tpublic static object PlaceOnTarget( string targetId, string className, out string note )\r\n\t{\r\n\t\tnote = null;\r\n\t\tvar scene = SceneEditorSession.Active?.Scene;\r\n\t\tif ( scene == null ) { note = \"No active scene to place into.\"; return null; }\r\n\t\tif ( !Guid.TryParse( targetId, out var guid ) ) { note = \"Invalid targetId GUID.\"; return null; }\r\n\t\tvar go = scene.Directory.FindByGuid( guid );\r\n\t\tif ( go == null ) { note = $\"Target GameObject not found: {targetId}\"; return null; }\r\n\t\tvar typeDesc = Game.TypeLibrary.GetType( className );\r\n\t\tif ( typeDesc == null )\r\n\t\t{\r\n\t\t\tnote = $\"Generated {className}.cs but it is not in the TypeLibrary yet -- trigger_hotload, then add it with add_component_with_properties.\";\r\n\t\t\treturn null;\r\n\t\t}\r\n\t\ttry { go.Components.Create( typeDesc ); return ClaudeBridge.SerializeGo( go ); }\r\n\t\tcatch ( Exception ex ) { note = $\"Placement failed ({ex.Message}).\"; return null; }\r\n\t}\r\n}\r\n"
},
{
"Ident": "sboxskinsgg.claudebridge",
"Path": "Editor/Mcp/BridgeGameObjectTools.cs",
"FileName": "BridgeGameObjectTools.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 335526,
"Code": "// AUTO-GENERATED by scripts/emit-mcp-wrappers.mjs \u2014 DO NOT EDIT.\r\n// Regenerate: node scripts/extract-manifest.mjs && node scripts/emit-mcp-wrappers.mjs\r\n// Source of truth: sbox-mcp-server/src/tools/ (zod schemas) \u2192 scripts/tools-manifest.json\r\n\r\nusing System.Text.Json.Nodes;\r\nusing System.Threading.Tasks;\r\nusing Editor.Mcp;\r\n\r\n/// <summary>\r\n/// GameObject lifecycle, hierarchy, transforms, tags, selection, and bulk layout (align,\r\n/// distribute, scatter, snap-to-ground, grid duplicate) in the open scene. Objects are referenced\r\n/// by GUID from get_scene_hierarchy or find_objects.\r\n/// </summary>\r\n[McpToolset( \"bridge_gameobject\", \"GameObject lifecycle, hierarchy, transforms, tags, selection, and bulk layout (align, distribute, scatter, snap-to-ground, grid duplicate) in the open scene. Objects are referenced by GUID from get_scene_hierarchy or find_objects.\" )]\r\npublic static class BridgeGameObjectTools\r\n{\r\n\t/// <summary>\r\n\t/// Align several GameObjects on one axis so they share a coordinate. mode = first (match the first\r\n\t/// object), min, max, or average; defaults to first. Returns { aligned, axis, mode, target } \u2014\r\n\t/// aligned is the object count and target the shared coordinate; verify positions with\r\n\t/// get_scene_hierarchy or a screenshot.\r\n\t/// </summary>\r\n\t/// <param name=\"ids\">GUIDs of the GameObjects to align (>= 2).</param>\r\n\t/// <param name=\"axis\">Axis to align on. One of: x | y | z.</param>\r\n\t/// <param name=\"mode\">Target coordinate to align to (default first). One of: first | min | max | average.</param>\r\n\t[McpTool( \"align_objects\" )]\r\n\tpublic static Task<object> AlignObjects( string[] ids, string axis, string mode = null )\r\n\t\t=> McpGate.Run( \"align_objects\", McpGate.Args( ( \"ids\", ids ), ( \"axis\", axis ), ( \"mode\", mode ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Commit a dry-run plan returned by place_along_path, grid_duplicate, or scatter_props. Plans are\r\n\t/// scene-scoped, capped, and expire after 10 minutes. Success consumes the plan; a complete\r\n\t/// rollback restores it for retry. The stored transforms are applied without rerolling randomness\r\n\t/// or repeating ground traces. Creation rolls back on failure; grid commits reject a\r\n\t/// changed/missing source before creating anything. Returns slot-to-GUID receipts.\r\n\t/// </summary>\r\n\t/// <param name=\"planId\">Plan id returned by a placement tool with dryRun:true.</param>\r\n\t[McpTool( \"commit_placement_plan\" )]\r\n\tpublic static Task<object> CommitPlacementPlan( string planId )\r\n\t\t=> McpGate.Run( \"commit_placement_plan\", McpGate.Args( ( \"planId\", planId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Create a new GameObject in the active scene. Returns its GUID for future reference.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Display name (e.g. 'Player', 'Enemy Spawn Point'). Defaults to 'New Object'.</param>\r\n\t/// <param name=\"position\">World position. As \"x,y,z\" (or JSON {x,y,z}).</param>\r\n\t/// <param name=\"rotation\">World rotation. As \"pitch,yaw,roll\" degrees.</param>\r\n\t/// <param name=\"scale\">Uniform scale (number) or per-axis scale \u2014 object {x,y,z} or comma string \"x,y,z\". As \"x,y,z\" (or JSON {x,y,z}).</param>\r\n\t/// <param name=\"parent\">GUID of parent GameObject. Omit for scene root.</param>\r\n\t[McpTool( \"create_gameobject\" )]\r\n\tpublic static Task<object> CreateGameobject( string name = null, string position = null, string rotation = null, string scale = null, string parent = null )\r\n\t\t=> McpGate.Run( \"create_gameobject\", McpGate.Args( ( \"name\", name ), ( \"position\", position ), ( \"rotation\", rotation ), ( \"scale\", scale ), ( \"parent\", parent ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Delete a GameObject from the active scene by its GUID.\r\n\t/// </summary>\r\n\t/// <param name=\"id\">GUID of the GameObject to delete.</param>\r\n\t[McpTool( \"delete_gameobject\" )]\r\n\tpublic static Task<object> DeleteGameobject( string id )\r\n\t\t=> McpGate.Run( \"delete_gameobject\", McpGate.Args( ( \"id\", id ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Evenly space GameObjects along an axis between the lowest and highest (keeps the two ends fixed,\r\n\t/// spreads the rest evenly). Returns { distributed, axis, from, to } \u2014 the object count and the\r\n\t/// fixed end coordinates the rest were spread between.\r\n\t/// </summary>\r\n\t/// <param name=\"ids\">GUIDs of the GameObjects to distribute (>= 3).</param>\r\n\t/// <param name=\"axis\">Axis to distribute along. One of: x | y | z.</param>\r\n\t[McpTool( \"distribute_objects\" )]\r\n\tpublic static Task<object> DistributeObjects( string[] ids, string axis )\r\n\t\t=> McpGate.Run( \"distribute_objects\", McpGate.Args( ( \"ids\", ids ), ( \"axis\", axis ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Clone a GameObject with all its components. Returns { duplicated, original, gameObject } \u2014\r\n\t/// gameObject.id is the clone's new GUID; pass it to set_transform / add_component_with_properties.\r\n\t/// If offset is omitted the clone lands exactly on top of the original.\r\n\t/// </summary>\r\n\t/// <param name=\"id\">GUID of the GameObject to duplicate.</param>\r\n\t/// <param name=\"name\">New name for the clone.</param>\r\n\t/// <param name=\"offset\">Position offset from original so the clone doesn't overlap. As \"x,y,z\" (or JSON {x,y,z}).</param>\r\n\t[McpTool( \"duplicate_gameobject\" )]\r\n\tpublic static Task<object> DuplicateGameobject( string id, string name = null, string offset = null )\r\n\t\t=> McpGate.Run( \"duplicate_gameobject\", McpGate.Args( ( \"id\", id ), ( \"name\", name ), ( \"offset\", offset ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Query the scene for GameObjects by name (case-insensitive substring), component type name,\r\n\t/// and/or tag \u2014 combine filters (AND). Returns {id,name} for matches (limit default 50, max 500).\r\n\t/// Read-only; works during play. Use it to get GUIDs to feed into\r\n\t/// align/distribute/set_tint/group/delete/etc.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Name substring (case-insensitive).</param>\r\n\t/// <param name=\"component\">Component type name, e.g. 'PointLight', 'SkinnedModelRenderer'.</param>\r\n\t/// <param name=\"tag\">Tag the object must have.</param>\r\n\t/// <param name=\"limit\">Max results (default 50, max 500).</param>\r\n\t[McpTool.ReadOnly( \"find_objects\" )]\r\n\tpublic static Task<object> FindObjects( string name = null, string component = null, string tag = null, int? limit = null )\r\n\t\t=> McpGate.Run( \"find_objects\", McpGate.Args( ( \"name\", name ), ( \"component\", component ), ( \"tag\", tag ), ( \"limit\", limit ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Find GameObjects within a world-space radius of exactly one explicit position or originId,\r\n\t/// sorted nearest first. Optional name/component/tag filters are applied before the capped result,\r\n\t/// and the Scene root is excluded. Returns pivot-distance results plus\r\n\t/// requestedRadius/radiusClamped and total/showing/truncated/scanned; it deliberately does not\r\n\t/// pretend render or collider overlap is pivot distance. Read-only and play-aware.\r\n\t/// </summary>\r\n\t/// <param name=\"position\">World-space search center; use instead of originId. As \"x,y,z\" (or JSON {x,y,z}).</param>\r\n\t/// <param name=\"originId\">GameObject GUID whose world position is the center.</param>\r\n\t/// <param name=\"radius\">Search radius in world units (default 256, max 1,000,000).</param>\r\n\t/// <param name=\"limit\">Maximum results (default 50).</param>\r\n\t/// <param name=\"name\">Case-insensitive GameObject name substring.</param>\r\n\t/// <param name=\"component\">Required component type name.</param>\r\n\t/// <param name=\"tag\">Required GameObject tag.</param>\r\n\t/// <param name=\"includeOrigin\">Include originId itself (default false).</param>\r\n\t[McpTool.ReadOnly( \"find_objects_near\" )]\r\n\tpublic static Task<object> FindObjectsNear( string position = null, string originId = null, double? radius = null, int? limit = null, string name = null, string component = null, string tag = null, bool? includeOrigin = null )\r\n\t\t=> McpGate.Run( \"find_objects_near\", McpGate.Args( ( \"position\", position ), ( \"originId\", originId ), ( \"radius\", radius ), ( \"limit\", limit ), ( \"name\", name ), ( \"component\", component ), ( \"tag\", tag ), ( \"includeOrigin\", includeOrigin ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Highlight a GameObject by selecting it in the editor. NOTE: s&box exposes no dedicated focus\r\n\t/// API, so this only sets the selection \u2014 it does NOT move any camera (returns { focused, id, note\r\n\t/// } saying so). To actually point the viewport at an object use frame_camera; to aim a screenshot\r\n\t/// use screenshot_from.\r\n\t/// </summary>\r\n\t/// <param name=\"id\">GUID of the GameObject to focus.</param>\r\n\t[McpTool( \"focus_object\" )]\r\n\tpublic static Task<object> FocusObject( string id )\r\n\t\t=> McpGate.Run( \"focus_object\", McpGate.Args( ( \"id\", id ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Get provenance-rich world bounds for a GameObject. Preserves legacy top-level\r\n\t/// center/size/extents/mins/maxs/radius/position/empty for compatibility, and adds render plus\r\n\t/// independent physics and solidPhysics aggregates. Collider outputs include trigger policy, capped\r\n\t/// contributor GameObject IDs and component type names, unsupported counts, and the exact API\r\n\t/// source. Read-only and play-aware.\r\n\t/// </summary>\r\n\t/// <param name=\"id\">GUID of the GameObject to measure.</param>\r\n\t[McpTool.ReadOnly( \"get_bounds\" )]\r\n\tpublic static Task<object> GetBounds( string id )\r\n\t\t=> McpGate.Run( \"get_bounds\", McpGate.Args( ( \"id\", id ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Get the scene tree \u2014 GameObjects with their names, GUIDs, components, and parent/child\r\n\t/// relationships. Pair maxDepth with rootId to drill into a subtree without paying for the whole\r\n\t/// scene.\r\n\t/// </summary>\r\n\t/// <param name=\"maxDepth\">Maximum recursion depth. Defaults to 10. Use 1 or 2 for cheap top-level overviews.</param>\r\n\t/// <param name=\"rootId\">Optional GUID of a GameObject to start traversal from. Omit to walk from the scene roots.</param>\r\n\t[McpTool.ReadOnly( \"get_scene_hierarchy\" )]\r\n\tpublic static Task<object> GetSceneHierarchy( int? maxDepth = null, string rootId = null )\r\n\t\t=> McpGate.Run( \"get_scene_hierarchy\", McpGate.Args( ( \"maxDepth\", maxDepth ), ( \"rootId\", rootId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Get the GameObjects currently selected by the user in the s&box editor. Returns { count,\r\n\t/// selected } where each entry is a serialized GameObject (id, name, enabled, position, rotation,\r\n\t/// scale, components, childCount) \u2014 use the ids with set_transform, add_component_with_properties,\r\n\t/// etc. Handy for 'do X to what I have selected' requests.\r\n\t/// </summary>\r\n\t[McpTool.ReadOnly( \"get_selected_objects\" )]\r\n\tpublic static Task<object> GetSelectedObjects()\r\n\t\t=> McpGate.Run( \"get_selected_objects\", McpGate.Args() );\r\n\r\n\t/// <summary>\r\n\t/// Read the tags currently on a GameObject. (Pair with set_tags to add/remove/clear, and\r\n\t/// find_objects to query by tag.).\r\n\t/// </summary>\r\n\t/// <param name=\"id\">GUID of the GameObject.</param>\r\n\t[McpTool.ReadOnly( \"get_tags\" )]\r\n\tpublic static Task<object> GetTags( string id )\r\n\t\t=> McpGate.Run( \"get_tags\", McpGate.Args( ( \"id\", id ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Clone a GameObject into an X/Y/Z grid. Existing calls mutate immediately and keep the legacy\r\n\t/// result. Use dryRun:true to preview exact capped transforms and receive a planId;\r\n\t/// commit_placement_plan then clones atomically and rejects the commit if the source transform or\r\n\t/// parent changed after preview.\r\n\t/// </summary>\r\n\t/// <param name=\"id\">GUID of the GameObject to clone.</param>\r\n\t/// <param name=\"countX\">Copies along X (default 1).</param>\r\n\t/// <param name=\"countY\">Copies along Y (default 1).</param>\r\n\t/// <param name=\"countZ\">Copies along Z (default 1).</param>\r\n\t/// <param name=\"spacing\">Spacing between copies per axis (default 100,100,100). As \"x,y,z\" (or JSON {x,y,z}).</param>\r\n\t/// <param name=\"dryRun\">Preview only: return deterministic transforms and planId without cloning.</param>\r\n\t[McpTool( \"grid_duplicate\" )]\r\n\tpublic static Task<object> GridDuplicate( string id, int? countX = null, int? countY = null, int? countZ = null, string spacing = null, bool? dryRun = null )\r\n\t\t=> McpGate.Run( \"grid_duplicate\", McpGate.Args( ( \"id\", id ), ( \"countX\", countX ), ( \"countY\", countY ), ( \"countZ\", countZ ), ( \"spacing\", spacing ), ( \"dryRun\", dryRun ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Parent a set of GameObjects under a new empty group object (placed at their centroid) \u2014 tidies\r\n\t/// the hierarchy and lets you move/rotate them together.\r\n\t/// </summary>\r\n\t/// <param name=\"ids\">GUIDs of the GameObjects to group.</param>\r\n\t/// <param name=\"name\">Name for the group object (default 'Group').</param>\r\n\t[McpTool( \"group_objects\" )]\r\n\tpublic static Task<object> GroupObjects( string[] ids, string name = null )\r\n\t\t=> McpGate.Run( \"group_objects\", McpGate.Args( ( \"ids\", ids ), ( \"name\", name ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Measure the distance between two points or two GameObjects. Provide a/b as {x,y,z} or idA/idB as\r\n\t/// GUIDs. Returns straight-line distance, horizontal (ground) distance, and the delta vector.\r\n\t/// Read-only (works during play).\r\n\t/// </summary>\r\n\t/// <param name=\"a\">First point {x,y,z}. As \"x,y,z\" (or JSON {x,y,z}).</param>\r\n\t/// <param name=\"b\">Second point {x,y,z}. As \"x,y,z\" (or JSON {x,y,z}).</param>\r\n\t/// <param name=\"idA\">First GameObject GUID (overrides a).</param>\r\n\t/// <param name=\"idB\">Second GameObject GUID (overrides b).</param>\r\n\t[McpTool.ReadOnly( \"measure_distance\" )]\r\n\tpublic static Task<object> MeasureDistance( string a = null, string b = null, string idA = null, string idB = null )\r\n\t\t=> McpGate.Run( \"measure_distance\", McpGate.Args( ( \"a\", a ), ( \"b\", b ), ( \"idA\", idA ), ( \"idB\", idB ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Add natural variation to existing objects: random yaw and/or random uniform scale within a\r\n\t/// range. Great for breaking up repetition in placed foliage/rocks/crates. Seeded \u2014 the same seed\r\n\t/// reproduces the same layout. Returns { randomized, seed } (the count of objects changed); scale\r\n\t/// only varies when scaleMax > scaleMin.\r\n\t/// </summary>\r\n\t/// <param name=\"ids\">GUIDs of the GameObjects to randomize.</param>\r\n\t/// <param name=\"randomYaw\">Randomize Z rotation (default true).</param>\r\n\t/// <param name=\"scaleMin\">Min uniform scale (default 1).</param>\r\n\t/// <param name=\"scaleMax\">Max uniform scale (default 1; set >min to vary).</param>\r\n\t/// <param name=\"seed\">PRNG seed (default 1).</param>\r\n\t[McpTool( \"randomize_transforms\" )]\r\n\tpublic static Task<object> RandomizeTransforms( string[] ids, bool? randomYaw = null, double? scaleMin = null, double? scaleMax = null, int? seed = null )\r\n\t\t=> McpGate.Run( \"randomize_transforms\", McpGate.Args( ( \"ids\", ids ), ( \"randomYaw\", randomYaw ), ( \"scaleMin\", scaleMin ), ( \"scaleMax\", scaleMax ), ( \"seed\", seed ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Change the display name of a GameObject identified by its GUID (the GUID itself never changes,\r\n\t/// so existing references stay valid). Returns { renamed, id, oldName, newName }. Name-based\r\n\t/// lookups (e.g. find_objects) will see the new name immediately.\r\n\t/// </summary>\r\n\t/// <param name=\"id\">GUID of the GameObject.</param>\r\n\t/// <param name=\"name\">New display name.</param>\r\n\t[McpTool( \"rename_gameobject\" )]\r\n\tpublic static Task<object> RenameGameobject( string id, string name )\r\n\t\t=> McpGate.Run( \"rename_gameobject\", McpGate.Args( ( \"id\", id ), ( \"name\", name ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Swap the model on one object (id) or many (ids) \u2014 e.g. retheme a row of props in one call.\r\n\t/// </summary>\r\n\t/// <param name=\"model\">New model path, e.g. 'models/dev/sphere.vmdl'.</param>\r\n\t/// <param name=\"id\">Single GameObject GUID.</param>\r\n\t/// <param name=\"ids\">Multiple GameObject GUIDs.</param>\r\n\t[McpTool( \"replace_model\" )]\r\n\tpublic static Task<object> ReplaceModel( string model, string id = null, string[] ids = null )\r\n\t\t=> McpGate.Run( \"replace_model\", McpGate.Args( ( \"model\", model ), ( \"id\", id ), ( \"ids\", ids ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Scatter seeded model copies inside a radius. Existing calls mutate immediately and keep the\r\n\t/// legacy { scattered, groupId, seed } result. Use dryRun:true to resolve random transforms and\r\n\t/// ground traces once, returning per-slot transforms, ground status, warnings, model bounds, and\r\n\t/// planId; commit_placement_plan creates exactly that preview with rollback on failure.\r\n\t/// </summary>\r\n\t/// <param name=\"model\">Model path to scatter, e.g. 'models/dev/box.vmdl'.</param>\r\n\t/// <param name=\"center\">Centre of the scatter area (default origin). As \"x,y,z\" (or JSON {x,y,z}).</param>\r\n\t/// <param name=\"radius\">Scatter radius in units (default 256).</param>\r\n\t/// <param name=\"count\">How many to place (default 10, max 300).</param>\r\n\t/// <param name=\"randomYaw\">Randomly rotate each around Z (default true).</param>\r\n\t/// <param name=\"snapToGround\">Raycast each onto the surface below (default true).</param>\r\n\t/// <param name=\"scaleMin\">Min uniform scale (default 1).</param>\r\n\t/// <param name=\"scaleMax\">Max uniform scale (default 1; set >min for size variation).</param>\r\n\t/// <param name=\"tint\">Tint applied to every copy \u2014 object {r,g,b,a} or comma string \"r,g,b,a\". As \"r,g,b[,a]\" (0-1 floats).</param>\r\n\t/// <param name=\"seed\">PRNG seed for a reproducible layout (default 1).</param>\r\n\t/// <param name=\"group\">Parent all copies under one group object (default true).</param>\r\n\t/// <param name=\"name\">Base name for the props/group (default 'Prop').</param>\r\n\t/// <param name=\"dryRun\">Preview only: return deterministic transforms and planId without creating props.</param>\r\n\t[McpTool( \"scatter_props\" )]\r\n\tpublic static Task<object> ScatterProps( string model, string center = null, double? radius = null, int? count = null, bool? randomYaw = null, bool? snapToGround = null, double? scaleMin = null, double? scaleMax = null, string tint = null, int? seed = null, bool? group = null, string name = null, bool? dryRun = null )\r\n\t\t=> McpGate.Run( \"scatter_props\", McpGate.Args( ( \"model\", model ), ( \"center\", center ), ( \"radius\", radius ), ( \"count\", count ), ( \"randomYaw\", randomYaw ), ( \"snapToGround\", snapToGround ), ( \"scaleMin\", scaleMin ), ( \"scaleMax\", scaleMax ), ( \"tint\", tint ), ( \"seed\", seed ), ( \"group\", group ), ( \"name\", name ), ( \"dryRun\", dryRun ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Select a GameObject in the editor (highlights it in the hierarchy and scene view). Replaces the\r\n\t/// current selection unless addToSelection=true. Returns { selected, id }; confirm the result with\r\n\t/// get_selected_objects.\r\n\t/// </summary>\r\n\t/// <param name=\"id\">GUID of the GameObject to select.</param>\r\n\t/// <param name=\"addToSelection\">If true, adds to current selection instead of replacing it.</param>\r\n\t[McpTool( \"select_object\" )]\r\n\tpublic static Task<object> SelectObject( string id, bool? addToSelection = null )\r\n\t\t=> McpGate.Run( \"select_object\", McpGate.Args( ( \"id\", id ), ( \"addToSelection\", addToSelection ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Enable or disable a GameObject (disabled objects are invisible and inactive, including their\r\n\t/// components and children). Returns { id, enabled } confirming the new state.\r\n\t/// </summary>\r\n\t/// <param name=\"id\">GUID of the GameObject.</param>\r\n\t/// <param name=\"enabled\">true to enable, false to disable.</param>\r\n\t[McpTool( \"set_enabled\" )]\r\n\tpublic static Task<object> SetEnabled( string id, bool enabled )\r\n\t\t=> McpGate.Run( \"set_enabled\", McpGate.Args( ( \"id\", id ), ( \"enabled\", enabled ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Reparent a GameObject. Set parentId to null or omit to move to scene root.\r\n\t/// </summary>\r\n\t/// <param name=\"id\">GUID of the GameObject to reparent.</param>\r\n\t/// <param name=\"parentId\">GUID of the new parent. Null or omitted = scene root.</param>\r\n\t[McpTool( \"set_parent\" )]\r\n\tpublic static Task<object> SetParent( string id, string parentId = null )\r\n\t\t=> McpGate.Run( \"set_parent\", McpGate.Args( ( \"id\", id ), ( \"parentId\", parentId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Add, remove, and/or clear gameplay tags on one object (id) or many (ids). Tags drive collision\r\n\t/// groups, queries, and triggers.\r\n\t/// </summary>\r\n\t/// <param name=\"id\">Single GameObject GUID.</param>\r\n\t/// <param name=\"ids\">Multiple GameObject GUIDs.</param>\r\n\t/// <param name=\"add\">Tags to add.</param>\r\n\t/// <param name=\"remove\">Tags to remove.</param>\r\n\t/// <param name=\"clear\">Remove all existing tags first.</param>\r\n\t[McpTool( \"set_tags\" )]\r\n\tpublic static Task<object> SetTags( string id = null, string[] ids = null, string[] add = null, string[] remove = null, bool? clear = null )\r\n\t\t=> McpGate.Run( \"set_tags\", McpGate.Args( ( \"id\", id ), ( \"ids\", ids ), ( \"add\", add ), ( \"remove\", remove ), ( \"clear\", clear ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Set the renderer tint colour on one object (id) or many (ids) at once. Works on any\r\n\t/// ModelRenderer/SkinnedModelRenderer. Pass the colour as \"tint\" (or its alias \"color\"); each\r\n\t/// accepts an object {r,g,b,a} OR a comma string \"r,g,b,a\".\r\n\t/// </summary>\r\n\t/// <param name=\"id\">Single GameObject GUID.</param>\r\n\t/// <param name=\"ids\">Multiple GameObject GUIDs.</param>\r\n\t/// <param name=\"tint\">Tint colour to apply (object or comma string). As \"r,g,b[,a]\" (0-1 floats).</param>\r\n\t/// <param name=\"color\">Alias for \"tint\" (object or comma string). As \"r,g,b[,a]\" (0-1 floats).</param>\r\n\t[McpTool( \"set_tint\" )]\r\n\tpublic static Task<object> SetTint( string id = null, string[] ids = null, string tint = null, string color = null )\r\n\t\t=> McpGate.Run( \"set_tint\", McpGate.Args( ( \"id\", id ), ( \"ids\", ids ), ( \"tint\", tint ), ( \"color\", color ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Atomically set position, rotation, and/or scale on a GameObject. All supplied values are parsed\r\n\t/// before mutation; values apply in world space by default. Prefer space='local' or space='world';\r\n\t/// local remains a legacy alias. Returns legacy { transformed, gameObject } plus before/after\r\n\t/// transform and bounds receipts.\r\n\t/// </summary>\r\n\t/// <param name=\"id\">GUID of the GameObject.</param>\r\n\t/// <param name=\"position\">New position. As \"x,y,z\" (or JSON {x,y,z}).</param>\r\n\t/// <param name=\"rotation\">New rotation. As \"pitch,yaw,roll\" degrees.</param>\r\n\t/// <param name=\"scale\">New scale \u2014 uniform number, per-axis object {x,y,z}, or comma string \"x,y,z\". As \"x,y,z\" (or JSON {x,y,z}).</param>\r\n\t/// <param name=\"local\">Legacy alias: true selects local space and false selects world space.</param>\r\n\t/// <param name=\"space\">Explicit transform space. If supplied with local, both values must agree. One of: world | local.</param>\r\n\t[McpTool( \"set_transform\" )]\r\n\tpublic static Task<object> SetTransform( string id, string position = null, string rotation = null, string scale = null, bool? local = null, string space = null )\r\n\t\t=> McpGate.Run( \"set_transform\", McpGate.Args( ( \"id\", id ), ( \"position\", position ), ( \"rotation\", rotation ), ( \"scale\", scale ), ( \"local\", local ), ( \"space\", space ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Drop a GameObject straight down onto the surface below it (physics raycast). Works best on\r\n\t/// collider-less props (an object with its own collider may self-hit). Optional offset lifts it off\r\n\t/// the surface. Returns { snapped, groundZ, gameObject } with the object's updated transform \u2014 or {\r\n\t/// snapped: false, reason } (not an error) when no ground was hit below.\r\n\t/// </summary>\r\n\t/// <param name=\"id\">GUID of the GameObject to snap.</param>\r\n\t/// <param name=\"offset\">Height above the surface to place it (default 0).</param>\r\n\t/// <param name=\"startHeight\">How far above the object to start the trace (default 2000).</param>\r\n\t/// <param name=\"maxDistance\">Max trace distance downward (default 20000).</param>\r\n\t[McpTool( \"snap_to_ground\" )]\r\n\tpublic static Task<object> SnapToGround( string id, double? offset = null, double? startHeight = null, double? maxDistance = null )\r\n\t\t=> McpGate.Run( \"snap_to_ground\", McpGate.Args( ( \"id\", id ), ( \"offset\", offset ), ( \"startHeight\", startHeight ), ( \"maxDistance\", maxDistance ) ) );\r\n}\r\n"
},
{
"Ident": "sboxskinsgg.claudebridge",
"Path": "Editor/Mcp/BridgeMovieMakerTools.cs",
"FileName": "BridgeMovieMakerTools.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 335526,
"Code": "// AUTO-GENERATED by scripts/emit-mcp-wrappers.mjs \u2014 DO NOT EDIT.\r\n// Regenerate: node scripts/extract-manifest.mjs && node scripts/emit-mcp-wrappers.mjs\r\n// Source of truth: sbox-mcp-server/src/tools/ (zod schemas) \u2192 scripts/tools-manifest.json\r\n\r\nusing System.Text.Json.Nodes;\r\nusing System.Threading.Tasks;\r\nusing Editor.Mcp;\r\n\r\n/// <summary>\r\n/// Wire and control Sandbox.MovieMaker cutscene playback: list .movie clips, add MoviePlayer\r\n/// components, play and stop clips.\r\n/// </summary>\r\n[McpToolset( \"bridge_moviemaker\", \"Wire and control Sandbox.MovieMaker cutscene playback: list .movie clips, add MoviePlayer components, play and stop clips.\" )]\r\npublic static class BridgeMovieMakerTools\r\n{\r\n\t/// <summary>\r\n\t/// Add a Sandbox.MovieMaker.MoviePlayer component and optionally wire a .movie resource into it \u2014\r\n\t/// the cutscene playback primitive. Creates a new 'Movie Player' GameObject when no id is given.\r\n\t/// Set playOnStart to begin playback the moment play mode starts (intro cinematics), or leave it\r\n\t/// and trigger via play_movie (scripted cutscenes \u2014 call it from a trigger zone or dialogue beat).\r\n\t/// isLooping + timeScale map straight onto the component. Movies must already exist as .movie\r\n\t/// assets (list_movies; author in the Movie Maker dock). Scene-mutating \u2014 refused during play mode.\r\n\t/// </summary>\r\n\t/// <param name=\"id\">GameObject GUID to attach to. Omit to create a new 'Movie Player' object.</param>\r\n\t/// <param name=\"moviePath\">Asset-relative path of the .movie resource to wire (see list_movies).</param>\r\n\t/// <param name=\"isLooping\">Loop playback.</param>\r\n\t/// <param name=\"timeScale\">Playback speed multiplier (1 = normal).</param>\r\n\t/// <param name=\"createTargets\">Let the player create missing track-target objects on play.</param>\r\n\t/// <param name=\"playOnStart\">Begin playing as soon as play mode starts (intro cinematic).</param>\r\n\t[McpTool( \"add_movie_player\" )]\r\n\tpublic static Task<object> AddMoviePlayer( string id = null, string moviePath = null, bool? isLooping = null, double? timeScale = null, bool? createTargets = null, bool? playOnStart = null )\r\n\t\t=> McpGate.Run( \"add_movie_player\", McpGate.Args( ( \"id\", id ), ( \"moviePath\", moviePath ), ( \"isLooping\", isLooping ), ( \"timeScale\", timeScale ), ( \"createTargets\", createTargets ), ( \"playOnStart\", playOnStart ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Author a MovieMaker .movie cutscene clip from a declarative shot list \u2014 EDIT MODE ONLY, no Movie\r\n\t/// Maker dock, no play mode, no real-time waiting (a 30s clip bakes in one call, typically <1s).\r\n\t/// Builds a hold+blend keyframe timeline from the shots (smoothstep ease by default), steps a\r\n\t/// camera through it, and hand-pumps MovieRecorder Advance/Capture per synthetic frame, then saves\r\n\t/// Assets/<folder>/<clipName>.movie (registered + compiled; errors if the file exists \u2014\r\n\t/// the scene itself is NOT saved). Returns { authored, path, name, durationSeconds, frames,\r\n\t/// sampleRate, shots, tracks, bakeMs, compiled, loadable, camera, nextSteps }. Camera: omit\r\n\t/// cameraId for a temp camera (destroyed after the bake \u2014 play back with add_movie_player\r\n\t/// createTargets:true so the missing target is recreated), or pass cameraId of an existing camera\r\n\t/// GameObject (transform + FOV restored EXACTLY afterwards; the clip then animates THAT object on\r\n\t/// playback). fovDegrees is baked for real (the clip carries a FieldOfView track). Authored clips\r\n\t/// animate ONLY the camera the bake moves \u2014 other scene objects don't move in edit mode (that's\r\n\t/// what record_gameplay_clip is for). Total timeline capped at 120s, max 32 shots. Errors during\r\n\t/// play mode (stop_play first). Verify with list_movies; play via add_movie_player + play_movie in\r\n\t/// play mode.\r\n\t/// </summary>\r\n\t/// <param name=\"shots\">The shot list in order (1-32 shots). Timeline = hold\u2080, then blend\u1d62 + hold\u1d62 per following shot. JSON array.</param>\r\n\t/// <param name=\"clipName\">Asset name without extension (default authored_<UTC timestamp>; sanitized to [A-Za-z0-9_-]).</param>\r\n\t/// <param name=\"folder\">Assets subfolder to save into (default \"movies\").</param>\r\n\t/// <param name=\"sampleRate\">Clip samples per second (default 30, clamped 1-120).</param>\r\n\t/// <param name=\"cameraId\">GUID of an existing camera GameObject (must have a CameraComponent) to bake through \u2014 restored EXACTLY afterwards, and playback then animates that object. Omit for a temp camera that is destroyed after the bake.</param>\r\n\t[McpTool( \"author_movie_clip\" )]\r\n\tpublic static Task<object> AuthorMovieClip( JsonNode shots, string clipName = null, string folder = null, int? sampleRate = null, string cameraId = null )\r\n\t\t=> McpGate.Run( \"author_movie_clip\", McpGate.Args( ( \"shots\", shots ), ( \"clipName\", clipName ), ( \"folder\", folder ), ( \"sampleRate\", sampleRate ), ( \"cameraId\", cameraId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a sealed killcam Component: a rolling-buffer MovieRecorder keeps ONLY the last\r\n\t/// MaxBufferSeconds of a target's gameplay (BufferDuration verified live: the compiled clip's\r\n\t/// Duration equals the buffer, re-based to 0), and TriggerReplay() plays that history back through\r\n\t/// a MoviePlayer while the main camera chase-follows the target (Scene.Camera takeover in\r\n\t/// OnPreRender, restored exactly afterwards; static OnReplayFinished event + IsReplaying flag).\r\n\t/// Sandbox-safe: live-verified that GAME code can construct and drive MovieRecorder/MoviePlayer at\r\n\t/// runtime, and killcams/replays are the official recording-api use case \u2014 this is the real\r\n\t/// MovieMaker path, not a transform-history approximation. The replay REWINDS THE LIVE TARGET\r\n\t/// through its recorded past (classic killcam \u2014 the target is dead/inactive when it runs; disable a\r\n\t/// still-alive controller for the duration). wholeScene:true makes the generated component default\r\n\t/// to MovieRecorderOptions.Default (all renderers/cameras/sound points/particles \u2014 the replay\r\n\t/// rewinds everything, killer included; heavy in dense scenes), and it stays toggleable\r\n\t/// per-instance via the RecordWholeScene property. Returns { created, path, className,\r\n\t/// bufferSeconds, sampleRate, cameraDistance, cameraHeight, nextSteps }. Then: trigger_hotload \u2192\r\n\t/// attach to a MANAGER object \u2192 set_component_reference Target to the player \u2192 arm via WatchOnStart\r\n\t/// or StartWatching() from spawn code \u2192 call TriggerReplay() from death code (pairs with\r\n\t/// create_health_system). LOCAL/visual-only \u2014 wrap in an [Rpc.Broadcast] for all clients. Refuses\r\n\t/// if the file already exists.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Component class/file name (default \"Killcam\").</param>\r\n\t/// <param name=\"directory\">Project folder for the .cs file (default \"Code\").</param>\r\n\t/// <param name=\"bufferSeconds\">Rolling-buffer length in seconds \u2014 the replay shows at most this much history (default 10, clamped 2-120).</param>\r\n\t/// <param name=\"sampleRate\">Recorder samples per second (default 30, clamped 1-120).</param>\r\n\t/// <param name=\"cameraDistance\">Replay chase-camera distance behind the target (default 150, clamped 10-2000).</param>\r\n\t/// <param name=\"cameraHeight\">Replay chase-camera height above the target (default 60, clamped 0-2000).</param>\r\n\t/// <param name=\"wholeScene\">Generated default for RecordWholeScene: true = buffer the WHOLE scene via MovieRecorderOptions.Default (replay rewinds everything; heavy in dense scenes), false = only the Target hierarchy (default).</param>\r\n\t[McpTool( \"create_killcam\" )]\r\n\tpublic static Task<object> CreateKillcam( string name = null, string directory = null, double? bufferSeconds = null, int? sampleRate = null, double? cameraDistance = null, double? cameraHeight = null, bool? wholeScene = null )\r\n\t\t=> McpGate.Run( \"create_killcam\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"bufferSeconds\", bufferSeconds ), ( \"sampleRate\", sampleRate ), ( \"cameraDistance\", cameraDistance ), ( \"cameraHeight\", cameraHeight ), ( \"wholeScene\", wholeScene ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Poll the gameplay recording job. While recording returns { recording:true, jobId, elapsedSeconds\r\n\t/// (clip-timeline seconds), framesWithData, maxSeconds, sampleRate, capture, trackedObjectCount }\r\n\t/// (trackedObjectCount is -1 for whole-scene capture). After an auto-stop (maxSeconds cap / play\r\n\t/// mode ended) returns { stopped:true, pendingSave:true, reason } \u2014 the clip is in memory awaiting\r\n\t/// stop_gameplay_recording. After a save/discard returns that last summary (assetPath etc.).\r\n\t/// Read-only; works during play. No params.\r\n\t/// </summary>\r\n\t[McpTool( \"gameplay_recording_status\" )]\r\n\tpublic static Task<object> GameplayRecordingStatus()\r\n\t\t=> McpGate.Run( \"gameplay_recording_status\", McpGate.Args() );\r\n\r\n\t/// <summary>\r\n\t/// List the project's .movie resources (Sandbox.MovieMaker clips authored in the editor's Movie\r\n\t/// Maker dock: Window \u2192 Movie Maker). Scans the ENTIRE Assets folder recursively and returns every\r\n\t/// .movie found \u2014 no limit or paging. Returns { count, movies, note } where each movie has { path\r\n\t/// (asset-relative \u2014 the form add_movie_player/play_movie expect), name, loadable (resolves via\r\n\t/// ResourceLibrary), hasCompiledClip }. Start here before add_movie_player / play_movie \u2014 if the\r\n\t/// list is empty, the movie has to be authored in the dock first (the bridge plays movies; it\r\n\t/// doesn't author keyframes).\r\n\t/// </summary>\r\n\t[McpTool.ReadOnly( \"list_movies\" )]\r\n\tpublic static Task<object> ListMovies()\r\n\t\t=> McpGate.Run( \"list_movies\", McpGate.Args() );\r\n\r\n\t/// <summary>\r\n\t/// Start MoviePlayer playback. Targets the MoviePlayer on the given GameObject, or the first\r\n\t/// MoviePlayer in the scene when id is omitted. Pass moviePath to load-and-play a different .movie\r\n\t/// on the same player; positionSeconds seeks before playing; isLooping/timeScale apply immediately.\r\n\t/// Clips genuinely advance in PLAY MODE (start_play first, then verify with capture_view) \u2014 in edit\r\n\t/// mode this only sets state, which the response calls out. NOT scene-mutating, so it works during\r\n\t/// play mode.\r\n\t/// </summary>\r\n\t/// <param name=\"id\">GameObject GUID holding the MoviePlayer. Omit to use the first MoviePlayer in the scene.</param>\r\n\t/// <param name=\"moviePath\">Asset-relative .movie path to load and play (otherwise plays the wired Resource).</param>\r\n\t/// <param name=\"positionSeconds\">Seek to this time (seconds) before playing.</param>\r\n\t/// <param name=\"timeScale\">Playback speed multiplier (1 = normal).</param>\r\n\t/// <param name=\"isLooping\">Loop playback.</param>\r\n\t[McpTool( \"play_movie\" )]\r\n\tpublic static Task<object> PlayMovie( string id = null, string moviePath = null, double? positionSeconds = null, double? timeScale = null, bool? isLooping = null )\r\n\t\t=> McpGate.Run( \"play_movie\", McpGate.Args( ( \"id\", id ), ( \"moviePath\", moviePath ), ( \"positionSeconds\", positionSeconds ), ( \"timeScale\", timeScale ), ( \"isLooping\", isLooping ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Start recording live play-mode gameplay into a Sandbox.MovieMaker clip \u2014 REQUIRES play mode\r\n\t/// (start_play first; errors otherwise). Captures the given GameObjects (ids \u2014 recommended: small\r\n\t/// focused clips) or, when ids is omitted, the WHOLE scene (heavy: every object becomes tracks).\r\n\t/// Returns { started, jobId, sampleRate, maxSeconds, capture, discarded, note } immediately;\r\n\t/// recording runs ASYNC in the editor frame loop until stop_gameplay_recording or the maxSeconds\r\n\t/// safety cap (default 60s of clip time, max 600s). Only one recording at a time (a second call\r\n\t/// errors while active; a stopped-but-unsaved clip is discarded by a new start, reported in\r\n\t/// 'discarded'). Combine with playtest or drive_player to record a SCRIPTED run, then\r\n\t/// stop_gameplay_recording to save the .movie and play_movie to replay it.\r\n\t/// </summary>\r\n\t/// <param name=\"ids\">GameObject GUIDs to capture (from get_scene_hierarchy WHILE PLAYING \u2014 play-mode ids can differ from editor ids). Omit to capture the whole scene (heavy).</param>\r\n\t/// <param name=\"sampleRate\">Samples per second (default 30, clamped 1-120).</param>\r\n\t/// <param name=\"maxSeconds\">Safety cap \u2014 auto-stops the recording once the clip timeline reaches this many seconds (default 60, clamped 1-600). The clip stays in memory until stop_gameplay_recording saves it.</param>\r\n\t[McpTool( \"record_gameplay_clip\" )]\r\n\tpublic static Task<object> RecordGameplayClip( string[] ids = null, int? sampleRate = null, double? maxSeconds = null )\r\n\t\t=> McpGate.Run( \"record_gameplay_clip\", McpGate.Args( ( \"ids\", ids ), ( \"sampleRate\", sampleRate ), ( \"maxSeconds\", maxSeconds ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Run a scripted playtest AND record the same run to a .movie clip in ONE call \u2014 automated\r\n\t/// regression footage: a failing playtest comes with a replayable clip of exactly what happened.\r\n\t/// REQUIRES play mode (start_play first). steps uses the EXACT playtest schema (one verb per step:\r\n\t/// move / look / lookDelta / action / jump / set / wait / capture / assert \u2014 see the playtest tool\r\n\t/// for the full verb reference). The recording defaults to the playtest's resolved player\r\n\t/// hierarchy; pass ids to record other objects, or nothing resolvable falls back to whole-scene\r\n\t/// capture (heavy). Returns { started, steps, recordingJobId, capture, sampleRate, clipName,\r\n\t/// folder, recorderCapSeconds, note } immediately; both jobs run ASYNC in the editor frame loop and\r\n\t/// the clip AUTO-SAVES the moment the playtest finishes (a failing or aborted run still saves its\r\n\t/// footage; play mode ending early is also saved). THE POLL CHAIN: 1) playtest_status until\r\n\t/// finished:true \u2192 the per-step pass/fail transcript. 2) gameplay_recording_status \u2192 the saved clip\r\n\t/// summary { saved, assetPath, durationSeconds, trackCount } (if it still says pendingSave, the\r\n\t/// save is a frame away \u2014 poll again; a save error there means name collision: call\r\n\t/// stop_gameplay_recording yourself with a new name). Replay the footage with add_movie_player +\r\n\t/// play_movie. Errors if a playtest or gameplay recording is already active. Only one at a time.\r\n\t/// </summary>\r\n\t/// <param name=\"steps\">Ordered playtest step objects \u2014 identical schema to the playtest tool (move/look/lookDelta/action/jump/set/wait/capture/assert). Runs top-to-bottom in the frame loop. JSON array.</param>\r\n\t/// <param name=\"id\">GUID of the player/controller GameObject the playtest drives. Omit to auto-resolve the first PlayerController.</param>\r\n\t/// <param name=\"component\">Controller component type to target (e.g. 'PlayerController'). Omit to auto-detect.</param>\r\n\t/// <param name=\"ids\">GameObject GUIDs to RECORD (from get_scene_hierarchy WHILE PLAYING). Omit to record the playtest's player hierarchy (the default and usually what you want).</param>\r\n\t/// <param name=\"sampleRate\">Recording samples per second (default 30, clamped 1-120).</param>\r\n\t/// <param name=\"clipName\">Saved .movie asset name without extension (default playtest_<UTC timestamp>; sanitized to [A-Za-z0-9_-]).</param>\r\n\t/// <param name=\"folder\">Assets subfolder to save the clip into (default \"recordings\").</param>\r\n\t[McpTool( \"record_playtest\" )]\r\n\tpublic static Task<object> RecordPlaytest( JsonNode steps, string id = null, string component = null, string[] ids = null, int? sampleRate = null, string clipName = null, string folder = null )\r\n\t\t=> McpGate.Run( \"record_playtest\", McpGate.Args( ( \"steps\", steps ), ( \"id\", id ), ( \"component\", component ), ( \"ids\", ids ), ( \"sampleRate\", sampleRate ), ( \"clipName\", clipName ), ( \"folder\", folder ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Stop the active gameplay recording and persist it as a project .movie asset the editor can load\r\n\t/// (written to Assets/<folder>/<name>.movie, registered + compiled \u2014 list_movies then\r\n\t/// shows it with hasCompiledClip). Also saves a job that already auto-stopped (maxSeconds cap, or\r\n\t/// play mode ended). Returns { saved, assetPath, durationSeconds, trackCount, sampleRate, compiled,\r\n\t/// stopReason, wired, note } \u2014 a trackCount of 0 means nothing was captured and the response warns\r\n\t/// about it. Pass wireToId to auto-wire a MoviePlayer on that GameObject pointed at the new clip\r\n\t/// (during play mode that wiring is RUNTIME-ONLY and discarded on stop_play; the .movie asset\r\n\t/// itself always persists). Errors if the target file already exists (the clip stays in memory \u2014\r\n\t/// retry with another name). discard:true throws the recording away instead. Replay:\r\n\t/// add_movie_player + play_movie in play mode.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Asset name without extension (default recording_<UTC timestamp>; sanitized to [A-Za-z0-9_-]).</param>\r\n\t/// <param name=\"folder\">Assets subfolder to save into (default \"recordings\").</param>\r\n\t/// <param name=\"wireToId\">GameObject GUID to auto-wire a MoviePlayer at the new clip (runtime-only if done during play mode).</param>\r\n\t/// <param name=\"discard\">Throw the recording away instead of saving it.</param>\r\n\t[McpTool( \"stop_gameplay_recording\" )]\r\n\tpublic static Task<object> StopGameplayRecording( string name = null, string folder = null, string wireToId = null, bool? discard = null )\r\n\t\t=> McpGate.Run( \"stop_gameplay_recording\", McpGate.Args( ( \"name\", name ), ( \"folder\", folder ), ( \"wireToId\", wireToId ), ( \"discard\", discard ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Stop MoviePlayer playback (the counterpart to play_movie). Targets the MoviePlayer on the given\r\n\t/// GameObject, or the first MoviePlayer in the scene when id is omitted. Pass rewind to also reset\r\n\t/// the playhead to 0 so the next play_movie starts from the top. Works during play mode.\r\n\t/// </summary>\r\n\t/// <param name=\"id\">GameObject GUID holding the MoviePlayer. Omit to use the first MoviePlayer in the scene.</param>\r\n\t/// <param name=\"rewind\">Also reset the playhead to 0.</param>\r\n\t[McpTool( \"stop_movie\" )]\r\n\tpublic static Task<object> StopMovie( string id = null, bool? rewind = null )\r\n\t\t=> McpGate.Run( \"stop_movie\", McpGate.Args( ( \"id\", id ), ( \"rewind\", rewind ) ) );\r\n}\r\n"
},
{
"Ident": "sboxskinsgg.claudebridge",
"Path": "Editor/Mcp/BridgeNpcTools.cs",
"FileName": "BridgeNpcTools.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 335526,
"Code": "// AUTO-GENERATED by scripts/emit-mcp-wrappers.mjs \u2014 DO NOT EDIT.\r\n// Regenerate: node scripts/extract-manifest.mjs && node scripts/emit-mcp-wrappers.mjs\r\n// Source of truth: sbox-mcp-server/src/tools/ (zod schemas) \u2192 scripts/tools-manifest.json\r\n\r\nusing System.Text.Json.Nodes;\r\nusing System.Threading.Tasks;\r\nusing Editor.Mcp;\r\n\r\n/// <summary>\r\n/// NPC brains (state machines), spawners, patrol routes, and perception simulation.\r\n/// </summary>\r\n[McpToolset( \"bridge_npc\", \"NPC brains (state machines), spawners, patrol routes, and perception simulation.\" )]\r\npublic static class BridgeNpcTools\r\n{\r\n\t/// <summary>\r\n\t/// Wire a placed route (or an arbitrary ordered GUID list) into an NpcBrain's Waypoints list on a\r\n\t/// target NPC. This is the list-of-GameObject-references case that plain set_property can't\r\n\t/// express. Pass either waypointIds (explicit order) or routeId (a route parent whose children\r\n\t/// become the waypoints in hierarchy order). The list count is returned; List<GameObject>\r\n\t/// refs may read back as handles/GUIDs via get_property, so trust the count or confirm patrol in\r\n\t/// play mode.\r\n\t/// </summary>\r\n\t/// <param name=\"npcId\">GUID of the GameObject holding the NpcBrain (or any component with a List<GameObject> waypoint property).</param>\r\n\t/// <param name=\"waypointIds\">Ordered waypoint GameObject GUIDs (e.g. from place_patrol_route). Takes precedence over routeId.</param>\r\n\t/// <param name=\"routeId\">A route parent GUID whose children (in hierarchy order) become the waypoints.</param>\r\n\t/// <param name=\"property\">The List<GameObject> property name to set. Defaults to 'Waypoints'. (Use 'SpawnPoints' to wire spawn points on a spawner.).</param>\r\n\t[McpTool( \"assign_patrol_route\" )]\r\n\tpublic static Task<object> AssignPatrolRoute( string npcId, string[] waypointIds = null, string routeId = null, string property = null )\r\n\t\t=> McpGate.Run( \"assign_patrol_route\", McpGate.Args( ( \"npcId\", npcId ), ( \"waypointIds\", waypointIds ), ( \"routeId\", routeId ), ( \"property\", property ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate an NpcBrain Component: a behavior state machine\r\n\t/// (Idle/Patrol/Wander/Chase/Search/Flee/Ambush) driven by occlusion-aware perception \u2014 FOV cone +\r\n\t/// sight range + a line-of-sight trace (respects walls/trees) + proximity hearing \u2014 with\r\n\t/// last-known-position memory (lose-LOS -> search -> give up -> resume). This is the\r\n\t/// decision layer on top of bake_navmesh / NavMeshAgent movement. Pick a behavior preset, then tune\r\n\t/// via the generated [Property] fields with set_property. After generating: trigger_hotload +\r\n\t/// get_compile_errors, place a route with place_patrol_route + assign_patrol_route, bake_navmesh,\r\n\t/// and verify perception in EDIT mode with simulate_npc_perception (chase/search behavior needs\r\n\t/// play mode). The component is added to a GameObject like any other; it auto-adds a NavMeshAgent\r\n\t/// in OnStart.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class/file name. Defaults to 'NpcBrain'. Sanitized to a valid C# identifier.</param>\r\n\t/// <param name=\"directory\">Subdirectory under the project root for the .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"behavior\">Preset (sets StartState + flee toggle): 'patrol' (walk waypoints), 'guard' (Ambush near spawn until a target enters range), 'hunter' (patrol->chase->search, the Sasquatch), 'swarm' (wander/idle->chase nearest, RUN mobs), 'skittish' (chase but flee on low health). The generated file is the same shape; the preset just changes defaults. Defaults to 'hunter'. One of: patrol | guard | hunter | swarm | skittish.</param>\r\n\t/// <param name=\"targetTag\">Tag the NPC hunts (its candidates are GameObjects with this tag). Defaults to 'player'.</param>\r\n\t/// <param name=\"moveSpeed\">Patrol/wander speed (NavMeshAgent MaxSpeed). Default 130.</param>\r\n\t/// <param name=\"chaseSpeed\">Chase/flee speed. Default 200.</param>\r\n\t/// <param name=\"sightRange\">Max sight distance. Default 1500.</param>\r\n\t/// <param name=\"fovDegrees\">Full field-of-view cone angle in degrees. Default 110. (Baked into a cosine threshold for cheap, trig-free checks.).</param>\r\n\t/// <param name=\"eyeHeight\">Trace origin height above the NPC's feet. Default 64.</param>\r\n\t/// <param name=\"hearingRadius\">Proximity-hearing radius \u2014 a target within it is investigated (sets last-known-pos) but NOT instantly aggroed. Default 600.</param>\r\n\t/// <param name=\"giveUpTime\">Seconds to search after losing line-of-sight before giving up and resuming the start state. Default 6.</param>\r\n\t/// <param name=\"searchRadius\">Wander radius around the last-known position while searching. Default 400.</param>\r\n\t/// <param name=\"waypointStopDistance\">How close the NPC must get to a waypoint/target before it counts as reached. Default 80.</param>\r\n\t/// <param name=\"canFlee\">Enable the Flee state (else the NPC never flees). Defaults from the preset.</param>\r\n\t/// <param name=\"fleeHealthFrac\">Flee when CurrentHealthFrac drops to/below this (the game sets CurrentHealthFrac 0..1). Default 0.25.</param>\r\n\t/// <param name=\"networked\">When true (default), emit a host-authoritative brain: 'if (IsProxy) return;' + [Sync] CurrentState. NOTE: a no-session solo playtest makes everything a proxy, so a networked brain won't think until a host session exists \u2014 pass false to iterate solo in the edit scene.</param>\r\n\t[McpTool( \"create_npc_brain\" )]\r\n\tpublic static Task<object> CreateNpcBrain( string name = null, string directory = null, string behavior = null, string targetTag = null, double? moveSpeed = null, double? chaseSpeed = null, double? sightRange = null, double? fovDegrees = null, double? eyeHeight = null, double? hearingRadius = null, double? giveUpTime = null, double? searchRadius = null, double? waypointStopDistance = null, bool? canFlee = null, double? fleeHealthFrac = null, bool? networked = null )\r\n\t\t=> McpGate.Run( \"create_npc_brain\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"behavior\", behavior ), ( \"targetTag\", targetTag ), ( \"moveSpeed\", moveSpeed ), ( \"chaseSpeed\", chaseSpeed ), ( \"sightRange\", sightRange ), ( \"fovDegrees\", fovDegrees ), ( \"eyeHeight\", eyeHeight ), ( \"hearingRadius\", hearingRadius ), ( \"giveUpTime\", giveUpTime ), ( \"searchRadius\", searchRadius ), ( \"waypointStopDistance\", waypointStopDistance ), ( \"canFlee\", canFlee ), ( \"fleeHealthFrac\", fleeHealthFrac ), ( \"networked\", networked ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a daily-routine NPC brain: a [Property] list of schedule entries (startHour/endHour\r\n\t/// 0..24, taskName, target = named scene GameObject or fixed position), the hour read from any\r\n\t/// create_day_night_clock component (capability match: a float TimeOfDay property, same GameObject\r\n\t/// first then scene-wide) with an HONEST fallback to its own internal clock when none exists (check\r\n\t/// the generated UsingClockComponent bool), walking the NPC to the active entry's target and idling\r\n\t/// outside the schedule, plus a static OnTaskChanged(brain, taskName) event and [Sync(FromHost)]\r\n\t/// CurrentTask. Entries with endHour < startHour wrap past midnight. Returns {created, path,\r\n\t/// className, tasks[], propertyNames[], note}. Next: trigger_hotload + get_compile_errors, attach\r\n\t/// (targetId or add_component_with_properties), create the named target GameObjects (e.g.\r\n\t/// 'WorkSpot'), pair with create_day_night_clock for shared time, verify via get_runtime_property\r\n\t/// CurrentTask in play mode. Limits: default movement is a direct transform walk (walks through\r\n\t/// walls) \u2014 pass useNavMeshAgent:true for pathfinding (then bake_navmesh is REQUIRED); a clock with\r\n\t/// a different shape (e.g. 0..1 DayProgress) will NOT bind; networked default true won't tick in a\r\n\t/// no-session solo playtest (networked:false to iterate). Refused during play mode; refuses to\r\n\t/// overwrite an existing file.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class/file name. Defaults to 'NpcScheduleBrain'. Sanitized to a valid C# identifier.</param>\r\n\t/// <param name=\"directory\">Subdirectory under the project root for the .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"schedule\">Schedule entries baked as inspector-editable defaults. Defaults to Work 8-17 @ 'WorkSpot', Relax 17-22 @ 'HomeSpot' (idles/sleeps otherwise). JSON array.</param>\r\n\t/// <param name=\"moveSpeed\">Walk speed in world units/s. Defaults to 100.</param>\r\n\t/// <param name=\"arriveDistance\">Distance at which the NPC counts as arrived and idles at the spot. Defaults to 32.</param>\r\n\t/// <param name=\"useNavMeshAgent\">true: move via NavMeshAgent.MoveTo (real pathfinding \u2014 REQUIRES bake_navmesh or the NPC won't move). Defaults to false (direct transform walk, no navmesh needed, walks through walls).</param>\r\n\t/// <param name=\"fallbackDayLengthSeconds\">Internal fallback clock only: real seconds per 24 in-game hours when NO TimeOfDay clock component exists. Defaults to 600.</param>\r\n\t/// <param name=\"fallbackStartHour\">Internal fallback clock only: starting hour 0..24. Defaults to 8.</param>\r\n\t/// <param name=\"networked\">true (default): host-authoritative (IsProxy guard) + [Sync(FromHost)] CurrentTask. false: local build for solo iteration.</param>\r\n\t/// <param name=\"targetId\">GUID of the NPC GameObject to attach to (only attaches if the type is already in the TypeLibrary \u2014 hotload first).</param>\r\n\t[McpTool( \"create_npc_schedule_brain\" )]\r\n\tpublic static Task<object> CreateNpcScheduleBrain( string name = null, string directory = null, JsonNode schedule = null, double? moveSpeed = null, double? arriveDistance = null, bool? useNavMeshAgent = null, double? fallbackDayLengthSeconds = null, double? fallbackStartHour = null, bool? networked = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_npc_schedule_brain\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"schedule\", schedule ), ( \"moveSpeed\", moveSpeed ), ( \"arriveDistance\", arriveDistance ), ( \"useNavMeshAgent\", useNavMeshAgent ), ( \"fallbackDayLengthSeconds\", fallbackDayLengthSeconds ), ( \"fallbackStartHour\", fallbackStartHour ), ( \"networked\", networked ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a spawner Component that instantiates an NPC prefab over time / in escalating waves at\r\n\t/// spawn points, capped by maxAlive. RUN's swarm backbone and Sasquatched's round-start spawn.\r\n\t/// After generating: set NpcPrefab via set_prefab_ref, set SpawnPoints (reuse place_patrol_route to\r\n\t/// make a set of empties, then assign_patrol_route with property='SpawnPoints'), trigger_hotload +\r\n\t/// get_compile_errors. Verify by watching the GameObject count over time in play mode. Networked\r\n\t/// spawns use NetworkSpawn() and are host-only.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class/file name. Defaults to 'NpcSpawner'.</param>\r\n\t/// <param name=\"directory\">Subdirectory under the project root. Defaults to 'Code'.</param>\r\n\t/// <param name=\"mode\">'continuous' (one every interval), 'waves' (a batch every interval, waveCount times), 'burst' (one batch then stop). Default 'waves'. One of: continuous | waves | burst.</param>\r\n\t/// <param name=\"count\">NPCs per wave (waves) or per batch (burst/continuous batch). Default 5.</param>\r\n\t/// <param name=\"interval\">Seconds between spawns (continuous) or between waves (waves). Default 8.</param>\r\n\t/// <param name=\"waveCount\">Number of waves (waves mode). Default 3.</param>\r\n\t/// <param name=\"waveGrowth\">Multiply count each wave (>1 = escalating). Default 1.0.</param>\r\n\t/// <param name=\"radius\">Random scatter radius around a spawn point. Default 200.</param>\r\n\t/// <param name=\"maxAlive\">Cap on concurrent live NPCs (important so swarms don't melt the frame rate). Default 12.</param>\r\n\t/// <param name=\"networked\">When true (default), spawn via NetworkSpawn() (host-only, try/catch solo-safe) so clients see the NPCs; false = a plain local Clone for solo/edit testing.</param>\r\n\t[McpTool( \"create_npc_spawner\" )]\r\n\tpublic static Task<object> CreateNpcSpawner( string name = null, string directory = null, string mode = null, double? count = null, double? interval = null, double? waveCount = null, double? waveGrowth = null, double? radius = null, double? maxAlive = null, bool? networked = null )\r\n\t\t=> McpGate.Run( \"create_npc_spawner\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"mode\", mode ), ( \"count\", count ), ( \"interval\", interval ), ( \"waveCount\", waveCount ), ( \"waveGrowth\", waveGrowth ), ( \"radius\", radius ), ( \"maxAlive\", maxAlive ), ( \"networked\", networked ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a utility-AI (scored-action) brain: one file with an abstract {name}Action : Component\r\n\t/// base (Score() 0..1 + Begin/Tick/End lifecycle), a sealed {name}Brain that every EvaluateInterval\r\n\t/// picks the highest-scoring sibling action (score \u00d7 ScoreWeight, current action gets\r\n\t/// +HysteresisBonus so near-ties don't flip-flop), and two example actions \u2014 {name}IdleAction\r\n\t/// (constant fallback score) and {name}WanderAction (desire builds while idle, walks to random\r\n\t/// points by direct transform movement, no navmesh). How it differs from create_npc_brain: the FSM\r\n\t/// has a FIXED transition table; here behavior EMERGES from per-frame scores \u2014 add behaviors by\r\n\t/// subclassing the base on the same GameObject, no transition wiring. Returns {created, path,\r\n\t/// classNames[4], propertyNames[], note}. Next: trigger_hotload + get_compile_errors, attach the\r\n\t/// brain AND example actions to one GameObject (targetId attaches only the brain), verify in play\r\n\t/// mode via get_runtime_property CurrentActionName. Limits: networked default true =\r\n\t/// host-authoritative (won't tick in a no-session solo playtest \u2014 use networked:false); actions\r\n\t/// Tick on the simulating machine only. Refused during play mode; refuses to overwrite an existing\r\n\t/// file.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">System prefix \u2014 generates {name}Action / {name}Brain / {name}IdleAction / {name}WanderAction in {name}Ai.cs. Defaults to 'Utility'. Sanitized to a valid C# identifier.</param>\r\n\t/// <param name=\"directory\">Subdirectory under the project root for the .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"evaluateInterval\">Seconds between score evaluations (the active action still Ticks every frame). Defaults to 0.25.</param>\r\n\t/// <param name=\"hysteresisBonus\">Score bonus the current action gets during evaluation \u2014 stickiness that prevents flip-flopping between near-tied actions. Defaults to 0.15.</param>\r\n\t/// <param name=\"moveSpeed\">Example WanderAction walk speed in world units/s. Defaults to 80.</param>\r\n\t/// <param name=\"wanderRadius\">Example WanderAction roam radius around its start position. Defaults to 300.</param>\r\n\t/// <param name=\"networked\">true (default): host-authoritative brain (IsProxy guard) + [Sync(FromHost)] CurrentActionName. false: local build for solo iteration.</param>\r\n\t/// <param name=\"targetId\">GUID of a GameObject to attach the BRAIN to (actions must be added separately; only attaches if the type is already in the TypeLibrary \u2014 hotload first).</param>\r\n\t[McpTool( \"create_utility_ai\" )]\r\n\tpublic static Task<object> CreateUtilityAi( string name = null, string directory = null, double? evaluateInterval = null, double? hysteresisBonus = null, double? moveSpeed = null, double? wanderRadius = null, bool? networked = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_utility_ai\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"evaluateInterval\", evaluateInterval ), ( \"hysteresisBonus\", hysteresisBonus ), ( \"moveSpeed\", moveSpeed ), ( \"wanderRadius\", wanderRadius ), ( \"networked\", networked ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Place a set of waypoint GameObjects (tagged empties) for a patrol route and group them under a\r\n\t/// parent route object \u2014 authorable in one call. Optionally snaps each point to the ground (raycast\r\n\t/// down) so waypoints sit on the navmesh, not floating. Returns the route parent GUID + ordered\r\n\t/// waypoint GUIDs to feed into assign_patrol_route. Validate connectivity afterward with\r\n\t/// get_navmesh_path between consecutive waypoints (catches a 'point in a wall').\r\n\t/// </summary>\r\n\t/// <param name=\"points\">Ordered world positions for the route (at least 2). JSON array.</param>\r\n\t/// <param name=\"name\">Route name. Defaults to 'PatrolRoute'. Waypoints are named <route>_WP0, _WP1, ...</param>\r\n\t/// <param name=\"tag\">Tag applied to each waypoint. Defaults to 'waypoint'.</param>\r\n\t/// <param name=\"snapToGround\">Drop each point onto the surface below via a downward raycast. Default true.</param>\r\n\t/// <param name=\"parentId\">Existing parent GameObject GUID to nest the waypoints under; otherwise a new route empty is created at the points' centroid.</param>\r\n\t[McpTool( \"place_patrol_route\" )]\r\n\tpublic static Task<object> PlacePatrolRoute( JsonNode points, string name = null, string tag = null, bool? snapToGround = null, string parentId = null )\r\n\t\t=> McpGate.Run( \"place_patrol_route\", McpGate.Args( ( \"points\", points ), ( \"name\", name ), ( \"tag\", tag ), ( \"snapToGround\", snapToGround ), ( \"parentId\", parentId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// READ-ONLY edit-mode verifier: evaluate the NPC's perception math RIGHT NOW without entering play\r\n\t/// mode. Given an NPC (reads its NpcBrain SightRange/FovDegrees/EyeHeight/TargetTag + transform)\r\n\t/// and either a targetId or a point, it runs the SAME line-of-sight check the brain uses \u2014 FOV cone\r\n\t/// (dot vs the baked cosine), sight-range gate, and an occlusion trace from the eye to the target \u2014\r\n\t/// and reports the result AND why. This is the keystone verifier: it makes the perception layer\r\n\t/// checkable in edit mode (no flaky screenshot timing) \u2014 e.g. place the Sasquatch, place a camper\r\n\t/// behind a tree, and confirm the tree blocks LOS. Call params override the brain's values, so it\r\n\t/// also works before/without an NpcBrain (uses defaults). Safe in play mode too (read-only, like\r\n\t/// raycast).\r\n\t/// </summary>\r\n\t/// <param name=\"npcId\">GUID of the NPC GameObject (ideally with an NpcBrain; its perception [Property] values are read).</param>\r\n\t/// <param name=\"targetId\">GUID of the target GameObject to test visibility to (e.g. a player). Provide this OR point.</param>\r\n\t/// <param name=\"point\">A raw world point to test visibility to. Provide this OR targetId. As \"x,y,z\" (or JSON {x,y,z}).</param>\r\n\t/// <param name=\"sightRange\">Override the sight range for this check (else read from the NpcBrain / default 1500).</param>\r\n\t/// <param name=\"fovDegrees\">Override the FOV cone angle for this check (else read from the NpcBrain / default 110).</param>\r\n\t/// <param name=\"eyeHeight\">Override the eye height for this check (else read from the NpcBrain / default 64).</param>\r\n\t/// <param name=\"targetTag\">Override the target tag (canSee also requires the target to carry this tag; else read from the NpcBrain / default 'player').</param>\r\n\t[McpTool( \"simulate_npc_perception\" )]\r\n\tpublic static Task<object> SimulateNpcPerception( string npcId, string targetId = null, string point = null, double? sightRange = null, double? fovDegrees = null, double? eyeHeight = null, string targetTag = null )\r\n\t\t=> McpGate.Run( \"simulate_npc_perception\", McpGate.Args( ( \"npcId\", npcId ), ( \"targetId\", targetId ), ( \"point\", point ), ( \"sightRange\", sightRange ), ( \"fovDegrees\", fovDegrees ), ( \"eyeHeight\", eyeHeight ), ( \"targetTag\", targetTag ) ) );\r\n}\r\n"
},
{
"Ident": "sboxskinsgg.claudebridge",
"Path": "Code/MyLibraryComponent.cs",
"FileName": "MyLibraryComponent.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 335526,
"Code": "using Sandbox;\r\n\r\n/// <summary>\r\n/// This is a component - in your library!\r\n/// </summary>\r\n[Title( \"claude bridge - My Component\" )]\r\npublic class MyLibraryComponent : Component\r\n{\r\n\r\n}\r\n"
},
{
"Ident": "sboxskinsgg.claudebridge",
"Path": "Editor/Mcp/BridgePrefabTools.cs",
"FileName": "BridgePrefabTools.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 335526,
"Code": "// AUTO-GENERATED by scripts/emit-mcp-wrappers.mjs \u2014 DO NOT EDIT.\r\n// Regenerate: node scripts/extract-manifest.mjs && node scripts/emit-mcp-wrappers.mjs\r\n// Source of truth: sbox-mcp-server/src/tools/ (zod schemas) \u2192 scripts/tools-manifest.json\r\n\r\nusing System.Text.Json.Nodes;\r\nusing System.Threading.Tasks;\r\nusing Editor.Mcp;\r\n\r\n/// <summary>\r\n/// Create prefabs from scene objects, instantiate them, list and inspect them, and wire prefab\r\n/// references into component properties.\r\n/// </summary>\r\n[McpToolset( \"bridge_prefab\", \"Create prefabs from scene objects, instantiate them, list and inspect them, and wire prefab references into component properties.\" )]\r\npublic static class BridgePrefabTools\r\n{\r\n\t/// <summary>\r\n\t/// Save an existing GameObject as a real .prefab file \u2014 FULL engine serialization: every component\r\n\t/// with its property values, and all children, in the same JSON format the editor writes. Returns {\r\n\t/// created, path, sourceId, components, children } \u2014 pass path to instantiate_prefab to spawn\r\n\t/// copies or get_prefab_info to inspect. Errors if the source GameObject is missing; overwrites an\r\n\t/// existing file at path.\r\n\t/// </summary>\r\n\t/// <param name=\"id\">GUID of the GameObject to save as prefab.</param>\r\n\t/// <param name=\"path\">Path for the prefab file relative to project root (e.g. 'prefabs/enemies/grunt.prefab').</param>\r\n\t[McpTool( \"create_prefab\" )]\r\n\tpublic static Task<object> CreatePrefab( string id, string path )\r\n\t\t=> McpGate.Run( \"create_prefab\", McpGate.Args( ( \"id\", id ), ( \"path\", path ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Inspect a prefab file as a structured summary: { path, name, size, modified, totalObjects,\r\n\t/// maxDepth, referencedPrefabs, tree } \u2014 tree is the object hierarchy with per-node component type\r\n\t/// lists (children capped at 8 per node with a truncation count). referencedPrefabs lists other\r\n\t/// .prefab files this one links to. Use before instantiate_prefab; find prefabs with list_prefabs;\r\n\t/// raw JSON via read_file if needed.\r\n\t/// </summary>\r\n\t/// <param name=\"path\">Path to the .prefab file (e.g. 'prefabs/enemies/grunt.prefab').</param>\r\n\t[McpTool.ReadOnly( \"get_prefab_info\" )]\r\n\tpublic static Task<object> GetPrefabInfo( string path )\r\n\t\t=> McpGate.Run( \"get_prefab_info\", McpGate.Args( ( \"path\", path ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Spawn a FULL prefab instance into the active scene \u2014 components and children recreated. Uses the\r\n\t/// engine's GameObject.Clone for registered prefabs, with a guid-remapped deserialize fallback for\r\n\t/// freshly-written files (repeat instantiations never collide). Returns { instantiated, prefab,\r\n\t/// method, gameObject, components, childCount } \u2014 gameObject.id is the new GUID for\r\n\t/// set_transform/set_property follow-ups. Optional name/position/rotation override the spawned\r\n\t/// root.\r\n\t/// </summary>\r\n\t/// <param name=\"path\">Path to the .prefab file (e.g. 'prefabs/enemies/grunt.prefab').</param>\r\n\t/// <param name=\"name\">Rename the spawned root (defaults to the prefab's root name).</param>\r\n\t/// <param name=\"position\">World position to spawn at \u2014 object {x,y,z} or comma string \"x,y,z\". Defaults to origin. As \"x,y,z\" (or JSON {x,y,z}).</param>\r\n\t/// <param name=\"rotation\">Rotation as euler angles. Defaults to identity. As \"pitch,yaw,roll\" degrees.</param>\r\n\t/// <param name=\"scale\">Uniform scale multiplier. Defaults to 1.0.</param>\r\n\t/// <param name=\"parent\">GUID of parent GameObject to attach to.</param>\r\n\t[McpTool( \"instantiate_prefab\" )]\r\n\tpublic static Task<object> InstantiatePrefab( string path, string name = null, string position = null, string rotation = null, double? scale = null, string parent = null )\r\n\t\t=> McpGate.Run( \"instantiate_prefab\", McpGate.Args( ( \"path\", path ), ( \"name\", name ), ( \"position\", position ), ( \"rotation\", rotation ), ( \"scale\", scale ), ( \"parent\", parent ) ) );\r\n\r\n\t/// <summary>\r\n\t/// List all .prefab files in the project. Filter by name or path.\r\n\t/// </summary>\r\n\t/// <param name=\"filter\">Search filter for prefab name or path.</param>\r\n\t/// <param name=\"maxResults\">Maximum results to return. Defaults to 100.</param>\r\n\t[McpTool.ReadOnly( \"list_prefabs\" )]\r\n\tpublic static Task<object> ListPrefabs( string filter = null, double? maxResults = null )\r\n\t\t=> McpGate.Run( \"list_prefabs\", McpGate.Args( ( \"filter\", filter ), ( \"maxResults\", maxResults ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Set a GameObject-typed property on a component to a loaded prefab. Use this when set_property\r\n\t/// can't handle prefab references (which it can't, because prefabs are GameObjects not primitives).\r\n\t/// </summary>\r\n\t/// <param name=\"id\">GUID of the GameObject holding the component.</param>\r\n\t/// <param name=\"component\">Component type name.</param>\r\n\t/// <param name=\"property\">Property name to set (must be GameObject-typed).</param>\r\n\t/// <param name=\"prefabPath\">Prefab asset path (e.g. 'prefabs/player.prefab').</param>\r\n\t[McpTool( \"set_prefab_ref\" )]\r\n\tpublic static Task<object> SetPrefabRef( string id, string component, string property, string prefabPath )\r\n\t\t=> McpGate.Run( \"set_prefab_ref\", McpGate.Args( ( \"id\", id ), ( \"component\", component ), ( \"property\", property ), ( \"prefabPath\", prefabPath ) ) );\r\n}\r\n"
},
{
"Ident": "sboxskinsgg.claudebridge",
"Path": "Editor/Mcp/BridgeScaffoldGameplayTools.cs",
"FileName": "BridgeScaffoldGameplayTools.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 335526,
"Code": "// AUTO-GENERATED by scripts/emit-mcp-wrappers.mjs \u2014 DO NOT EDIT.\r\n// Regenerate: node scripts/extract-manifest.mjs && node scripts/emit-mcp-wrappers.mjs\r\n// Source of truth: sbox-mcp-server/src/tools/ (zod schemas) \u2192 scripts/tools-manifest.json\r\n\r\nusing System.Text.Json.Nodes;\r\nusing System.Threading.Tasks;\r\nusing Editor.Mcp;\r\n\r\n/// <summary>\r\n/// Generate complete, compile-verified gameplay C# components: player/NPC controllers, game\r\n/// managers, health, pickups, inventory, save systems, economy, loot tables, round/phase machines,\r\n/// interaction systems, placement mode, and more. Each tool writes a .cs file into the project;\r\n/// follow with trigger_hotload + compile_status.\r\n/// </summary>\r\n[McpToolset( \"bridge_scaffold_gameplay\", \"Generate complete, compile-verified gameplay C# components: player/NPC controllers, game managers, health, pickups, inventory, save systems, economy, loot tables, round/phase machines, interaction systems, placement mode, and more. Each tool writes a .cs file into the project; follow with trigger_hotload + compile_status.\" )]\r\npublic static class BridgeScaffoldGameplayTools\r\n{\r\n\t/// <summary>\r\n\t/// SCENE-MUTATING: generate a data-driven achievement trigger-zone component AND create its\r\n\t/// GameObject now (named zone with a sized BoxCollider, IsTrigger=true, at the given position).\r\n\t/// When an object tagged triggerTag enters, the component calls\r\n\t/// <achievementSetClass>.Instance.Progress(achievementId, amount) \u2014 or Unlock() when\r\n\t/// unlock=true \u2014 with a once-only latch and optional destroy-after-fire. Returns { created, path,\r\n\t/// className, achievementSetClass, achievementId, gameObject, attached, note, nextSteps }. The\r\n\t/// generated component only attaches to the zone after trigger_hotload \u2014 until then `attached` is\r\n\t/// false and nextSteps carries the exact add_component_with_properties follow-up. The generated\r\n\t/// code references the set class BY NAME: run create_achievement_set first or the project will not\r\n\t/// compile (the result warns via `note`). Re-running with the same name fails unless\r\n\t/// reuseClass=true, which skips codegen and just places another zone (attaching + configuring\r\n\t/// immediately since the class is already compiled). Refused during play mode.\r\n\t/// </summary>\r\n\t/// <param name=\"achievementId\">Id of the achievement to progress/unlock (sanitized to [a-z0-9_-]).</param>\r\n\t/// <param name=\"name\">Class name for the generated trigger component. Defaults to 'AchievementTrigger'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"achievementSetClass\">Class name of the achievement set the zone reports to (from create_achievement_set). Defaults to 'AchievementSet'.</param>\r\n\t/// <param name=\"amount\">Progress amount added per fire (ignored when unlock=true). Defaults to 1.</param>\r\n\t/// <param name=\"unlock\">Call Unlock() instead of Progress(). Defaults to false.</param>\r\n\t/// <param name=\"triggerTag\">Tag the entering object must carry (put it on the player via set_tags). Defaults to 'player'.</param>\r\n\t/// <param name=\"onceOnly\">Only the first tagged entry fires. Defaults to true.</param>\r\n\t/// <param name=\"destroyAfterFire\">Destroy the zone GameObject after firing. Defaults to false.</param>\r\n\t/// <param name=\"createObject\">Create the zone GameObject now (with BoxCollider). Defaults to true; false = code-gen only.</param>\r\n\t/// <param name=\"objectName\">Name for the zone GameObject. Defaults to '<name>Zone'.</param>\r\n\t/// <param name=\"position\">World position of the zone GameObject. As \"x,y,z\" (or JSON {x,y,z}).</param>\r\n\t/// <param name=\"scale\">BoxCollider size \u2014 uniform number, object {x,y,z}, or comma string \"x,y,z\". Defaults to 100,100,100. As \"x,y,z\" (or JSON {x,y,z}).</param>\r\n\t/// <param name=\"reuseClass\">If the .cs already exists, skip codegen and just place another zone with the existing class. Defaults to false.</param>\r\n\t[McpTool( \"add_achievement_trigger\" )]\r\n\tpublic static Task<object> AddAchievementTrigger( string achievementId, string name = null, string directory = null, string achievementSetClass = null, double? amount = null, bool? unlock = null, string triggerTag = null, bool? onceOnly = null, bool? destroyAfterFire = null, bool? createObject = null, string objectName = null, string position = null, string scale = null, bool? reuseClass = null )\r\n\t\t=> McpGate.Run( \"add_achievement_trigger\", McpGate.Args( ( \"achievementId\", achievementId ), ( \"name\", name ), ( \"directory\", directory ), ( \"achievementSetClass\", achievementSetClass ), ( \"amount\", amount ), ( \"unlock\", unlock ), ( \"triggerTag\", triggerTag ), ( \"onceOnly\", onceOnly ), ( \"destroyAfterFire\", destroyAfterFire ), ( \"createObject\", createObject ), ( \"objectName\", objectName ), ( \"position\", position ), ( \"scale\", scale ), ( \"reuseClass\", reuseClass ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate an eye-traced interaction-prompt HUD \u2014 a PanelComponent (.razor + .razor.scss pair,\r\n\t/// like create_leaderboard_panel) that every frame traces a ray from the scene camera\r\n\t/// (Scene.Trace.Ray, out to [Property] float Range) and, when the crosshair is on a component\r\n\t/// implementing Component.IPressable, shows a centered \"Press E\"-style pill. The prompt text comes\r\n\t/// from the target's IPressable.GetTooltip() when it overrides it (most don't), else a [Property]\r\n\t/// DefaultPrompt built from the action. This is the visible half of the interaction loop: it PAIRS\r\n\t/// with create_interactable / add_interaction_station (which implement IPressable) \u2014 this tool\r\n\t/// tells the player they CAN press, those tools handle the press. Host it under a ScreenPanel\r\n\t/// (add_screen_panel), then add the component to that panel object. The generated Razor is\r\n\t/// razor_lint-safe by construction: PanelComponent + BuildHash override folding the visible state,\r\n\t/// no switch-expressions and no non-ASCII in @code, and a class root selector in the SCSS.\r\n\t/// LOCAL/visual-only (no [Sync]).\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class/file name for the generated .razor. Defaults to 'InteractionPrompt'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the generated .razor + .razor.scss. Defaults to 'Code/UI'.</param>\r\n\t/// <param name=\"action\">Verb woven into the default prompt text ('Press E to <action>'). Defaults to 'use'.</param>\r\n\t/// <param name=\"range\">Eye-trace reach in world units \u2014 how close the crosshair must be to a pressable to show the prompt. Defaults to 120.</param>\r\n\t[McpTool( \"add_interaction_prompt\" )]\r\n\tpublic static Task<object> AddInteractionPrompt( string name = null, string directory = null, string action = null, double? range = null )\r\n\t\t=> McpGate.Run( \"add_interaction_prompt\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"action\", action ), ( \"range\", range ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a Component.IPressable 'station' prop (crafting bench / shop till / arcade cabinet)\r\n\t/// that ONE user occupies at a time. Occupancy is host-authoritative: the occupant is a\r\n\t/// [Sync(SyncFlags.FromHost)] Guid (GameObject/Connection aren't [Sync]-able) and Press() routes\r\n\t/// the claim to the host via an [Rpc.Host] Occupy(). Includes a reservation grace window (the\r\n\t/// station stays reserved for its last user for graceSeconds after they leave, so a brief walk-away\r\n\t/// can't jump the queue), an optional unlock-level gate (users below requiredLevel can't use it \u2014\r\n\t/// wire the static ResolveUserLevel hook to your progression system to activate it), and an\r\n\t/// overlay-open hook (a static OnStationOpened(GameObject) event to open your UI, plus an opt-in\r\n\t/// [Rpc.Broadcast] mirror). Single-player safe. Optionally attached to an existing GameObject by\r\n\t/// GUID (only after a trigger_hotload). Give the prop a Collider so the player's use key can\r\n\t/// raycast it. Mined from interaction-station patterns across shipped s&box games.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name for the generated component. Defaults to 'InteractionStation'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the .cs file (path override). Defaults to 'Code'.</param>\r\n\t/// <param name=\"graceSeconds\">Seconds the station stays reserved for its last user after they leave, before anyone else can claim it. 0 = no grace window. Defaults to 5.</param>\r\n\t/// <param name=\"requiredLevel\">Unlock-level gate: users below this level can't use the station. 0 = no gate. The gate only bites once you wire the static ResolveUserLevel hook to your progression system. Defaults to 0.</param>\r\n\t/// <param name=\"targetId\">GUID of an existing GameObject to attach the station component to (only attaches if the type is already loaded \u2014 generate, trigger_hotload, then it places; otherwise add it after the hotload).</param>\r\n\t[McpTool( \"add_interaction_station\" )]\r\n\tpublic static Task<object> AddInteractionStation( string name = null, string directory = null, double? graceSeconds = null, int? requiredLevel = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"add_interaction_station\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"graceSeconds\", graceSeconds ), ( \"requiredLevel\", requiredLevel ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a batched write-side stat reporter component for Sandbox.Services.Stats \u2014 the write\r\n\t/// partner of create_leaderboard_panel. Gameplay code calls the static <Name>.Report(\"kills\",\r\n\t/// 1) from anywhere; amounts accumulate locally and flush as Stats.Increment deltas on a timer\r\n\t/// (default every 12 s, also on disable/destroy). Baseline-delta bookkeeping means a partial flush\r\n\t/// retries the un-sent remainder instead of double-counting, and deltas larger than maxChunk are\r\n\t/// sent in chunks. Returns { created, path, className, placedOn, note, nextSteps }. Place ONE in\r\n\t/// the scene after trigger_hotload (add_component_to_new_object), or pass targetId to attach\r\n\t/// immediately when the type is already compiled. Stats are PER LOCAL PLAYER (each client reports\r\n\t/// its own) and only exist on leaderboards once the stat is registered for the project ident on\r\n\t/// sbox.game. Fails if the file already exists.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name for the generated component. Defaults to 'StatReporter'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"flushIntervalSeconds\">Seconds between batched flushes to the backend. Defaults to 12, clamped to >= 1.</param>\r\n\t/// <param name=\"maxChunk\">Largest amount sent in a single Stats.Increment call; bigger deltas are chunked. Defaults to 1000, clamped to >= 1.</param>\r\n\t/// <param name=\"targetId\">GUID of a GameObject to attach to (only attaches if the type is already loaded \u2014 hotload first).</param>\r\n\t[McpTool( \"add_leaderboard_stat\" )]\r\n\tpublic static Task<object> AddLeaderboardStat( string name = null, string directory = null, double? flushIntervalSeconds = null, double? maxChunk = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"add_leaderboard_stat\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"flushIntervalSeconds\", flushIntervalSeconds ), ( \"maxChunk\", maxChunk ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a currency component (sealed) persisted over Sandbox.Services.Stats \u2014 Steam-cloud\r\n\t/// persistence, per Steam account, per package ident, with NO local save file. The stat stores the\r\n\t/// ABSOLUTE balance: every Add(double)/TrySpend(double) pushes Stats.SetValue(statName, balance);\r\n\t/// Flush() (and OnDestroy) pushes the buffered writes. On start it reads the balance back\r\n\t/// asynchronously via Stats.GetLocalPlayerStats(ident) -> Refresh() -> Get(statName).Value\r\n\t/// and fires the static OnBalanceLoaded(double); wait for IsLoaded before showing the balance.\r\n\t/// CLOUD SEMANTICS (surprising): stat writes are buffered/rate-limited by the backend and apply\r\n\t/// ONLY to the LOCAL Steam user \u2014 calling this for another player silently does nothing, so attach\r\n\t/// it to the LOCAL player's GameObject (IsProxy guards keep remote copies inert); read-back is\r\n\t/// eventually consistent and can lag minutes behind writes \u2014 the in-session Balance property is the\r\n\t/// runtime truth. Dev sessions without a real published package ident may read back nothing\r\n\t/// (balance starts 0 with a log line). packageIdent defaults to the running package (Game.Ident).\r\n\t/// Returns { created, path, className, statName, packageIdent, flushEveryChange, placedOn, note,\r\n\t/// nextSteps }. Next: trigger_hotload, attach to the local player, bind OnBalanceChanged for the\r\n\t/// HUD. Refused during play mode. Use create_economy_wallet/create_currency_account for in-run\r\n\t/// networked money, create_signed_save for offline local persistence; pair with\r\n\t/// create_leaderboard_panel (the same stat can back a leaderboard).\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name for the generated component. Defaults to 'SteamStatCurrency'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"statName\">Sandbox.Services stat that stores the balance (the stat-name string is the contract between write and read-back). Defaults to 'currency'.</param>\r\n\t/// <param name=\"packageIdent\">Package ident to read stats from. Omit/empty = the running package (Game.Ident).</param>\r\n\t/// <param name=\"flushEveryChange\">Call Stats.Flush() after every balance change instead of relying on the buffered flush + OnDestroy flush (the backend rate-limits flushes). Defaults to false.</param>\r\n\t/// <param name=\"targetId\">GUID of the LOCAL player's GameObject to attach to (only attaches if the type is already loaded \u2014 hotload first).</param>\r\n\t[McpTool( \"add_steam_stat_currency\" )]\r\n\tpublic static Task<object> AddSteamStatCurrency( string name = null, string directory = null, string statName = null, string packageIdent = null, bool? flushEveryChange = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"add_steam_stat_currency\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"statName\", statName ), ( \"packageIdent\", packageIdent ), ( \"flushEveryChange\", flushEveryChange ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate an achievement engine: a component with an AchievementDef list\r\n\t/// (id/title/description/target), per-achievement progress persisted via FileSystem.Data JSON\r\n\t/// (survives restarts), Progress(id, amount) / Unlock(id) API on a static Instance, a static\r\n\t/// OnAchievementUnlocked event, and an optional Stats.Increment mirror ('ach-<id>' += 1) on\r\n\t/// unlock. Also emits a Razor unlock-toast HUD (<Name>Toast.razor + .razor.scss, razor_lint\r\n\t/// clean) unless makeToast=false. Returns { created, path, className, toastRazorPath,\r\n\t/// toastScssPath, toastClassName, achievements, placedOn, note, nextSteps }. Ids are sanitized to\r\n\t/// [a-z0-9_-]; omitting achievements bakes 3 editable samples. After trigger_hotload: place ONE set\r\n\t/// in the scene, and host the toast under a ScreenPanel (add_screen_panel). Pair with\r\n\t/// add_achievement_trigger for world-trigger unlocks. LOCAL-only: achievements belong to each\r\n\t/// client's local player. Fails if the .cs or toast .razor already exists.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name for the generated engine component (toast panel becomes <name>Toast). Defaults to 'AchievementSet'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for all generated files. Defaults to 'Code'.</param>\r\n\t/// <param name=\"achievements\">Achievement definitions baked into the component. Omit for 3 editable samples (first_steps, collector, veteran). JSON array.</param>\r\n\t/// <param name=\"fileName\">Save file name inside FileSystem.Data. Defaults to 'achievements.json'.</param>\r\n\t/// <param name=\"mirrorToStats\">Mirror each unlock into Sandbox.Services.Stats as 'ach-<id>' += 1. Defaults to true.</param>\r\n\t/// <param name=\"makeToast\">Also emit the <name>Toast.razor + .razor.scss unlock toast HUD. Defaults to true.</param>\r\n\t/// <param name=\"toastSeconds\">Seconds each unlock toast stays on screen. Defaults to 4, clamped to >= 0.5.</param>\r\n\t/// <param name=\"targetId\">GUID of a GameObject to attach the engine to (only attaches if the type is already loaded \u2014 hotload first).</param>\r\n\t[McpTool( \"create_achievement_set\" )]\r\n\tpublic static Task<object> CreateAchievementSet( string name = null, string directory = null, JsonNode achievements = null, string fileName = null, bool? mirrorToStats = null, bool? makeToast = null, double? toastSeconds = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_achievement_set\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"achievements\", achievements ), ( \"fileName\", fileName ), ( \"mirrorToStats\", mirrorToStats ), ( \"makeToast\", makeToast ), ( \"toastSeconds\", toastSeconds ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a first-person pickup / carry / throw component (sealed Component) for physics props.\r\n\t/// Attach it to the PLAYER (the object that owns the camera). It eye-traces from Scene.Camera for a\r\n\t/// Rigidbody-bearing GameObject tagged [Property] CarryTag (default 'carryable') within [Property]\r\n\t/// Range; grabbing routes a host-authoritative [Rpc.Host] request that re-validates the target and\r\n\t/// caller, hands the object's network ownership to the carrier\r\n\t/// (GameObject.Network.AssignOwnership), and disables the rigidbody's MotionEnabled while held. The\r\n\t/// held object follows a hold point ([Property] Vector3 HoldOffset in front of the camera) each\r\n\t/// FixedUpdate; dropping restores physics, throwing applies an impulse ([Property] float\r\n\t/// ThrowForce). The held-object id is [Sync(SyncFlags.FromHost)] so proxies see the carrying state,\r\n\t/// and static OnPickedUp / OnDropped events fire uniformly for SFX/VFX. PAIRS with physics props \u2014\r\n\t/// give each carryable a Rigidbody + Collider and the CarryTag (set_tags); network-spawn them for\r\n\t/// multiplayer so ownership + transform replicate. Single-player safe (IsProxy is false and RPCs\r\n\t/// run locally with no session). Inputs: GrabAction (default 'use') grabs/drops, ThrowAction\r\n\t/// (default 'attack1') throws. Optionally attach to an existing player GameObject by GUID after a\r\n\t/// hotload.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name for the generated component. Defaults to 'CarrySystem'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"range\">Eye-trace reach for grabbing a carryable, in world units. Defaults to 130.</param>\r\n\t/// <param name=\"throwForce\">Impulse magnitude applied on throw (scales with the prop's mass \u2014 tune per game). Defaults to 20000.</param>\r\n\t/// <param name=\"carryTag\">Only objects with this tag (and a Rigidbody) can be picked up; lower-cased/underscored to match s&box tag convention. Defaults to 'carryable'.</param>\r\n\t/// <param name=\"targetId\">GUID of the PLAYER GameObject (the one with the camera) to attach to (only attaches if the type is already loaded \u2014 hotload first).</param>\r\n\t[McpTool( \"create_carry_system\" )]\r\n\tpublic static Task<object> CreateCarrySystem( string name = null, string directory = null, double? range = null, double? throwForce = null, string carryTag = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_carry_system\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"range\", range ), ( \"throwForce\", throwForce ), ( \"carryTag\", carryTag ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a host-authoritative currency ACCOUNT component (sealed) \u2014 the audited sibling of\r\n\t/// create_economy_wallet (wallet = simple money, account = money + a ledger). Balance is\r\n\t/// [Sync(SyncFlags.FromHost)] so clients can't author their own money; host-guarded Deposit(amount,\r\n\t/// reason), Withdraw(amount, reason) -> bool, and TryTransfer(otherAccount, amount, reason)\r\n\t/// -> bool each record a Transaction { Time (Time.Now), signed Amount, Reason, BalanceAfter }\r\n\t/// into a fixed-size ring buffer (historySize, default 32; oldest entries overwritten SILENTLY).\r\n\t/// GetRecentTransactions(max) returns them NEWEST FIRST \u2014 the ledger is HOST-SIDE ONLY and does not\r\n\t/// replicate (Balance does); proxies get an empty list. Bind the instance OnBalanceChanged(long)\r\n\t/// for HUD labels. Single-player safe. Returns { created, path, className, startingBalance,\r\n\t/// historySize, placedOn, note, nextSteps }. Next: trigger_hotload, then attach via targetId re-run\r\n\t/// or add_component_to_new_object. Refuses if the file already exists; refused during play mode.\r\n\t/// Use create_economy_wallet when you don't need the audit trail; pair with create_idle_economy (it\r\n\t/// auto-wires this account's Money/TrySpend).\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name for the generated component. Defaults to 'CurrencyAccount'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"startingBalance\">Balance the account opens with (host seeds it in OnStart). Defaults to 0.</param>\r\n\t/// <param name=\"historySize\">Transaction ring-buffer capacity (clamped 1..4096); fixed once the first transaction is recorded, oldest overwritten silently after that. Defaults to 32.</param>\r\n\t/// <param name=\"targetId\">GUID of a per-player/bank GameObject to attach to (only attaches if the type is already loaded \u2014 hotload first).</param>\r\n\t[McpTool( \"create_currency_account\" )]\r\n\tpublic static Task<object> CreateCurrencyAccount( string name = null, string directory = null, int? startingBalance = null, int? historySize = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_currency_account\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"startingBalance\", startingBalance ), ( \"historySize\", historySize ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a networked coin / currency pickup component (sealed, Component.ITriggerListener).\r\n\t/// Host-spawned; when a GameObject carrying PlayerTag ('player') enters its trigger the HOST\r\n\t/// validates and grants Value (default 1) into a wallet on the player, then destroys the pickup\r\n\t/// network-wide (the host Destroy() replicates \u2014 there is no NetworkDestroy on this SDK). Optional\r\n\t/// magnet: while MagnetRadius (default 0 = off) is > 0 the coin accelerates toward the nearest\r\n\t/// player each FixedUpdate (host-side, capped by MaxMagnetSpeed). IsProxy guards keep the grant +\r\n\t/// despawn host-only in multiplayer (NetworkSpawn the coin on the host); single-player works with\r\n\t/// no networking. The deposit is reflection-free and dependency-free: a static Grant seam is wired\r\n\t/// ONCE to the direct typed call \u2014 player.Components.Get<EconomyWallet>()?.AddMoney(amount) \u2014\r\n\t/// so the component compiles with NO hard reference to a specific wallet class (rename the wallet\r\n\t/// type if yours differs; mirrors create_pickup's self-contained convention). WalletComponentName\r\n\t/// (default 'EconomyWallet') is used to locate the wallet and name the fix if Grant is left unwired\r\n\t/// (never silent). Pairs with create_economy_wallet (AddMoney/TrySpend/CanAfford) and\r\n\t/// create_floating_combat_text (spawn a '+N' popup from OnCollected).\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name for the generated component. Defaults to 'CurrencyPickup'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"value\">How much currency the pickup grants into the wallet. Defaults to 1.</param>\r\n\t/// <param name=\"magnetRadius\">Magnet range in world units \u2014 within it the coin flies to the nearest player each FixedUpdate. 0 = magnet off. Defaults to 0.</param>\r\n\t/// <param name=\"walletComponentName\">Type name of the wallet component to deposit into (used to locate it and to name the fix if the Grant seam is left unwired). Defaults to 'EconomyWallet'.</param>\r\n\t/// <param name=\"targetId\">GUID of a coin GameObject to attach to \u2014 give it a trigger Collider (SphereCollider, IsTrigger=true). Only attaches if the type is already loaded \u2014 hotload first.</param>\r\n\t[McpTool( \"create_currency_pickup\" )]\r\n\tpublic static Task<object> CreateCurrencyPickup( string name = null, string directory = null, int? value = null, double? magnetRadius = null, string walletComponentName = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_currency_pickup\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"value\", value ), ( \"magnetRadius\", magnetRadius ), ( \"walletComponentName\", walletComponentName ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a host-authoritative time-of-day clock: [Sync(SyncFlags.FromHost)] TimeOfDay (0\u201324) +\r\n\t/// Day advancing by Time.Delta, IsDay/IsNight from sunrise/sunset hours, and static OnNewDay /\r\n\t/// OnDayNightChanged events to drive lighting, NPC schedules, or spawns. Single-player safe. Pairs\r\n\t/// with create_round_phase_machine. Optionally attached to a GameObject by GUID (after a hotload).\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name. Defaults to 'DayNightClock'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"dayLengthSeconds\">Real seconds per in-game day. Defaults to 600 (10 min).</param>\r\n\t/// <param name=\"startHour\">Hour the clock starts at (0\u201324). Defaults to 8.</param>\r\n\t/// <param name=\"sunriseHour\">Hour day begins. Defaults to 6.</param>\r\n\t/// <param name=\"sunsetHour\">Hour night begins. Defaults to 20.</param>\r\n\t/// <param name=\"targetId\">GUID of an existing GameObject to attach to (hotload first).</param>\r\n\t[McpTool( \"create_day_night_clock\" )]\r\n\tpublic static Task<object> CreateDayNightClock( string name = null, string directory = null, double? dayLengthSeconds = null, double? startHour = null, double? sunriseHour = null, double? sunsetHour = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_day_night_clock\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"dayLengthSeconds\", dayLengthSeconds ), ( \"startHour\", startHour ), ( \"sunriseHour\", sunriseHour ), ( \"sunsetHour\", sunsetHour ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a host-authoritative currency Wallet component: a [Sync(SyncFlags.FromHost)] Money\r\n\t/// balance (only the host can write it \u2014 plain [Sync] money is the classic economy exploit) with\r\n\t/// AddMoney / TrySpend / SetMoney / CanAfford and an OnMoneyChanged event. Single-player safe.\r\n\t/// Optionally attached to an existing GameObject by GUID (after a hotload). Pairs with a save\r\n\t/// system for persistence. Mined from the most-requested currency pattern across 51 games.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name. Defaults to 'Wallet'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"startingMoney\">Initial balance the host seeds on start. Defaults to 0.</param>\r\n\t/// <param name=\"targetId\">GUID of an existing GameObject to attach the Wallet to (only attaches if the type is already loaded \u2014 hotload first).</param>\r\n\t[McpTool( \"create_economy_wallet\" )]\r\n\tpublic static Task<object> CreateEconomyWallet( string name = null, string directory = null, int? startingMoney = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_economy_wallet\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"startingMoney\", startingMoney ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a self-contained, host-authoritative elo rating component: standard elo math (expected\r\n\t/// = 1/(1+10^((Rb-Ra)/400)), delta = K * (score - expected)) with a [Property] K-factor, ratings in\r\n\t/// a [Sync(SyncFlags.FromHost)] NetDictionary<long,float> keyed by SteamId, and host-side\r\n\t/// persistence via FileSystem.Data JSON. API on a static Instance: ReportMatch(winnerSteamId,\r\n\t/// loserSteamId) for 1v1 and ReportTeamMatch(winnerIds, loserIds) for teams (team-average elo,\r\n\t/// uniform delta per member) \u2014 both are IsProxy-guarded no-ops on clients; GetRating(steamId) works\r\n\t/// anywhere (unknown players = defaultRating); the static OnRatingChanged(steamId, newRating) fires\r\n\t/// on EVERY machine via an [Rpc.Broadcast]. Returns { created, path, className, kFactor,\r\n\t/// defaultRating, placedOn, note, nextSteps }. After trigger_hotload: place ONE in the scene and\r\n\t/// network its GameObject (network_spawn) or the [Sync] never replicates. Only the HOST's disk\r\n\t/// holds the ratings ledger. Fails if the file already exists.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name for the generated component. Defaults to 'EloRatingSystem'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"kFactor\">Elo K-factor \u2014 how far one result moves ratings (32 = fast, 16 = stable). Defaults to 32, clamped to >= 1.</param>\r\n\t/// <param name=\"defaultRating\">Rating assigned to players with no recorded matches. Defaults to 1000.</param>\r\n\t/// <param name=\"fileName\">Save file name inside FileSystem.Data (host-side ledger). Defaults to 'elo_ratings.json'.</param>\r\n\t/// <param name=\"targetId\">GUID of a GameObject to attach to (only attaches if the type is already loaded \u2014 hotload first).</param>\r\n\t[McpTool( \"create_elo_rating_system\" )]\r\n\tpublic static Task<object> CreateEloRatingSystem( string name = null, string directory = null, double? kFactor = null, double? defaultRating = null, string fileName = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_elo_rating_system\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"kFactor\", kFactor ), ( \"defaultRating\", defaultRating ), ( \"fileName\", fileName ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a typed LOCAL pub/sub event bus: a pure STATIC class (NOT a Component \u2014 nothing to\r\n\t/// place in the scene) with Subscribe<T>(owner, Action<T>), Unsubscribe(owner) (removes\r\n\t/// all of that owner's handlers across every event type), Publish<T>(evt) (synchronous,\r\n\t/// exact-type-T subscribers only, snapshot-iterated so handlers may subscribe/unsubscribe\r\n\t/// mid-publish), Count<T>() and Clear(), keyed by a plain Dictionary<Type,\r\n\t/// List<(object, Delegate)>> \u2014 plus a tiny example event record ({name}Ping). Decouples\r\n\t/// game systems: the quest system publishes 'EnemyDied', UI and achievements subscribe, neither\r\n\t/// knows the other. Returns {created, path, className, exampleEvent, api[], note}. Next:\r\n\t/// trigger_hotload + get_compile_errors, then Subscribe in components' OnStart and \u2014 REQUIRED \u2014\r\n\t/// Unsubscribe(this) in OnDestroy: handler lists hold PLAIN references (no weak refs), so a\r\n\t/// component that never unsubscribes leaks itself for the scene's life; call Clear() on scene\r\n\t/// teardown. Limits: LOCAL only \u2014 Publish reaches the calling machine's subscribers, NOT other\r\n\t/// clients; for networked events pair with [Rpc.Broadcast]/[Rpc.Host] methods that Publish on\r\n\t/// arrival. No base-type dispatch (Publish<Base> won't reach Subscribe<Derived>).\r\n\t/// Refuses to overwrite an existing file; refused during play mode.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Static class/file name. Defaults to 'EventBus'. The example event record is named {name}Ping. Sanitized to a valid C# identifier.</param>\r\n\t/// <param name=\"directory\">Subdirectory under the project root for the .cs file. Defaults to 'Code'.</param>\r\n\t[McpTool( \"create_event_bus\" )]\r\n\tpublic static Task<object> CreateEventBus( string name = null, string directory = null )\r\n\t\t=> McpGate.Run( \"create_event_bus\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a generalized L4D-style AI/pacing director component (host-authoritative). On a\r\n\t/// configurable interval the host rolls a weighted pick over a [Property] List<GameObject>\r\n\t/// EventPrefabs (with a parallel List<float> Weights), skips any event already active\r\n\t/// (dedupe) and anything past a MaxActive concurrency cap, clones the chosen prefab, NetworkSpawns\r\n\t/// it, and attaches a generated {name}TimedEvent companion so each spawned event self-destructs\r\n\t/// after EventLifetime seconds. Great for ambient events, waves, and world events. Single-player\r\n\t/// safe (IsProxy guard; NetworkSpawn falls back to a local clone). Fill EventPrefabs/Weights in the\r\n\t/// inspector or via the bridge after a hotload; edit the RollInterval() stub to make pacing\r\n\t/// adaptive (player-count/inactivity/time-pressure factors) per the ai-director cookbook.\r\n\t/// Optionally attached to an existing GameObject by GUID (after a hotload). NOTE: emits ONE .cs\r\n\t/// file containing two classes ({name} + {name}TimedEvent); the type only resolves after\r\n\t/// trigger_hotload.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name for the director (a {name}TimedEvent companion is generated alongside it). Defaults to 'EventDirector'.</param>\r\n\t/// <param name=\"path\">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"intervalSeconds\">Base seconds between director rolls. Defaults to 30 (clamped to >= 0.1).</param>\r\n\t/// <param name=\"maxActive\">Maximum number of concurrently-live events. Defaults to 3 (clamped to >= 1).</param>\r\n\t/// <param name=\"eventLifetime\">Seconds before each spawned event self-destructs. Defaults to 60 (clamped to >= 0.1).</param>\r\n\t/// <param name=\"targetId\">GUID of an existing GameObject to attach the director to (only attaches if the type is already loaded \u2014 hotload first).</param>\r\n\t[McpTool( \"create_event_director\" )]\r\n\tpublic static Task<object> CreateEventDirector( string name = null, string path = null, double? intervalSeconds = null, int? maxActive = null, double? eventLifetime = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_event_director\", McpGate.Args( ( \"name\", name ), ( \"path\", path ), ( \"intervalSeconds\", intervalSeconds ), ( \"maxActive\", maxActive ), ( \"eventLifetime\", eventLifetime ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a host-authoritative gacha / loot-box roller component. Two-level pick: parallel\r\n\t/// [Property] lists RarityNames + RarityWeights select a RARITY by cumulative weight (the\r\n\t/// create_weighted_loot_table shape), then a flat 'Rarity:Item' [Property] list (e.g.\r\n\t/// 'Legendary:Dragon Fang') picks an ITEM uniformly within that rarity \u2014 simple and\r\n\t/// inspector-editable. A pity counter (PityAfter, default 50) guarantees the rarest tier (the LAST\r\n\t/// entry in RarityNames) after N rolls without it and resets on a hit. Duplicate detection against\r\n\t/// an owned-items set fires a host-side OnDuplicate hook (marked TODO: convert dupes to\r\n\t/// shards/currency). Roll() routes to the host via an [Rpc.Host] RequestRoll (Rpc.Caller\r\n\t/// re-validated \u2014 NetFlags is not security) and the result fans out via [Rpc.Broadcast] so every\r\n\t/// machine fires the static OnRolled(rarity, item, isDuplicate) event; single-player safe (RPCs run\r\n\t/// locally). Use create_weighted_loot_table instead for a simpler single-tier weighted pick with no\r\n\t/// pity/dupe/networking. Pairs with create_economy_wallet (spend currency to roll) and\r\n\t/// create_inventory (store the pulls).\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name for the generated component. Defaults to 'GachaDropTable'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"pityAfter\">Rolls without a rarest-tier hit before the next roll is guaranteed rarest. 0 disables pity. Defaults to 50.</param>\r\n\t/// <param name=\"targetId\">GUID of a per-player/manager GameObject to attach to (only attaches if the type is already loaded \u2014 hotload first).</param>\r\n\t[McpTool( \"create_gacha_drop_table\" )]\r\n\tpublic static Task<object> CreateGachaDropTable( string name = null, string directory = null, int? pityAfter = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_gacha_drop_table\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"pityAfter\", pityAfter ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a minimal game-manager Component: a static Instance singleton, [Property] MaxPlayers /\r\n\t/// GameState, and a Component.INetworkListener OnActive hook that logs player connects. Writes\r\n\t/// <name>.cs and returns { created, path, className }. NOTE: the\r\n\t/// includeScore/includeTimer/includeSpawning params are not currently applied \u2014 the same minimal\r\n\t/// manager is always generated (for richer game-loop scaffolds see create_round_phase_machine /\r\n\t/// create_objective_system / create_economy_wallet). Follow with trigger_hotload, then\r\n\t/// get_compile_errors, then place via add_component_to_new_object.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name. Defaults to 'GameManager'.</param>\r\n\t/// <param name=\"directory\">Subdirectory under code/ for the file.</param>\r\n\t/// <param name=\"includeScore\">Include score tracking (currently not applied by the handler).</param>\r\n\t/// <param name=\"includeTimer\">Include round timer with countdown (currently not applied by the handler).</param>\r\n\t/// <param name=\"includeSpawning\">Include player spawning from prefab at spawn point (currently not applied by the handler).</param>\r\n\t[McpTool( \"create_game_manager\" )]\r\n\tpublic static Task<object> CreateGameManager( string name = null, string directory = null, bool? includeScore = null, bool? includeTimer = null, bool? includeSpawning = null )\r\n\t\t=> McpGate.Run( \"create_game_manager\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"includeScore\", includeScore ), ( \"includeTimer\", includeTimer ), ( \"includeSpawning\", includeSpawning ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a Health component: MaxHealth, [Sync] CurrentHealth, TakeDamage/Heal, an OnDeath event,\r\n\t/// optional regen and respawn. Host-authoritative damage when networked, single-player safe.\r\n\t/// Optionally attached to an existing GameObject by GUID.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name. Defaults to 'Health'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"maxHealth\">Starting/maximum health. Defaults to 100.</param>\r\n\t/// <param name=\"regen\">Include passive health regeneration after a delay. Defaults to false.</param>\r\n\t/// <param name=\"respawn\">On death, respawn at a RespawnPoint (wire it with set_component_reference) instead of disabling. Defaults to false.</param>\r\n\t/// <param name=\"targetId\">GUID of an existing GameObject to attach the Health component to (only attaches if the type is already loaded \u2014 hotload first).</param>\r\n\t[McpTool( \"create_health_system\" )]\r\n\tpublic static Task<object> CreateHealthSystem( string name = null, string directory = null, double? maxHealth = null, bool? regen = null, bool? respawn = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_health_system\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"maxHealth\", maxHealth ), ( \"regen\", regen ), ( \"respawn\", respawn ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a hold-to-confirm action component (sealed Component). While a named input action is\r\n\t/// held (Input.Down), a public Progress value fills 0\u21921 over [Property] float HoldSeconds;\r\n\t/// releasing early snaps back to 0, or drains down if [Property] bool DecayOnRelease. Reaching 1\r\n\t/// fires the static OnConfirmed(GameObject) event, then a short CooldownSeconds blocks\r\n\t/// re-triggering. The classic 'hold E to disarm / open / revive' interaction. No UI is generated \u2014\r\n\t/// read the public Progress (0..1) from your own HUD to draw a radial or bar; a #region Feedback\r\n\t/// hook marks where to tie in a sound/effect. LOCAL/owner-only: input is IsProxy-guarded so it\r\n\t/// never fires on proxies and is single-player safe. For a host-authoritative outcome, call an\r\n\t/// [Rpc.Host] from inside the OnConfirmed subscriber. Attach to the player (or any owned object\r\n\t/// that reads input); optionally attach to an existing GameObject by GUID after a hotload.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name for the generated component. Defaults to 'HoldToConfirm'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"action\">Input action name that must be held (must exist in the project's Input settings \u2014 see ensure_input_action). Defaults to 'use'.</param>\r\n\t/// <param name=\"holdSeconds\">Seconds of continuous hold required to confirm. Defaults to 1.5.</param>\r\n\t/// <param name=\"decayOnRelease\">Baked default for DecayOnRelease: if true, releasing early drains Progress back down instead of snapping to 0 (editable per-instance). Defaults to false.</param>\r\n\t/// <param name=\"targetId\">GUID of a GameObject to attach the component to (only attaches if the type is already loaded \u2014 hotload first).</param>\r\n\t[McpTool( \"create_hold_to_confirm\" )]\r\n\tpublic static Task<object> CreateHoldToConfirm( string name = null, string directory = null, string action = null, double? holdSeconds = null, bool? decayOnRelease = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_hold_to_confirm\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"action\", action ), ( \"holdSeconds\", holdSeconds ), ( \"decayOnRelease\", decayOnRelease ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a geometric idle-economy component (sealed): generators on the classic BaseCost *\r\n\t/// Growth^Owned cost curve with Buy 1 / Buy N / Buy Max \u2014 CostOf(index, count),\r\n\t/// MaxAffordable(index), TryBuy(index, count) and BuyMax(index) all use the CLOSED-FORM geometric\r\n\t/// series (cost = c0*(g^n-1)/(g-1), buyMax = floor(log_g(funds*(g-1)/c0+1))) \u2014 no per-copy loops,\r\n\t/// Buy 1000 is the same math as Buy 1. Wallet wiring is TypeLibrary reflection with NO compile-time\r\n\t/// wallet dependency (the shipped create_idle_income pattern): each income tick invokes\r\n\t/// AddMoney(long|int) on the first sibling component that has one, purchases invoke\r\n\t/// TrySpend(long|int), Buy Max reads the sibling's Money (or Balance) property \u2014 works out of the\r\n\t/// box next to create_economy_wallet or create_currency_account; with NO wallet sibling, purchases\r\n\t/// are refused with a Log.Warning (never silent) while TotalEarned still accumulates.\r\n\t/// Host-authoritative: mutations IsProxy-guarded; owned counts are HOST-SIDE state (not\r\n\t/// replicated); TotalEarned is [Sync(FromHost)]. Static events OnPurchased(index, count, cost) and\r\n\t/// OnIncomeTick(amount, total). BuyMax steps down once past a whole-currency rounding edge rather\r\n\t/// than failing. Returns { created, path, className, generators, tickSeconds, placedOn, note,\r\n\t/// nextSteps }. Next: trigger_hotload, place it NEXT TO a wallet on the same GameObject, tune the\r\n\t/// parallel GeneratorNames/BaseCosts/Growths/IncomesPerSecond lists with set_property. Refused\r\n\t/// during play mode. Pair with create_offline_progress for away-time earnings; use\r\n\t/// create_idle_income for a bare income ticker with no purchasing.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name for the generated component. Defaults to 'IdleEconomy'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"tickSeconds\">Seconds between income grants (floored at 0.1). Defaults to 1.</param>\r\n\t/// <param name=\"generators\">Baked-in generator defaults (inspector-tunable after generation). Omit for a starter trio: Cursor 15/1.15/0.5, Farm 200/1.15/4, Factory 3000/1.12/30. JSON array.</param>\r\n\t/// <param name=\"targetId\">GUID of the GameObject to attach to \u2014 put it on the SAME GameObject as the wallet so the reflection wiring finds it (only attaches if the type is already loaded \u2014 hotload first).</param>\r\n\t[McpTool( \"create_idle_economy\" )]\r\n\tpublic static Task<object> CreateIdleEconomy( string name = null, string directory = null, double? tickSeconds = null, JsonNode generators = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_idle_economy\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"tickSeconds\", tickSeconds ), ( \"generators\", generators ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a host-authoritative passive income component: every tickSeconds the host grants\r\n\t/// incomePerTick \u00d7 Multiplier, auto-wiring the first sibling component with an AddMoney(int) method\r\n\t/// (a create_economy_wallet scaffold plugs in with zero code) or an overridable Grant() seam;\r\n\t/// TotalEarned is [Sync(FromHost)] and static OnIncomeTick fires per grant. The idle-game kit:\r\n\t/// wallet (create_economy_wallet) + this + create_offline_progress. Writes a .cs file and returns {\r\n\t/// created, path, className, nextSteps } \u2014 follow with trigger_hotload + compile_status.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class/file name (default 'IdleIncome' -> Code/IdleIncome.cs). Errors if the file exists.</param>\r\n\t/// <param name=\"directory\">Directory for the .cs file. Default 'Code'.</param>\r\n\t/// <param name=\"incomePerTick\">Amount granted per tick. Default 1.</param>\r\n\t/// <param name=\"tickSeconds\">Seconds between grants. Default 1.</param>\r\n\t[McpTool( \"create_idle_income\" )]\r\n\tpublic static Task<object> CreateIdleIncome( string name = null, string directory = null, double? incomePerTick = null, double? tickSeconds = null )\r\n\t\t=> McpGate.Run( \"create_idle_income\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"incomePerTick\", incomePerTick ), ( \"tickSeconds\", tickSeconds ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a Component.IPressable interactable: the built-in PlayerController 'use' key drives\r\n\t/// Press()/Hover()/Blur() with no custom player code. Includes a static OnPressed event, an\r\n\t/// optional cooldown (TimeUntil), and a private OnPress() extensionpoint for effects. For\r\n\t/// host-authoritative side-effects call an [Rpc.Host] from OnPress(). The Prompt property is left\r\n\t/// to your game's HUD. Optionally attached to an existing GameObject by GUID (after a hotload).\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name. Defaults to 'Interactable'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"prompt\">Prompt string shown by the game's HUD when hovering. Defaults to 'Press'.</param>\r\n\t/// <param name=\"cooldownSeconds\">Seconds before the interactable can be pressed again. 0 = no cooldown. Defaults to 0.</param>\r\n\t/// <param name=\"targetId\">GUID of an existing GameObject to attach the component to (only attaches if the type is already loaded \u2014 hotload first).</param>\r\n\t[McpTool( \"create_interactable\" )]\r\n\tpublic static Task<object> CreateInteractable( string name = null, string directory = null, string prompt = null, double? cooldownSeconds = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_interactable\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"prompt\", prompt ), ( \"cooldownSeconds\", cooldownSeconds ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a slot-based inventory component using parallel List<string> ItemIds /\r\n\t/// List<int> Counts (serialization-safe, inspector-editable). Includes TryAdd (stack-first,\r\n\t/// partial-add rejected), TryRemove, CountOf, Move (swap or merge same-id slots), and Clear. Static\r\n\t/// OnChanged event fires after every successful mutation. Host-authoritative usage note: mutate on\r\n\t/// the host in multiplayer, replicate via your own [Sync]/RPC. Pairs with create_pickup.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name. Defaults to 'Inventory'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"capacity\">Total slot count. Defaults to 24.</param>\r\n\t/// <param name=\"maxStack\">Maximum items per slot (stack cap). Defaults to 99.</param>\r\n\t/// <param name=\"targetId\">GUID of an existing GameObject to attach to (hotload first).</param>\r\n\t[McpTool( \"create_inventory\" )]\r\n\tpublic static Task<object> CreateInventory( string name = null, string directory = null, int? capacity = null, int? maxStack = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_inventory\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"capacity\", capacity ), ( \"maxStack\", maxStack ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a Razor PanelComponent that fetches and displays a Sandbox.Services leaderboard derived\r\n\t/// from a stat name. Produces TWO files: {name}.razor and {name}.razor.scss. The panel\r\n\t/// auto-refreshes every 30 s, shows rank/displayName/value rows, handles loading state, and\r\n\t/// includes a BuildHash() override (razor-lint clean). Must be hosted under a ScreenPanel or\r\n\t/// WorldPanel. Stats must be configured for the project ident on sbox.game. Uses\r\n\t/// Leaderboards.Get(statName) + board.Refresh() -- the exact API from ServicesQueryHandler. Returns\r\n\t/// { created, razorPath, scssPath, className, note }. Follow with trigger_hotload, then\r\n\t/// get_compile_errors, then host it via add_screen_panel (panelComponent=className).\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name for the panel component. Defaults to 'LeaderboardPanel'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the generated files. Defaults to 'Code/UI'.</param>\r\n\t/// <param name=\"statName\">Sandbox.Services stat name the leaderboard is derived from. Defaults to 'score'.</param>\r\n\t/// <param name=\"title\">Display title shown at the top of the panel. Defaults to 'Leaderboard'.</param>\r\n\t/// <param name=\"maxRows\">Maximum leaderboard rows to fetch and display. Defaults to 10.</param>\r\n\t[McpTool( \"create_leaderboard_panel\" )]\r\n\tpublic static Task<object> CreateLeaderboardPanel( string name = null, string directory = null, string statName = null, string title = null, int? maxRows = null )\r\n\t\t=> McpGate.Run( \"create_leaderboard_panel\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"statName\", statName ), ( \"title\", title ), ( \"maxRows\", maxRows ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate GameResource-based loot tables \u2014 the data-asset sibling of create_weighted_loot_table.\r\n\t/// One .cs file containing THREE types: an entry POCO { Name, Weight, optional NestedTable\r\n\t/// reference }, a [AssetType]-registered GameResource loot-table class (designers author '.loot'\r\n\t/// files in the editor asset browser \u2014 New > Loot Table \u2014 after the hotload; NOTE:\r\n\t/// [AssetType(Name=..., Extension=..., Category=...)] is used because GameResourceAttribute is\r\n\t/// [Obsolete] on this SDK), and a '<name>Resolver' Component that rolls an assigned table by\r\n\t/// cumulative weight. Nested tables: an entry with a NestedTable rolls INTO that table instead of\r\n\t/// dropping its Name, capped at maxDepth (default 4) with a self-reference guard so cycles\r\n\t/// terminate (at the cap the deepest entry's Name is returned). Resolver.Roll() returns the item\r\n\t/// name (null + warning when no Table is assigned or the table is empty; entries with weight <=\r\n\t/// 0 never win; all-zero weights fall back to the first entry) and fires the static\r\n\t/// OnLoot(GameObject, item) event; roll HOST-SIDE and replicate the result yourself. targetId\r\n\t/// attaches the RESOLVER (the resource is an asset type, not a component). SURPRISING: pick an\r\n\t/// extension that is NOT a suffix of a built-in one (e.g. avoid 'cfg') or ResourceLibrary picks up\r\n\t/// engine files as phantom instances. Returns { created, path, className, resolverClass, extension,\r\n\t/// maxDepth, placedOn, note, nextSteps }. Next: trigger_hotload -> author .loot assets in the\r\n\t/// editor -> assign the resolver's Table (set_property with the asset path). Refused during play\r\n\t/// mode. Use create_weighted_loot_table for a single inline component with no asset files;\r\n\t/// create_gacha_drop_table for pity + duplicate mechanics.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name for the generated GameResource (the resolver becomes '<name>Resolver'). Defaults to 'LootTableResource'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"extension\">Asset file extension (lowercase alphanumerics; avoid suffixes of built-in extensions like 'cfg'). Defaults to 'loot'.</param>\r\n\t/// <param name=\"title\">Display name of the asset type in the editor's New-asset menu. Defaults to 'Loot Table'.</param>\r\n\t/// <param name=\"maxDepth\">Default nested-table resolve depth cap baked into the resolver (clamped 0..16; also a [Property]). Defaults to 4.</param>\r\n\t/// <param name=\"targetId\">GUID of a GameObject to attach the RESOLVER component to (only attaches if the type is already loaded \u2014 hotload first).</param>\r\n\t[McpTool( \"create_loot_table_resource\" )]\r\n\tpublic static Task<object> CreateLootTableResource( string name = null, string directory = null, string extension = null, string title = null, int? maxDepth = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_loot_table_resource\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"extension\", extension ), ( \"title\", title ), ( \"maxDepth\", maxDepth ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a between-runs roguelite meta-progression component (sealed, owner-only): persistent\r\n\t/// meta-currency + an unlock-flag dictionary saved to FileSystem.Data JSON (dirty-flag autosave +\r\n\t/// OnDestroy, the create_save_system shape). API: Grant(long), TrySpend(long) -> bool,\r\n\t/// Unlock(key) (idempotent \u2014 the static OnUnlocked(key) event fires only on the FIRST unlock, and\r\n\t/// unlocks write through to disk immediately), IsUnlocked(key) -> bool, and the run-end seam\r\n\t/// BankRun(int earned) which converts a finished run's earnings into meta-currency, bumps\r\n\t/// RunsBanked, and saves immediately \u2014 call it from your round machine's end-of-run transition\r\n\t/// (create_round_state_machine / create_round_phase_machine). Instance OnCurrencyChanged(long)\r\n\t/// drives meta-shop balance labels. Versioned payload: old-version files start fresh.\r\n\t/// IsProxy-guarded \u2014 in multiplayer each machine banks only its own local meta file (this is\r\n\t/// per-machine persistence, not a server economy). Returns { created, path, className, fileName,\r\n\t/// version, placedOn, note, nextSteps }. Next: trigger_hotload, attach to a persistent\r\n\t/// hub/menu-scene manager GameObject, gate content with IsUnlocked when building the player.\r\n\t/// Refused during play mode. Pair with create_currency_account (in-run money) and\r\n\t/// create_signed_save (if the meta file needs tamper evidence \u2014 this one is unsigned).\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name for the generated component. Defaults to 'MetaProgression'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"fileName\">FileSystem.Data path the meta state is written to. Defaults to 'meta.json'.</param>\r\n\t/// <param name=\"version\">Payload version; mismatched files start fresh. Defaults to 1.</param>\r\n\t/// <param name=\"autosaveSeconds\">Dirty-flag autosave cadence in seconds; 0 disables the heartbeat (unlocks and BankRun still write through immediately). Defaults to 10.</param>\r\n\t/// <param name=\"targetId\">GUID of a persistent manager GameObject to attach to (only attaches if the type is already loaded \u2014 hotload first).</param>\r\n\t[McpTool( \"create_meta_progression\" )]\r\n\tpublic static Task<object> CreateMetaProgression( string name = null, string directory = null, string fileName = null, int? version = null, double? autosaveSeconds = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_meta_progression\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"fileName\", fileName ), ( \"version\", version ), ( \"autosaveSeconds\", autosaveSeconds ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a sim/tycoon needs engine component: a [Property] list of need definitions (name, decay\r\n\t/// rate/s, critical threshold, weight) with per-need 0..100 values that decay over Time.Delta,\r\n\t/// Satisfy(name, amount) to restore, an aggregate Happiness (weighted mean, [Sync(FromHost)] when\r\n\t/// networked), and static OnNeedCritical (edge-triggered: fires once crossing below threshold,\r\n\t/// re-arms above) + OnHappinessChanged (>0.25-point moves) events. Returns {created, path,\r\n\t/// className, needs[], propertyNames[], note}. Next: trigger_hotload, get_compile_errors, then\r\n\t/// attach via targetId re-call or add_component_with_properties; drive from game code (e.g. a\r\n\t/// create_interactable that calls Satisfy). Limits: per-need values live on the simulating machine\r\n\t/// only (host) \u2014 sync per-need UI yourself via RPCs; events fire on the simulating machine only;\r\n\t/// networked default true means a no-session solo playtest won't tick (everything is a proxy) \u2014\r\n\t/// pass networked:false to iterate solo. Refused during play mode; refuses to overwrite an existing\r\n\t/// file.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class/file name. Defaults to 'NeedsSystem'. Sanitized to a valid C# identifier.</param>\r\n\t/// <param name=\"directory\">Subdirectory under the project root for the .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"needs\">Need definitions baked as inspector-editable defaults. Defaults to the classic sim trio: Hunger(0.8/s), Energy(0.5/s), Fun(0.3/s). JSON array.</param>\r\n\t/// <param name=\"networked\">true (default): host-authoritative (IsProxy guard) + [Sync(FromHost)] Happiness \u2014 needs a host session. false: local build that ticks in a solo playtest.</param>\r\n\t/// <param name=\"targetId\">GUID of a GameObject to attach the component to (only attaches if the type is already in the TypeLibrary \u2014 hotload first, then re-call or use add_component_with_properties).</param>\r\n\t[McpTool( \"create_needs_system\" )]\r\n\tpublic static Task<object> CreateNeedsSystem( string name = null, string directory = null, JsonNode needs = null, bool? networked = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_needs_system\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"needs\", needs ), ( \"networked\", networked ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate an NPC controller script with NavMeshAgent pathfinding. Supports patrol, chase, and\r\n\t/// patrol-chase behaviors.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name. Defaults to 'NpcController'.</param>\r\n\t/// <param name=\"directory\">Subdirectory under code/ for the file.</param>\r\n\t/// <param name=\"behavior\">AI behavior: 'patrol' (follow waypoints), 'chase' (follow player), 'patrol_chase' (patrol until player nearby). Defaults to 'patrol'. One of: patrol | chase | patrol_chase.</param>\r\n\t/// <param name=\"moveSpeed\">Movement speed. Defaults to 150.</param>\r\n\t/// <param name=\"chaseRange\">Detection range for chase behavior. Defaults to 500.</param>\r\n\t[McpTool( \"create_npc_controller\" )]\r\n\tpublic static Task<object> CreateNpcController( string name = null, string directory = null, string behavior = null, double? moveSpeed = null, double? chaseRange = null )\r\n\t\t=> McpGate.Run( \"create_npc_controller\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"behavior\", behavior ), ( \"moveSpeed\", moveSpeed ), ( \"chaseRange\", chaseRange ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate an ObjectiveManager component \u2014 the win/lose brain of a game. Tracks an objective\r\n\t/// (collect_all / reach_goal / survive_time / eliminate_all), fires a win, and handles a lose\r\n\t/// condition (fall below kill-Z / timer / out of lives). Self-contained C#; other systems call\r\n\t/// ObjectiveManager.Instance. Optionally placed as a scene singleton. Returns { created, path,\r\n\t/// className, gameObject, note } \u2014 gameObject is the placed singleton, or null with a note when the\r\n\t/// fresh type isn't in the TypeLibrary yet. Follow with trigger_hotload, then get_compile_errors;\r\n\t/// if placement was skipped, place with add_component_to_new_object after the hotload.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name. Defaults to 'ObjectiveManager'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"objective\">Win condition. Defaults to 'reach_goal'. One of: collect_all | reach_goal | survive_time | eliminate_all.</param>\r\n\t/// <param name=\"targetCount\">How many to collect/eliminate (for collect_all / eliminate_all). Defaults to 3.</param>\r\n\t/// <param name=\"timeLimit\">Seconds \u2014 survive this long to win (survive_time) or before losing (loseOn=timer). Defaults to 60.</param>\r\n\t/// <param name=\"loseOn\">Lose condition. 'fall' = player drops below killZ. Defaults to 'fall'. One of: fall | timer | lives | none.</param>\r\n\t/// <param name=\"killZ\">World Z below which the player is considered fallen out of the world. Defaults to -1000.</param>\r\n\t/// <param name=\"lives\">Lives before game over (loseOn=lives). Defaults to 1.</param>\r\n\t/// <param name=\"placeInScene\">Place the manager as a scene singleton. Defaults to true. (Only attaches if the type is already loaded \u2014 generate, hotload, then it places; otherwise add it after hotload.).</param>\r\n\t[McpTool( \"create_objective_system\" )]\r\n\tpublic static Task<object> CreateObjectiveSystem( string name = null, string directory = null, string objective = null, int? targetCount = null, double? timeLimit = null, string loseOn = null, double? killZ = null, int? lives = null, bool? placeInScene = null )\r\n\t\t=> McpGate.Run( \"create_objective_system\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"objective\", objective ), ( \"targetCount\", targetCount ), ( \"timeLimit\", timeLimit ), ( \"loseOn\", loseOn ), ( \"killZ\", killZ ), ( \"lives\", lives ), ( \"placeInScene\", placeInScene ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate an offline / idle-progress component (sealed, owner/host-only) \u2014 the idle-game staple.\r\n\t/// Persists LastSeenUtc (DateTime) to FileSystem.Data JSON on a dirty-flag autosave heartbeat\r\n\t/// (AutosaveSeconds) and on OnDisabled, copying create_save_system's persistence patterns. On\r\n\t/// enable it computes elapsed = now \u2212 LastSeenUtc, guards a clock rollback (negative \u2192 0), clamps\r\n\t/// to MaxOfflineHours (default 8), then replays that time through a SimulateOffline(double seconds)\r\n\t/// TODO hook in fixed TickSeconds chunks (default 1) so idle accumulation is deterministic\r\n\t/// (frame-rate independent), and fires the static OnOfflineProgressApplied(seconds) event (drive a\r\n\t/// 'welcome back, you earned X' screen). IsProxy-guarded so a client can't author their own offline\r\n\t/// earnings. Fill in the SimulateOffline hook with your idle math (e.g.\r\n\t/// wallet.AddMoney(rate*seconds)). Pairs with create_economy_wallet / create_save_system /\r\n\t/// create_stat_modifier_system.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name for the generated component. Defaults to 'OfflineProgress'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"maxOfflineHours\">Offline time is clamped to this many hours (stops a week-away paying out a week). Defaults to 8.</param>\r\n\t/// <param name=\"tickSeconds\">SimulateOffline chunk size in seconds \u2014 smaller = finer-grained deterministic replay (floored at 0.1). Defaults to 1.</param>\r\n\t/// <param name=\"targetId\">GUID of an idle/save-manager GameObject to attach to (only attaches if the type is already loaded \u2014 hotload first).</param>\r\n\t[McpTool( \"create_offline_progress\" )]\r\n\tpublic static Task<object> CreateOfflineProgress( string name = null, string directory = null, double? maxOfflineHours = null, double? tickSeconds = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_offline_progress\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"maxOfflineHours\", maxOfflineHours ), ( \"tickSeconds\", tickSeconds ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a trigger-based collectible component. On enter by a tagged object it raises\r\n\t/// OnCollected (wire it to your objective/score system) and despawns. Optionally builds a visible\r\n\t/// pickup GameObject with a trigger SphereCollider (+ a model) in one call. Returns { created,\r\n\t/// path, className, gameObject, note } \u2014 gameObject is the placed pickup (null unless\r\n\t/// placeInScene=true); a note flags when the component couldn't attach because the fresh type needs\r\n\t/// a hotload. Follow with trigger_hotload, then get_compile_errors.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name. Defaults to 'Pickup'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"action\">Effect flavour (all self-contained; the heal/item branches show the typed call to a companion system in comments). Defaults to 'score'. One of: score | heal | item | custom.</param>\r\n\t/// <param name=\"amount\">Magnitude of the effect (score points, heal amount). Defaults to 1.</param>\r\n\t/// <param name=\"filterTag\">Only collect for objects with this tag. Defaults to 'player'.</param>\r\n\t/// <param name=\"placeInScene\">Also build a pickup GameObject (trigger SphereCollider + optional model). Defaults to false.</param>\r\n\t/// <param name=\"position\">World position when placeInScene is true. As \"x,y,z\" (or JSON {x,y,z}).</param>\r\n\t/// <param name=\"radius\">Trigger sphere radius when placed. Defaults to 24.</param>\r\n\t/// <param name=\"model\">Optional model path for a visible pickup (e.g. 'models/dev/box.vmdl'). Cloud assets must be installed first.</param>\r\n\t[McpTool( \"create_pickup\" )]\r\n\tpublic static Task<object> CreatePickup( string name = null, string directory = null, string action = null, double? amount = null, string filterTag = null, bool? placeInScene = null, string position = null, double? radius = null, string model = null )\r\n\t\t=> McpGate.Run( \"create_pickup\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"action\", action ), ( \"amount\", amount ), ( \"filterTag\", filterTag ), ( \"placeInScene\", placeInScene ), ( \"position\", position ), ( \"radius\", radius ), ( \"model\", model ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a ghost-preview + commit placement component (single class). StartPlacing() clones\r\n\t/// GhostPrefab as a NetworkMode.Never preview with colliders disabled and ModelRenderers tinted\r\n\t/// semi-transparent. Each frame while placing: ray from Scene.Camera.GetMouseRay(),\r\n\t/// IgnoreGameObjectHierarchy(ghost), snap hit position to GridSize (0 = freeform), move ghost. On\r\n\t/// Input.Pressed('attack1') TryPlace() re-validates distance and commits a real clone.\r\n\t/// StopPlacing() destroys the ghost. Static OnPlaced(GameObject, Vector3) event. Includes a\r\n\t/// multiplayer RPC note. API grounded in building-placement cookbook (enifun.shop_manager pattern).\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name. Defaults to 'PlacementMode'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"gridSize\">Snap grid size in world units (0 = freeform placement). Defaults to 0.</param>\r\n\t/// <param name=\"targetId\">GUID of an existing GameObject to attach to (hotload first).</param>\r\n\t[McpTool( \"create_placement_mode\" )]\r\n\tpublic static Task<object> CreatePlacementMode( string name = null, string directory = null, double? gridSize = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_placement_mode\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"gridSize\", gridSize ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a player controller script with WASD movement, mouse look, jumping, and sprint.\r\n\t/// Supports first-person, third-person, and top-down movement modes. Optionally places a player rig\r\n\t/// (GameObject + CharacterController + Camera) in the scene \u2014 note the generated component is\r\n\t/// attached AFTER a trigger_hotload (it isn't in the TypeLibrary until a recompile).\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name. Defaults to 'PlayerController'.</param>\r\n\t/// <param name=\"directory\">Subdirectory under code/ for the file.</param>\r\n\t/// <param name=\"type\">Movement mode: 'first_person' (mouse-look body+camera, WASD relative to facing), 'third_person' (mouse yaw, WASD relative to facing, boom camera), or 'top_down' (screen-relative WASD, fixed overhead camera, no jump). Defaults to 'first_person'. One of: first_person | third_person | top_down.</param>\r\n\t/// <param name=\"moveSpeed\">Movement speed in units/sec. Defaults to 300.</param>\r\n\t/// <param name=\"jumpForce\">Jump force (ignored for top_down). Defaults to 350.</param>\r\n\t/// <param name=\"sprintMultiplier\">Sprint speed multiplier (held 'run' action). Defaults to 1.5.</param>\r\n\t/// <param name=\"placeInScene\">If true, build a player rig in the scene: a GameObject (tagged 'player') with a CharacterController and (unless createCamera=false) a Camera. The generated controller component is NOT attached in this call \u2014 trigger_hotload then add_component_with_properties on the returned GameObject. Defaults to false (file-only).</param>\r\n\t/// <param name=\"createCamera\">When placeInScene is true, also create a Camera (FP/TP: child at eye/boom offset; top_down: fixed overhead). Defaults to true.</param>\r\n\t/// <param name=\"spawnPosition\">When placeInScene is true, the world position to spawn the player rig at \u2014 object {x,y,z} or comma string \"x,y,z\". Defaults to the origin. As \"x,y,z\" (or JSON {x,y,z}).</param>\r\n\t[McpTool( \"create_player_controller\" )]\r\n\tpublic static Task<object> CreatePlayerController( string name = null, string directory = null, string type = null, double? moveSpeed = null, double? jumpForce = null, double? sprintMultiplier = null, bool? placeInScene = null, bool? createCamera = null, string spawnPosition = null )\r\n\t\t=> McpGate.Run( \"create_player_controller\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"type\", type ), ( \"moveSpeed\", moveSpeed ), ( \"jumpForce\", jumpForce ), ( \"sprintMultiplier\", sprintMultiplier ), ( \"placeInScene\", placeInScene ), ( \"createCamera\", createCamera ), ( \"spawnPosition\", spawnPosition ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a host-authoritative round/phase machine: a [Sync(SyncFlags.FromHost)] CurrentPhase\r\n\t/// cycled through your named phases on a per-phase timer (host-only), with a static OnPhaseChanged\r\n\t/// event that fires on every machine. Great for round/match flow, match phases, or a day/night\r\n\t/// cycle. Single-player safe. Optionally attached to an existing GameObject by GUID (after a\r\n\t/// hotload). Mined from the round-flow pattern across the 51 games.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name. Defaults to 'GameDirector'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"phases\">Ordered phase names (become an enum), e.g. [\"Lobby\",\"Day\",\"Night\",\"Payout\"]. Defaults to [\"Lobby\",\"Active\",\"Ended\"].</param>\r\n\t/// <param name=\"duration\">Default seconds per phase (each phase also gets its own tunable [Property]). Defaults to 60.</param>\r\n\t/// <param name=\"loop\">Loop back to the first phase after the last (true) or hold on the last phase (false). Defaults to true.</param>\r\n\t/// <param name=\"targetId\">GUID of an existing GameObject to attach to (only if the type is already loaded \u2014 hotload first).</param>\r\n\t[McpTool( \"create_round_phase_machine\" )]\r\n\tpublic static Task<object> CreateRoundPhaseMachine( string name = null, string directory = null, string[] phases = null, double? duration = null, bool? loop = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_round_phase_machine\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"phases\", phases ), ( \"duration\", duration ), ( \"loop\", loop ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a host-authoritative MULTI-STATE round machine (the complex variant of\r\n\t/// create_round_phase_machine). Produces one .cs file: a RoundManager singleton component + an\r\n\t/// abstract RoundState base (Begin/Tick/OnTimeUp/Finish lifecycle with a per-state\r\n\t/// [Sync(SyncFlags.FromHost)] TimeUntil timer) + one sealed stub class per named state. The manager\r\n\t/// auto-attaches the state components on start (you only place the manager), ticks ONLY the active\r\n\t/// state on the host, Advance()s on timeout with index-wrap, SKIPS any state whose CanEnter()\r\n\t/// returns false, and announces every transition via a static OnStateChanged event plus an\r\n\t/// [Rpc.Broadcast] mirror so the host fires immediately and proxies converge without waiting a\r\n\t/// snapshot (the [Sync] index reconciles late joiners). Single-player safe. USE THIS (not\r\n\t/// create_round_phase_machine) when each phase needs its OWN behaviour \u2014 entry side-effects,\r\n\t/// per-frame Tick logic, a skip condition, or copy-data-out-on-exit; use the phase machine for 3\u20135\r\n\t/// light phases that differ only in duration. Optionally attached to an existing GameObject by GUID\r\n\t/// (after a hotload).\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Manager class name. Defaults to 'RoundManager'. The abstract base is derived from it (RoundManager \u2192 RoundState).</param>\r\n\t/// <param name=\"directory\">Subdirectory for the .cs file (path override). Defaults to 'Code'.</param>\r\n\t/// <param name=\"states\">Ordered state names \u2014 each becomes a sealed {Name}State stub class. Defaults to [\"Waiting\",\"Active\",\"PostRound\"].</param>\r\n\t/// <param name=\"duration\">Default seconds each state lasts (each state also gets its own tunable [Property] Duration). 0 = no auto-advance for a state. Defaults to 30.</param>\r\n\t/// <param name=\"durations\">Optional per-state duration override: an array aligned to `states` ([10,120,8]) OR an object keyed by state name ({\"Waiting\":10,\"Active\":120}). Any state not covered falls back to `duration`. JSON value.</param>\r\n\t/// <param name=\"loop\">Loop back to the first state after the last (true) or hold on the last state (false). Defaults to true.</param>\r\n\t/// <param name=\"targetId\">GUID of an existing GameObject to attach the manager to (only if the type is already loaded \u2014 trigger_hotload first).</param>\r\n\t[McpTool( \"create_round_state_machine\" )]\r\n\tpublic static Task<object> CreateRoundStateMachine( string name = null, string directory = null, string[] states = null, double? duration = null, JsonNode durations = null, bool? loop = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_round_state_machine\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"states\", states ), ( \"duration\", duration ), ( \"durations\", durations ), ( \"loop\", loop ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a multi-slot save MANAGER component (the slot-picker sibling of create_save_system).\r\n\t/// Use this when the game needs SEVERAL named save slots the player chooses between (New Game /\r\n\t/// Load Game menu, per-character or per-run saves) \u2014 not one silent autosave. Use\r\n\t/// create_save_system instead when a single implicit save file is enough. Emits one sealed\r\n\t/// Component that lists / creates / loads / saves / deletes N slots: a lightweight manifest file\r\n\t/// (saveslots.json) holds per-slot metadata for the picker (Used flag + Name + SavedAtUnix\r\n\t/// timestamp + PlaytimeSeconds) so listing never loads a heavy payload, and each slot's game state\r\n\t/// lives in its own saveslot_<i>.json. Versioned SlotData POCO with clamp-on-load Sanitize()\r\n\t/// and delete-on-version-mismatch; runs only on the owning machine (IsProxy guard). Static\r\n\t/// OnSlotLoaded / OnSlotSaved / OnSlotDeleted hooks for HUD. Storage stays within the verified\r\n\t/// FileSystem.Data.ReadJsonOrDefault / WriteJson / DeleteFile surface (index-file pattern, no\r\n\t/// directory enumeration). Set sceneReconciliation:true to also reconcile scene objects by\r\n\t/// GameObject.Id on load \u2014 records the save marks destroyed are destroyed, survivors repositioned,\r\n\t/// missing skipped (good for a placeable-world tycoon). Optionally attached to an existing\r\n\t/// GameObject by GUID (after a hotload).\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name for the generated component. Defaults to 'SaveSlotManager'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"maxSlots\">How many save slots the manager manages (manifest is normalized to exactly this many, indexed 0..N-1). Clamped to 1..100. Defaults to 3.</param>\r\n\t/// <param name=\"sceneReconciliation\">If true, saved records carry each object's GameObject.Id GUID and load reconciles the live scene against them (destroy the save's destroyed records via Scene.Directory.FindByGuid, reposition survivors, skip missing) \u2014 call RecordObject(go) to track a placeable. If false (default), the slot save is a plain payload with no scene reconciliation. Defaults to false.</param>\r\n\t/// <param name=\"targetId\">GUID of an existing GameObject to attach the manager to (only attaches if the type is already loaded \u2014 hotload first).</param>\r\n\t[McpTool( \"create_save_slots\" )]\r\n\tpublic static Task<object> CreateSaveSlots( string name = null, string directory = null, int? maxSlots = null, bool? sceneReconciliation = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_save_slots\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"maxSlots\", maxSlots ), ( \"sceneReconciliation\", sceneReconciliation ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a versioned save-system component: a SaveData POCO with Version bump on schema change,\r\n\t/// dirty-flag autosave on a TimeUntil timer, clamp-on-load Sanitize() for corrupt/hand-edited\r\n\t/// saves, and delete-on-version-mismatch to start fresh instead of crashing. Runs only on the\r\n\t/// owning machine (IsProxy guard). Fires static OnLoaded/OnSaved hooks for HUD and analytics.\r\n\t/// FileSystem.Data.ReadJsonOrDefault/WriteJson verified live on the current SDK. Optionally\r\n\t/// attached to an existing GameObject by GUID (after a hotload).\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name. Defaults to 'SaveSystem'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"fileName\">Save file name under FileSystem.Data (e.g. 'save.json'). Defaults to 'save.json'.</param>\r\n\t/// <param name=\"version\">Schema version embedded in SaveData. Old saves with a different version start fresh. Defaults to 1.</param>\r\n\t/// <param name=\"autosaveSeconds\">Seconds between autosave ticks (0 disables autosave). Defaults to 10.</param>\r\n\t/// <param name=\"targetId\">GUID of an existing GameObject to attach to (only if the type is already loaded -- hotload first).</param>\r\n\t[McpTool( \"create_save_system\" )]\r\n\tpublic static Task<object> CreateSaveSystem( string name = null, string directory = null, string fileName = null, int? version = null, double? autosaveSeconds = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_save_system\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"fileName\", fileName ), ( \"version\", version ), ( \"autosaveSeconds\", autosaveSeconds ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a tamper-evident, versioned save-system component (sealed, owner-only). The SaveData\r\n\t/// payload POCO is serialized to JSON (Sandbox.Json), FNV-1a-64 hashed over payload + version +\r\n\t/// salt, and written as a signed envelope { Version, Payload, Signature } to FileSystem.Data.\r\n\t/// Load() re-verifies: a signature mismatch (hand-edited/corrupt file) triggers a FORCED RESET \u2014\r\n\t/// the save file is DELETED, defaults are used, and the static OnTampered(reason) event fires\r\n\t/// (destructive and deliberate; tell the player). A version mismatch starts fresh without the\r\n\t/// tamper event (add migrations in Load). Loaded values pass a Sanitize() clamp hook so even a\r\n\t/// re-signed save can't smuggle absurd values. Dirty-flag autosave (autosaveSeconds, default 10;\r\n\t/// MarkDirty() to arm) + a final save in OnDestroy. HONEST LIMIT: the salt ships inside the game\r\n\t/// assembly, so this is tamper-EVIDENT (stops notepad edits), NOT cryptographically secure. If you\r\n\t/// omit salt, a unique random one is baked into the generated file \u2014 changing it later invalidates\r\n\t/// existing saves. Returns { created, path, className, fileName, version, autosaveSeconds,\r\n\t/// placedOn, note, nextSteps }. Next: trigger_hotload, attach, add your fields to SaveData + clamps\r\n\t/// to Sanitize(), bump version on shape changes. Refused during play mode. Use create_save_system\r\n\t/// for a plain unsigned save, create_save_slots for multi-slot UI flows, create_meta_progression\r\n\t/// for roguelite meta-state.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name for the generated component. Defaults to 'SignedSave'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the generated .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"fileName\">FileSystem.Data path the signed envelope is written to. Defaults to 'save_signed.json'.</param>\r\n\t/// <param name=\"version\">Save-shape version baked into the file and the signature; mismatched files start fresh. Defaults to 1.</param>\r\n\t/// <param name=\"salt\">Signing salt baked into the generated code. Omit to bake a unique random salt (recommended); changing it later invalidates existing saves.</param>\r\n\t/// <param name=\"autosaveSeconds\">Dirty-flag autosave cadence in seconds; 0 disables the heartbeat (OnDestroy still saves). Defaults to 10.</param>\r\n\t/// <param name=\"targetId\">GUID of a save-manager GameObject to attach to (only attaches if the type is already loaded \u2014 hotload first).</param>\r\n\t[McpTool( \"create_signed_save\" )]\r\n\tpublic static Task<object> CreateSignedSave( string name = null, string directory = null, string fileName = null, int? version = null, string salt = null, double? autosaveSeconds = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_signed_save\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"fileName\", fileName ), ( \"version\", version ), ( \"salt\", salt ), ( \"autosaveSeconds\", autosaveSeconds ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a speedrun timer component plus a leaderboard display panel. The timer\r\n\t/// (<Name>.cs) is TimeSince-based with a static Instance: StartTimer() at run start,\r\n\t/// StopTimer() at the finish (pairs with a trigger zone), ResetTimer() to abort. StopTimer persists\r\n\t/// the local best via FileSystem.Data and submits Stats.SetValue(statName, seconds) ONLY when the\r\n\t/// run beats it \u2014 configure the stat with MIN aggregation on sbox.game so the global board keeps\r\n\t/// best times. The panel (<Name>Panel.razor + .razor.scss, razor_lint clean) fetches via\r\n\t/// Leaderboards.GetFromStat with min aggregation + ascending sort, has a clickable Friends-only\r\n\t/// filter button, and overlays a local-best row read from the same save file. Returns { created,\r\n\t/// path, className, panelRazorPath, panelScssPath, panelClassName, statName, placedOn, note,\r\n\t/// nextSteps }. After trigger_hotload: place ONE timer (add_component_to_new_object or targetId)\r\n\t/// and host the panel under a ScreenPanel/WorldPanel (add_screen_panel). maxRows clamps to 1..50;\r\n\t/// makePanel=false skips the panel files. Fails if the .cs or panel .razor already exists.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name for the generated timer component (panel becomes <name>Panel). Defaults to 'SpeedrunTimer'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for all generated files. Defaults to 'Code'.</param>\r\n\t/// <param name=\"statName\">Sandbox.Services stat the best time is written to (sanitized to [a-z0-9_-]). Defaults to 'best_time'.</param>\r\n\t/// <param name=\"fileName\">Save file name inside FileSystem.Data for the local best. Defaults to 'speedrun.json'.</param>\r\n\t/// <param name=\"title\">Panel title text. Defaults to 'Best Times'.</param>\r\n\t/// <param name=\"maxRows\">Leaderboard rows fetched/shown. Defaults to 10, clamped to 1..50.</param>\r\n\t/// <param name=\"makePanel\">Also emit the <name>Panel.razor + .razor.scss display panel. Defaults to true.</param>\r\n\t/// <param name=\"targetId\">GUID of a GameObject to attach the timer to (only attaches if the type is already loaded \u2014 hotload first).</param>\r\n\t[McpTool( \"create_speedrun_leaderboard\" )]\r\n\tpublic static Task<object> CreateSpeedrunLeaderboard( string name = null, string directory = null, string statName = null, string fileName = null, string title = null, double? maxRows = null, bool? makePanel = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_speedrun_leaderboard\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"statName\", statName ), ( \"fileName\", fileName ), ( \"title\", title ), ( \"maxRows\", maxRows ), ( \"makePanel\", makePanel ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate an enum-keyed stat modifier system with three modifier layers: SET\r\n\t/// (highest-priority-wins hard override), ADD (summed bonuses), MULT (multiplied factors applied\r\n\t/// last). Modifier storage uses parallel private Lists of primitive types (serialization-safe).\r\n\t/// RemoveModifiersFrom(source) cleans up all mods from a buff/debuff source by reference. Static\r\n\t/// OnStatChanged(stat, value) event fires after every add/remove. Mined from RPG/buff/debuff\r\n\t/// patterns across shipped s&box games. Returns { created, path, className, stats, placedOn,\r\n\t/// note } \u2014 stats echoes the sanitized stat names ({name}Stat enum values); placedOn is the target\r\n\t/// GameObject when attached (needs the type hotloaded). Follow with trigger_hotload, then\r\n\t/// get_compile_errors.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name prefix -- generates {name}Stat enum + {name} Component. Defaults to 'StatSystem'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"stats\">Stat names as a JSON array or comma-separated string. Defaults to 'Health,Speed,Damage'. JSON value.</param>\r\n\t/// <param name=\"targetId\">GUID of an existing GameObject to attach to (hotload first).</param>\r\n\t[McpTool( \"create_stat_modifier_system\" )]\r\n\tpublic static Task<object> CreateStatModifierSystem( string name = null, string directory = null, JsonNode stats = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_stat_modifier_system\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"stats\", stats ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a host-authoritative balanced team assigner component (smallest-bucket draft):\r\n\t/// AssignSmallest(steamId) drops a joining player into the emptiest team, announces via\r\n\t/// [Rpc.Broadcast] so every client's roster agrees, and fires static OnTeamAssigned(steamId, index,\r\n\t/// name); plus Rebalance(), GetTeam, GetMembers. Writes a .cs file and returns { created, path,\r\n\t/// className, teams, nextSteps } \u2014 follow with trigger_hotload + compile_status, attach to your\r\n\t/// game manager, call AssignSmallest from your join hook (e.g. INetworkListener.OnActive).\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class/file name (default 'TeamAssigner' -> Code/TeamAssigner.cs). Errors if the file exists.</param>\r\n\t/// <param name=\"directory\">Directory for the .cs file. Default 'Code'.</param>\r\n\t/// <param name=\"teams\">Team names in index order. Default [\"Red\", \"Blue\"].</param>\r\n\t[McpTool( \"create_team_assigner\" )]\r\n\tpublic static Task<object> CreateTeamAssigner( string name = null, string directory = null, string[] teams = null )\r\n\t\t=> McpGate.Run( \"create_team_assigner\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"teams\", teams ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a trigger-zone Component (Component.ITriggerListener): auto-adds a trigger BoxCollider\r\n\t/// on start, filters entrants by a TriggerTag [Property] (default 'player'), and logs enter/exit\r\n\t/// via private OnPlayerEnter/OnPlayerExit extension points you fill in. Writes <name>.cs and\r\n\t/// returns { created, path, className }. NOTE: the action/filterTag params are not currently\r\n\t/// applied at generation time \u2014 the zone always logs; implement teleport/damage/spawn in the\r\n\t/// generated methods (edit_script). Follow with trigger_hotload, then get_compile_errors.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name. Defaults to 'TriggerZone'.</param>\r\n\t/// <param name=\"directory\">Subdirectory under code/ for the file.</param>\r\n\t/// <param name=\"action\">What happens on trigger (currently not applied by the handler \u2014 the generated zone always logs; implement the effect in OnPlayerEnter yourself). One of: log | teleport | damage | spawn.</param>\r\n\t/// <param name=\"filterTag\">Only trigger for objects with this tag (currently not applied at generation \u2014 the generated TriggerTag [Property] defaults to 'player'; change it per-instance with set_property).</param>\r\n\t[McpTool( \"create_trigger_zone\" )]\r\n\tpublic static Task<object> CreateTriggerZone( string name = null, string directory = null, string action = null, string filterTag = null )\r\n\t\t=> McpGate.Run( \"create_trigger_zone\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"action\", action ), ( \"filterTag\", filterTag ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Generate a cumulative-weight random loot picker: parallel Name/Weight lists\r\n\t/// (inspector-editable), a Roll() method that returns a winning entry name and fires a static\r\n\t/// OnLoot event, and optional pity (guarantee the last/rarest entry after PityAfter consecutive\r\n\t/// non-rare rolls). Roll() is host-authoritative -- only call it on the host and replicate the\r\n\t/// result (clients rolling their own loot is equivalent to clients writing their own money\r\n\t/// balance). Optionally attached to an existing GameObject by GUID (after a hotload).\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name. Defaults to 'LootTable'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the .cs file. Defaults to 'Code'.</param>\r\n\t/// <param name=\"entries\">Loot table entries. Defaults to common:70 / uncommon:25 / rare:5. JSON value.</param>\r\n\t/// <param name=\"pity\">If true, guarantee the last (rarest) entry after PityAfter consecutive non-rare rolls. Defaults to false.</param>\r\n\t/// <param name=\"targetId\">GUID of an existing GameObject to attach to (only if the type is already loaded -- hotload first).</param>\r\n\t[McpTool( \"create_weighted_loot_table\" )]\r\n\tpublic static Task<object> CreateWeightedLootTable( string name = null, string directory = null, JsonNode entries = null, bool? pity = null, string targetId = null )\r\n\t\t=> McpGate.Run( \"create_weighted_loot_table\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"entries\", entries ), ( \"pity\", pity ), ( \"targetId\", targetId ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Scaffold an end-of-round map vote. Three files: <Name>.cs (sealed host-authoritative\r\n\t/// controller) + <Name>Panel.razor + <Name>Panel.razor.scss (vote UI: one button per\r\n\t/// map, live tallies, countdown, own-pick highlight, winner banner). Flow: host calls StartVote()\r\n\t/// (usually from a post-round phase/state, or set the AutoStart [Property]) -> clients click\r\n\t/// -> votes route client-to-host via [Rpc.Host] SubmitVote with the caller re-resolved HOST-SIDE\r\n\t/// from Rpc.Caller (null-checked \u2014 Connection has no IsValid on this SDK) and the map index\r\n\t/// re-validated (re-votes overwrite, keyed by SteamId) -> tallies replicate via [Sync(FromHost)]\r\n\t/// NetList<int> -> when the [Sync] TimeUntil countdown expires the host picks the winner\r\n\t/// (most votes; ties break deterministically via one LCG scramble of a time seed \u2014 no\r\n\t/// System.Random) -> after resultLingerSeconds the HOST calls Scene.LoadFromFile(winner) (API\r\n\t/// verified live on this SDK; clients follow via the scene networking layer \u2014 verify the client\r\n\t/// hand-off in a real multi-client session). Static event OnVoteFinished(sceneFile) fires on every\r\n\t/// machine. Returns { created, componentPath, razorPath, scssPath, className, panelClassName, maps,\r\n\t/// voteDurationSeconds, resultLingerSeconds, autoStart, note, nextSteps }. REQUIREMENTS: the\r\n\t/// controller must sit on a NETWORK-SPAWNED object in multiplayer or [Sync] never replicates; if\r\n\t/// maps is omitted the MapScenes list is generated EMPTY and StartVote() refuses with a warning\r\n\t/// until you fill it in the inspector. Follow with trigger_hotload, attach via\r\n\t/// add_component_with_properties, host the panel under add_screen_panel.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Class name for the controller; the panel is generated as <Name>Panel. Defaults to 'MapVote'.</param>\r\n\t/// <param name=\"directory\">Subdirectory for the generated .cs + .razor + .razor.scss. Defaults to 'Code'.</param>\r\n\t/// <param name=\"maps\">Scene files to vote between, e.g. [\"scenes/arena.scene\", \"scenes/docks.scene\"] (find them with list_scenes). Baked into the MapScenes [Property] list, editable later in the inspector. Defaults to an EMPTY list (StartVote() then refuses until it's filled).</param>\r\n\t/// <param name=\"voteDurationSeconds\">Seconds the vote stays open once StartVote() is called (clamped to >= 3). Defaults to 20.</param>\r\n\t/// <param name=\"resultLingerSeconds\">Seconds the winner banner shows before the host loads the winning scene (clamped to >= 0). Defaults to 4.</param>\r\n\t/// <param name=\"autoStart\">Start the vote automatically on spawn (host only). Usually false \u2014 call StartVote() from your round machine's post-round state instead. Defaults to false.</param>\r\n\t[McpTool( \"scaffold_map_vote_flow\" )]\r\n\tpublic static Task<object> ScaffoldMapVoteFlow( string name = null, string directory = null, string[] maps = null, double? voteDurationSeconds = null, double? resultLingerSeconds = null, bool? autoStart = null )\r\n\t\t=> McpGate.Run( \"scaffold_map_vote_flow\", McpGate.Args( ( \"name\", name ), ( \"directory\", directory ), ( \"maps\", maps ), ( \"voteDurationSeconds\", voteDurationSeconds ), ( \"resultLingerSeconds\", resultLingerSeconds ), ( \"autoStart\", autoStart ) ) );\r\n}\r\n"
},
{
"Ident": "sboxskinsgg.claudebridge",
"Path": "Editor/NetPrimitivesHandlers.cs",
"FileName": "NetPrimitivesHandlers.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 335526,
"Code": "using Editor;\r\nusing Sandbox;\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.IO;\r\nusing System.Linq;\r\nusing System.Text;\r\nusing System.Text.Json;\r\nusing System.Threading;\r\nusing System.Threading.Tasks;\r\n\r\n// =============================================================================\r\n// Networking primitives pack (v1.20.0, Track B) -- four multiplayer scaffolds\r\n// (code-gen; scene-mutating):\r\n//\r\n// create_host_rpc_action validated + rate-limited [Rpc.Host] action skeleton\r\n// add_targeted_rpc Rpc.FilterInclude single-client (unicast) side-effect\r\n// create_local_player_resolver proxy-safe \"who is MY player\" resolver (online + offline)\r\n// add_host_migration_recovery proxy->authority transition detector + OnBecameHost hook\r\n//\r\n// Compiles into the SAME editor assembly as MyEditorMenu.cs / ScaffoldHandlers.cs,\r\n// so it reuses the shared statics on ClaudeBridge (TryResolveProjectPath,\r\n// SanitizeIdentifier, SerializeGo) and ScaffoldHelpers (PrepareCodeFile /\r\n// WriteCode / Utf8NoBom). Handler code here is UNSANDBOXED editor code.\r\n//\r\n// The C# *strings these handlers WRITE TO DISK* are SANDBOXED game code and must\r\n// obey the s&box sandbox rules:\r\n// - System.Math/MathF/MathX all compile on the current SDK; Array.Clone() is\r\n// whitelist-blocked (not used here).\r\n// - Fully-qualify System.Collections.Generic.Dictionary (dodges a missing using).\r\n// - TimeSince/TimeUntil for timers; float literals formatted InvariantCulture + 'f'.\r\n// - Guard Networking access: check Networking.IsActive before Networking.IsHost\r\n// (IsHost can throw with no session). Rpc.Caller re-resolved host-side, never\r\n// trusting client args for identity.\r\n// - VERIFIED live against the installed SDK before codegen (describe_type +\r\n// networking-authority cookbook): Connection.Local (static), Connection.All,\r\n// Connection.SteamId (NOTE: Connection has NO IsValid member on this SDK \u2014\r\n// null-check it; caught live by the v1.20.0 verify-gate), Rpc.Caller (Connection) / Rpc.CallerId (Guid),\r\n// Rpc.FilterInclude(Connection) -> IDisposable, GameObject.Network (NetworkAccessor)\r\n// -> Owner (Connection) / OwnerId (Guid) / IsOwner / IsProxy, [Sync(SyncFlags.FromHost)],\r\n// [Rpc.Host] / [Rpc.Broadcast], (ulong)SteamId cast.\r\n//\r\n// Register(...) lines + the _sceneMutatingCommands additions live in\r\n// MyEditorMenu.cs (Batch 45) to keep the files decoupled.\r\n// =============================================================================\r\n\r\n// -----------------------------------------------------------------------------\r\n// create_host_rpc_action -- the validated, rate-limited host-action skeleton.\r\n//\r\n// The safe answer to \"a client asks the host to DO something\": a client-callable\r\n// Request() forwards to an [Rpc.Host] body that re-resolves the caller via\r\n// Rpc.Caller (NEVER trusting client args for identity), enforces a per-SteamId\r\n// cooldown from a Dictionary<ulong, TimeSince>, runs a clearly-marked TODO hook,\r\n// and fires a static OnActionExecuted event. Covers the backlog's\r\n// add_rate_limited_rpc.\r\n// -----------------------------------------------------------------------------\r\npublic class CreateHostRpcActionHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar ci = System.Globalization.CultureInfo.InvariantCulture;\r\n\r\n\t\t\tif ( !ScaffoldHelpers.PrepareCodeFile( p, \"HostRpcAction\", out var fullPath, out var relPath, out var className, out var err ) )\r\n\t\t\t\treturn Task.FromResult<object>( err );\r\n\r\n\t\t\tfloat cooldown = p.TryGetProperty( \"cooldownSeconds\", out var cv ) && cv.TryGetSingle( out var cf ) ? cf : 1f;\r\n\t\t\tif ( cooldown < 0f ) cooldown = 0f; // a negative cooldown would emit nonsense\r\n\r\n\t\t\tvar code = BuildCode( className, cooldown, ci );\r\n\t\t\tScaffoldHelpers.WriteCode( fullPath, code );\r\n\r\n\t\t\tobject placedOn = null; string note = null;\r\n\t\t\tif ( p.TryGetProperty( \"targetId\", out var tid ) && tid.ValueKind == JsonValueKind.String )\r\n\t\t\t\tplacedOn = NetPrimitivesHelpers.PlaceOnTarget( tid.GetString(), className, out note );\r\n\r\n\t\t\treturn Task.FromResult<object>( new\r\n\t\t\t{\r\n\t\t\t\tcreated = true,\r\n\t\t\t\tpath = relPath,\r\n\t\t\t\tclassName,\r\n\t\t\t\tcooldownSeconds = cooldown,\r\n\t\t\t\tplacedOn,\r\n\t\t\t\tnote,\r\n\t\t\t\tnextSteps = new[]\r\n\t\t\t\t{\r\n\t\t\t\t\t$\"trigger_hotload to compile {className} into the game assembly.\",\r\n\t\t\t\t\tplacedOn != null\r\n\t\t\t\t\t\t? $\"{className} was attached to the target GameObject.\"\r\n\t\t\t\t\t\t: $\"Attach it to the object that owns this action (a player, a station, or your game manager): add_component_with_properties (component=\\\"{className}\\\") after the hotload, or re-run with targetId.\",\r\n\t\t\t\t\t$\"Fire it from the owning client (input / UI button): GetComponent<{className}>()?.Request(); -- it routes to the host, which re-validates and rate-limits.\",\r\n\t\t\t\t\t$\"Fill in the TODO host block with your authoritative action (spend currency, NetworkSpawn, grant a reward). Re-clamp any gameplay args there -- forged client args bypass NetFlags.\",\r\n\t\t\t\t\t$\"React to accepted actions: {className}.OnActionExecuted += conn => Log.Info( $\\\"action by {{conn.DisplayName}}\\\" ); (fires on the host). Wrap an [Rpc.Broadcast] if every client should react.\",\r\n\t\t\t\t\t\"Tune CooldownSeconds with set_property. The per-SteamId cooldown is host-only runtime state (not [Sync]).\"\r\n\t\t\t\t}\r\n\t\t\t} );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"create_host_rpc_action failed: {ex.Message}\" } );\r\n\t\t}\r\n\t}\r\n\r\n\tstatic string BuildCode( string className, float cooldown, System.Globalization.CultureInfo ci )\r\n\t{\r\n\t\tstring cd = cooldown.ToString( ci ) + \"f\";\r\n\r\n\t\treturn $@\"using Sandbox;\r\nusing System;\r\n\r\n/// <summary>\r\n/// {className} -- a validated, rate-limited host action. The safe skeleton for\r\n/// \"\"a client asks the host to DO something\"\" (buy, use, vote, interact).\r\n///\r\n/// Flow: client calls Request() -> [Rpc.Host] SubmitRequest() runs ON THE HOST\r\n/// -> host re-resolves WHO called it via Rpc.Caller (never trusts client\r\n/// args for identity) -> enforces a per-SteamId cooldown -> runs your\r\n/// host-authoritative action -> fires OnActionExecuted.\r\n///\r\n/// [Rpc.Host] is callable by ANY client with forged args -- NetFlags restrict who\r\n/// may INVOKE, which is not security. That is why identity + cooldown + your\r\n/// validation all live INSIDE the host body. Single-player safe (no session -> the\r\n/// RPC just runs locally; the caller falls back to Connection.Local).\r\n///\r\n/// Usage:\r\n/// GetComponent<{className}>()?.Request(); // from input / a UI button, on the owning client\r\n/// {className}.OnActionExecuted += conn => Log.Info( $\"\"action by {{conn.DisplayName}}\"\" );\r\n/// </summary>\r\npublic sealed class {className} : Component\r\n{{\r\n\t/// <summary>Minimum seconds between accepted requests, per calling player.</summary>\r\n\t[Property] public float CooldownSeconds {{ get; set; }} = {cd};\r\n\r\n\t/// <summary>Fires ON THE HOST after an accepted request. Arg = the validated caller.</summary>\r\n\tpublic static Action<Connection> OnActionExecuted {{ get; set; }}\r\n\r\n\t// Host-only runtime state: last-accept time keyed by the caller's SteamId.\r\n\t// NOT [Sync] -- it is the host's own rate-limit bookkeeping, never replicated.\r\n\tprivate readonly System.Collections.Generic.Dictionary<ulong, TimeSince> _cooldowns = new();\r\n\r\n\t/// <summary>\r\n\t/// Client entry point. Call this on the owning client (input handler / UI button).\r\n\t/// It routes to the host; do NOT put authoritative logic here -- a client controls\r\n\t/// this machine and could call anything. The real work happens host-side.\r\n\t/// </summary>\r\n\tpublic void Request()\r\n\t{{\r\n\t\tSubmitRequest(); // [Rpc.Host] -- executes on the host (or locally in solo)\r\n\t}}\r\n\r\n\t/// <summary>\r\n\t/// Host-authoritative handler. Public so the RPC source generator is happy; the\r\n\t/// re-validation below is what actually protects it. NEVER trust args passed from\r\n\t/// the client for identity -- re-resolve the caller here.\r\n\t/// </summary>\r\n\t[Rpc.Host]\r\n\tpublic void SubmitRequest()\r\n\t{{\r\n\t\t// Re-resolve the caller SERVER-SIDE. Read Rpc.Caller only when a session is\r\n\t\t// active (offline it is meaningless); fall back to us in solo.\r\n\t\tvar caller = Networking.IsActive ? Rpc.Caller : Connection.Local;\r\n\t\tif ( caller == null ) caller = Connection.Local;\r\n\t\tif ( caller == null ) return; // no identity at all -- refuse\r\n\r\n\t\t// FOOTGUN (some SDK builds): Rpc.Caller can return the HOST's own connection\r\n\t\t// for a proxy-initiated call. If identity is security-critical, resolve the\r\n\t\t// acting player from the OWNING component's Network.Owner instead.\r\n\r\n\t\tulong callerId = (ulong)caller.SteamId;\r\n\r\n\t\t// Per-SteamId rate limit -- spamming the RPC cannot bypass the cooldown.\r\n\t\tif ( _cooldowns.TryGetValue( callerId, out var since ) && since < CooldownSeconds )\r\n\t\t\treturn; // still cooling down for this caller\r\n\t\t_cooldowns[callerId] = 0f; // reset this caller's timer\r\n\r\n\t\t// --- TODO: your host-authoritative action goes here ---------------------\r\n\t\t// Runs ONLY on the host. Re-validate + re-clamp any gameplay values, then\r\n\t\t// mutate [Sync(SyncFlags.FromHost)] state / NetworkSpawn() / grant rewards.\r\n\t\t// Example: GetComponent<Wallet>()?.AddMoney( 10 );\r\n\t\t// ------------------------------------------------------------------------\r\n\r\n\t\tOnActionExecuted?.Invoke( caller );\r\n\t}}\r\n}}\r\n\";\r\n\t}\r\n}\r\n\r\n// -----------------------------------------------------------------------------\r\n// add_targeted_rpc -- the Rpc.FilterInclude single-client (unicast) pattern.\r\n//\r\n// A host-side SendTo(Connection, string) wraps an [Rpc.Broadcast] call in\r\n// using ( Rpc.FilterInclude( target ) ) so ONLY that one connection executes the\r\n// body, which raises a static OnReceived event.\r\n// -----------------------------------------------------------------------------\r\npublic class AddTargetedRpcHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tif ( !ScaffoldHelpers.PrepareCodeFile( p, \"TargetedRpc\", out var fullPath, out var relPath, out var className, out var err ) )\r\n\t\t\t\treturn Task.FromResult<object>( err );\r\n\r\n\t\t\tvar code = BuildCode( className );\r\n\t\t\tScaffoldHelpers.WriteCode( fullPath, code );\r\n\r\n\t\t\tobject placedOn = null; string note = null;\r\n\t\t\tif ( p.TryGetProperty( \"targetId\", out var tid ) && tid.ValueKind == JsonValueKind.String )\r\n\t\t\t\tplacedOn = NetPrimitivesHelpers.PlaceOnTarget( tid.GetString(), className, out note );\r\n\r\n\t\t\treturn Task.FromResult<object>( new\r\n\t\t\t{\r\n\t\t\t\tcreated = true,\r\n\t\t\t\tpath = relPath,\r\n\t\t\t\tclassName,\r\n\t\t\t\tplacedOn,\r\n\t\t\t\tnote,\r\n\t\t\t\tnextSteps = new[]\r\n\t\t\t\t{\r\n\t\t\t\t\t$\"trigger_hotload to compile {className} into the game assembly.\",\r\n\t\t\t\t\tplacedOn != null\r\n\t\t\t\t\t\t? $\"{className} was attached to the target GameObject.\"\r\n\t\t\t\t\t\t: $\"Attach it to a networked manager object: add_component_with_properties (component=\\\"{className}\\\") after the hotload, or re-run with targetId. The object must be NetworkSpawn'd for the RPC to route.\",\r\n\t\t\t\t\t$\"Send to ONE player from the host: GetComponent<{className}>()?.SendTo( player.Network.Owner, \\\"You're up next!\\\" ); -- only that client runs the body.\",\r\n\t\t\t\t\t$\"Receive on the target: {className}.OnReceived += msg => ShowToast( msg ); -- fires only on the filtered client (and locally in solo).\",\r\n\t\t\t\t\t\"Use this instead of [Rpc.Broadcast] + a client-side 'is this for me?' check -- FilterInclude scopes it server-side, so no data leaks and no wasted bandwidth.\"\r\n\t\t\t\t}\r\n\t\t\t} );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"add_targeted_rpc failed: {ex.Message}\" } );\r\n\t\t}\r\n\t}\r\n\r\n\tstatic string BuildCode( string className )\r\n\t{\r\n\t\treturn $@\"using Sandbox;\r\nusing System;\r\n\r\n/// <summary>\r\n/// {className} -- send a message to exactly ONE client using Rpc.FilterInclude.\r\n///\r\n/// A normal [Rpc.Broadcast] runs on EVERY machine. Wrapping the call in\r\n/// using ( Rpc.FilterInclude( target ) ) scopes it server-side so ONLY the target\r\n/// connection executes the RPC body -- the right way to unicast (a private prompt,\r\n/// a personal reward toast, a per-player cutscene) instead of broadcasting to all\r\n/// and filtering on the client (which leaks data + wastes bandwidth).\r\n///\r\n/// Call SendTo on the host. Single-player safe (with no session it just runs locally).\r\n///\r\n/// Usage (host-side):\r\n/// GetComponent<{className}>()?.SendTo( somePlayer.Network.Owner, \"\"You're up next!\"\" );\r\n/// {className}.OnReceived += msg => Log.Info( $\"\"(only me) {{msg}}\"\" );\r\n/// </summary>\r\npublic sealed class {className} : Component\r\n{{\r\n\t/// <summary>Fires on the TARGET client only (and locally in solo) when a message arrives.</summary>\r\n\tpublic static Action<string> OnReceived {{ get; set; }}\r\n\r\n\t/// <summary>\r\n\t/// Host-side: deliver <paramref name=\"\"message\"\"/> to exactly one connection.\r\n\t/// FilterInclude scopes the broadcast so only <paramref name=\"\"target\"\"/> runs it.\r\n\t/// </summary>\r\n\tpublic void SendTo( Connection target, string message )\r\n\t{{\r\n\t\tif ( target == null ) return;\r\n\r\n\t\t// Only the host should originate a targeted message in a host-authoritative\r\n\t\t// game. Guarded behind IsActive because Networking.IsHost can throw with no\r\n\t\t// session; in solo this falls through and just runs locally.\r\n\t\tif ( Networking.IsActive && !Networking.IsHost ) return;\r\n\r\n\t\tusing ( Rpc.FilterInclude( target ) )\r\n\t\t\tReceive( message );\r\n\t}}\r\n\r\n\t/// <summary>\r\n\t/// The unicast body. Public so the RPC source generator is happy. Runs ONLY on the\r\n\t/// filtered target connection (FilterInclude decided that server-side).\r\n\t/// </summary>\r\n\t[Rpc.Broadcast]\r\n\tpublic void Receive( string message )\r\n\t{{\r\n\t\tOnReceived?.Invoke( message );\r\n\t}}\r\n}}\r\n\";\r\n\t}\r\n}\r\n\r\n// -----------------------------------------------------------------------------\r\n// create_local_player_resolver -- proxy-safe \"who is MY player\".\r\n//\r\n// Static Local property that lazily finds the player GameObject owned by the local\r\n// connection ( Network.Owner == Connection.Local, or Network.IsOwner ) when\r\n// networking is active, and falls back to the first/only tagged player when it is\r\n// NOT (offline/solo). Cached with an IsValid() revalidation. The corpus footgun\r\n// killer -- running \"my player\" logic against a proxy of someone else's player.\r\n// -----------------------------------------------------------------------------\r\npublic class CreateLocalPlayerResolverHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tif ( !ScaffoldHelpers.PrepareCodeFile( p, \"LocalPlayerResolver\", out var fullPath, out var relPath, out var className, out var err ) )\r\n\t\t\t\treturn Task.FromResult<object>( err );\r\n\r\n\t\t\tvar tag = p.TryGetProperty( \"playerTag\", out var tv ) && !string.IsNullOrWhiteSpace( tv.GetString() )\r\n\t\t\t\t? tv.GetString().Trim() : \"player\";\r\n\t\t\tvar tagLiteral = NetPrimitivesHelpers.EscapeStringLiteral( tag );\r\n\r\n\t\t\tvar code = BuildCode( className, tagLiteral );\r\n\t\t\tScaffoldHelpers.WriteCode( fullPath, code );\r\n\r\n\t\t\tobject placedOn = null; string note = null;\r\n\t\t\tif ( p.TryGetProperty( \"targetId\", out var tid ) && tid.ValueKind == JsonValueKind.String )\r\n\t\t\t\tplacedOn = NetPrimitivesHelpers.PlaceOnTarget( tid.GetString(), className, out note );\r\n\r\n\t\t\treturn Task.FromResult<object>( new\r\n\t\t\t{\r\n\t\t\t\tcreated = true,\r\n\t\t\t\tpath = relPath,\r\n\t\t\t\tclassName,\r\n\t\t\t\tplayerTag = tag,\r\n\t\t\t\tplacedOn,\r\n\t\t\t\tnote,\r\n\t\t\t\tnextSteps = new[]\r\n\t\t\t\t{\r\n\t\t\t\t\t$\"trigger_hotload to compile {className} into the game assembly.\",\r\n\t\t\t\t\tplacedOn != null\r\n\t\t\t\t\t\t? $\"{className} was attached to the target GameObject.\"\r\n\t\t\t\t\t\t: $\"Attach ONE to a persistent object (your game manager): add_component_with_properties (component=\\\"{className}\\\") after the hotload, or re-run with targetId. Placing it lets you set PlayerTag in the inspector.\",\r\n\t\t\t\t\t$\"Tag each player GameObject with \\\"{tag}\\\" (set_tags) so the resolver can find them.\",\r\n\t\t\t\t\t$\"Read your player from anywhere: var me = {className}.Local; -- online it is the object you OWN, offline it is the only player. Cached + revalidated automatically.\",\r\n\t\t\t\t\t$\"Filter events to your own player: if ( {className}.IsLocal( someGameObject ) ) {{ ... }} -- kills the 'ran my UI/logic against a proxy' footgun.\"\r\n\t\t\t\t}\r\n\t\t\t} );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"create_local_player_resolver failed: {ex.Message}\" } );\r\n\t\t}\r\n\t}\r\n\r\n\tstatic string BuildCode( string className, string tagLiteral )\r\n\t{\r\n\t\treturn $@\"using Sandbox;\r\nusing System;\r\n\r\n/// <summary>\r\n/// {className} -- \"\"who is MY player?\"\", the proxy-safe way. Resolves the player\r\n/// GameObject that belongs to THIS machine, both online and offline.\r\n///\r\n/// Online: your player is the tagged object whose Network.Owner is the local\r\n/// connection ( Network.Owner == Connection.Local, or Network.IsOwner ). Offline /\r\n/// solo (no session), there is exactly one player, so it returns the first tagged\r\n/// object. The result is cached and revalidated with IsValid() so a destroyed /\r\n/// respawned player is re-resolved automatically.\r\n///\r\n/// Attach ONE of these to a persistent object (your game manager) so PlayerTag is\r\n/// configurable; the resolver itself is static and callable from anywhere:\r\n/// var me = {className}.Local; // my player GameObject (or null)\r\n/// if ( {className}.IsLocal( someGo ) ) ... // filter events to my own player\r\n///\r\n/// This kills the #1 multiplayer footgun -- running \"\"my player\"\" logic against a\r\n/// proxy of someone else's player.\r\n/// </summary>\r\npublic sealed class {className} : Component\r\n{{\r\n\t/// <summary>Tag that marks a player GameObject. Players must carry this tag.</summary>\r\n\t[Property] public string PlayerTag {{ get; set; }} = \"\"{tagLiteral}\"\";\r\n\r\n\tprivate static {className} _instance;\r\n\tprivate static string _tag = \"\"{tagLiteral}\"\";\r\n\tprivate static GameObject _cached;\r\n\r\n\tprotected override void OnEnabled()\r\n\t{{\r\n\t\t_instance = this;\r\n\t\t_tag = PlayerTag;\r\n\t}}\r\n\r\n\tprotected override void OnDisabled()\r\n\t{{\r\n\t\tif ( _instance == this ) _instance = null;\r\n\t}}\r\n\r\n\t/// <summary>The local machine's player GameObject, or null if not found yet.</summary>\r\n\tpublic static GameObject Local\r\n\t{{\r\n\t\tget\r\n\t\t{{\r\n\t\t\tif ( IsLocal( _cached ) ) return _cached; // cache hit, still valid + still ours\r\n\t\t\t_cached = Resolve();\r\n\t\t\treturn _cached;\r\n\t\t}}\r\n\t}}\r\n\r\n\t/// <summary>True if <paramref name=\"\"go\"\"/> is the local machine's player.</summary>\r\n\tpublic static bool IsLocal( GameObject go )\r\n\t{{\r\n\t\tif ( !go.IsValid() ) return false;\r\n\t\tif ( !Networking.IsActive ) return true; // solo: the only player is mine\r\n\t\treturn go.Network.Owner == Connection.Local || go.Network.IsOwner;\r\n\t}}\r\n\r\n\tprivate static GameObject Resolve()\r\n\t{{\r\n\t\tvar scene = Game.ActiveScene;\r\n\t\tif ( !scene.IsValid() ) return null;\r\n\r\n\t\tif ( !Networking.IsActive )\r\n\t\t{{\r\n\t\t\t// Offline / solo: the first tagged player is ours.\r\n\t\t\tforeach ( var go in scene.GetAllObjects( true ) )\r\n\t\t\t\tif ( go.Tags.Has( _tag ) ) return go;\r\n\t\t\treturn null;\r\n\t\t}}\r\n\r\n\t\t// Online: our player is the tagged object owned by the local connection.\r\n\t\tforeach ( var go in scene.GetAllObjects( true ) )\r\n\t\t{{\r\n\t\t\tif ( !go.Tags.Has( _tag ) ) continue;\r\n\t\t\tif ( go.Network.Owner == Connection.Local || go.Network.IsOwner )\r\n\t\t\t\treturn go;\r\n\t\t}}\r\n\t\treturn null;\r\n\t}}\r\n}}\r\n\";\r\n\t}\r\n}\r\n\r\n// -----------------------------------------------------------------------------\r\n// add_host_migration_recovery -- proxy->authority transition detector.\r\n//\r\n// Tracks previous IsProxy each frame; when it flips from true to false (we became\r\n// the authority for this object, i.e. host migration promoted us), it fires a\r\n// static OnBecameHost event and runs a virtual-style TODO rebuild hook, then -- a\r\n// short settle delay later -- a deferred validation hook. Inert offline (IsProxy\r\n// is always false with no session).\r\n// -----------------------------------------------------------------------------\r\npublic class AddHostMigrationRecoveryHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar ci = System.Globalization.CultureInfo.InvariantCulture;\r\n\r\n\t\t\tif ( !ScaffoldHelpers.PrepareCodeFile( p, \"HostMigrationRecovery\", out var fullPath, out var relPath, out var className, out var err ) )\r\n\t\t\t\treturn Task.FromResult<object>( err );\r\n\r\n\t\t\t// Settle delay is fixed at the cookbook-recommended ~1s but exposed as a\r\n\t\t\t// [Property] so it is tunable; no param for it (keeps the schema to name/directory).\r\n\t\t\tfloat settle = 1f;\r\n\r\n\t\t\tvar code = BuildCode( className, settle, ci );\r\n\t\t\tScaffoldHelpers.WriteCode( fullPath, code );\r\n\r\n\t\t\tobject placedOn = null; string note = null;\r\n\t\t\tif ( p.TryGetProperty( \"targetId\", out var tid ) && tid.ValueKind == JsonValueKind.String )\r\n\t\t\t\tplacedOn = NetPrimitivesHelpers.PlaceOnTarget( tid.GetString(), className, out note );\r\n\r\n\t\t\treturn Task.FromResult<object>( new\r\n\t\t\t{\r\n\t\t\t\tcreated = true,\r\n\t\t\t\tpath = relPath,\r\n\t\t\t\tclassName,\r\n\t\t\t\tplacedOn,\r\n\t\t\t\tnote,\r\n\t\t\t\tnextSteps = new[]\r\n\t\t\t\t{\r\n\t\t\t\t\t$\"trigger_hotload to compile {className} into the game assembly.\",\r\n\t\t\t\t\tplacedOn != null\r\n\t\t\t\t\t\t? $\"{className} was attached to the target GameObject.\"\r\n\t\t\t\t\t\t: $\"Attach it to your host-authoritative manager object: add_component_with_properties (component=\\\"{className}\\\") after the hotload, or re-run with targetId. The object should be NetworkSpawn'd.\",\r\n\t\t\t\t\t$\"React to becoming host: {className}.OnBecameHost += go => Log.Info( \\\"I am the host now -- rebuilding\\\" );\",\r\n\t\t\t\t\t\"Fill in the RebuildAfterMigration() TODO region: re-arm host-only loops/timers against your clock, TakeOwnership of orphans, rebuild handle maps by world position, reconcile your [Sync] registry against the real scene.\",\r\n\t\t\t\t\t\"Fill in the deferred ValidateAfterMigration() TODO: sanity-check expected-vs-actual and hard-reset the round if it looks corrupt (SettleSeconds delay lets in-flight packets land first).\",\r\n\t\t\t\t\t\"Requires a real host migration to fire (a second client that becomes host when the first leaves) -- it is inert in solo/offline play.\"\r\n\t\t\t\t}\r\n\t\t\t} );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"add_host_migration_recovery failed: {ex.Message}\" } );\r\n\t\t}\r\n\t}\r\n\r\n\tstatic string BuildCode( string className, float settle, System.Globalization.CultureInfo ci )\r\n\t{\r\n\t\tstring st = settle.ToString( ci ) + \"f\";\r\n\r\n\t\treturn $@\"using Sandbox;\r\nusing System;\r\n\r\n/// <summary>\r\n/// {className} -- detects when THIS machine takes authority over this object\r\n/// (proxy -> owner), which is what happens to a host-authoritative manager during\r\n/// host migration, and gives you a clean hook to rebuild host-only state.\r\n///\r\n/// It tracks IsProxy each frame; when it flips from true (someone else was the\r\n/// authority) to false (now it is us), it fires OnBecameHost and runs the rebuild\r\n/// hook, then -- after a short settle delay so in-flight packets can land -- runs a\r\n/// deferred validation hook. Inert offline (IsProxy is always false with no session).\r\n///\r\n/// Attach to your host-authoritative manager object. Fill in the two TODO regions.\r\n///\r\n/// Usage:\r\n/// {className}.OnBecameHost += go => Log.Info( \"\"I am the host now -- rebuilding\"\" );\r\n/// </summary>\r\npublic sealed class {className} : Component\r\n{{\r\n\t/// <summary>Seconds to wait after becoming host before the deferred validation runs.</summary>\r\n\t[Property] public float SettleSeconds {{ get; set; }} = {st};\r\n\r\n\t/// <summary>Fires on the machine that just gained authority. Arg = this GameObject.</summary>\r\n\tpublic static Action<GameObject> OnBecameHost {{ get; set; }}\r\n\r\n\tprivate bool _wasProxy;\r\n\tprivate bool _initialized;\r\n\tprivate bool _pendingValidate;\r\n\tprivate TimeSince _sinceBecameHost;\r\n\r\n\tprotected override void OnEnabled()\r\n\t{{\r\n\t\t_wasProxy = IsProxy; // baseline so we only fire on a real transition\r\n\t\t_initialized = true;\r\n\t}}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{{\r\n\t\tbool proxyNow = IsProxy;\r\n\t\tif ( _initialized && _wasProxy && !proxyNow )\r\n\t\t\tBecameHost();\r\n\t\t_wasProxy = proxyNow;\r\n\r\n\t\tif ( _pendingValidate && _sinceBecameHost > SettleSeconds )\r\n\t\t{{\r\n\t\t\t_pendingValidate = false;\r\n\t\t\tValidateAfterMigration();\r\n\t\t}}\r\n\t}}\r\n\r\n\tprivate void BecameHost()\r\n\t{{\r\n\t\t_sinceBecameHost = 0f;\r\n\t\t_pendingValidate = true;\r\n\t\tRebuildAfterMigration();\r\n\t\tOnBecameHost?.Invoke( GameObject );\r\n\t}}\r\n\r\n\t// virtual-style rebuild hook -- edit this body (the component is sealed, so there\r\n\t// is nothing to override; this region IS your override point).\r\n\tprivate void RebuildAfterMigration()\r\n\t{{\r\n\t\t// TODO: rebuild host-only state now that YOU are the authority. The previous\r\n\t\t// host is gone; anything it owned or was mid-computing is now your job. Typical\r\n\t\t// moves (networking-authority cookbook, pattern 17):\r\n\t\t// - Re-arm host-only loops / spawners. A [Sync] TimeUntil stores the DEAD\r\n\t\t// host's clock epoch -- read its .Relative remaining and re-arm it here.\r\n\t\t// - Network.TakeOwnership() any orphaned objects you must now manage/destroy.\r\n\t\t// - Rebuild handle->handle maps by world-position matching (object Ids do not\r\n\t\t// survive migration).\r\n\t\t// - Reconcile your [Sync] registry against the REAL scene (drop dead entries,\r\n\t\t// add visible objects the list is missing).\r\n\t}}\r\n\r\n\t// deferred sanity check -- runs SettleSeconds after becoming host so in-flight\r\n\t// packets that have not applied yet do not make a healthy scene look broken.\r\n\tprivate void ValidateAfterMigration()\r\n\t{{\r\n\t\t// TODO: compare expected-vs-actual (child counts, roster tags) and hard-reset\r\n\t\t// the round rather than limping along if it looks corrupt. (cookbook pattern 17)\r\n\t}}\r\n}}\r\n\";\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// Shared helpers for the networking-primitives handlers -- mirrors the standard\r\n/// scaffold placement (GameFeelHelpers / create_event_director) plus a tiny\r\n/// string-literal escaper for baked-in tag defaults.\r\n/// </summary>\r\ninternal static class NetPrimitivesHelpers\r\n{\r\n\tpublic static object PlaceOnTarget( string targetId, string className, out string note )\r\n\t{\r\n\t\tnote = null;\r\n\t\tvar scene = SceneEditorSession.Active?.Scene;\r\n\t\tif ( scene == null ) { note = \"No active scene to place into.\"; return null; }\r\n\t\tif ( !Guid.TryParse( targetId, out var guid ) ) { note = \"Invalid targetId GUID.\"; return null; }\r\n\t\tvar go = scene.Directory.FindByGuid( guid );\r\n\t\tif ( go == null ) { note = $\"Target GameObject not found: {targetId}\"; return null; }\r\n\t\tvar typeDesc = Game.TypeLibrary.GetType( className );\r\n\t\tif ( typeDesc == null )\r\n\t\t{\r\n\t\t\tnote = $\"Generated {className}.cs but it is not in the TypeLibrary yet -- trigger_hotload, then add it with add_component_with_properties.\";\r\n\t\t\treturn null;\r\n\t\t}\r\n\t\ttry { go.Components.Create( typeDesc ); return ClaudeBridge.SerializeGo( go ); }\r\n\t\tcatch ( Exception ex ) { note = $\"Placement failed ({ex.Message}).\"; return null; }\r\n\t}\r\n\r\n\t/// <summary>Escape a user string so it can be baked as a C# double-quoted literal.</summary>\r\n\tpublic static string EscapeStringLiteral( string s )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( s ) ) return \"player\";\r\n\t\treturn s.Replace( \"\\\\\", \"\\\\\\\\\" ).Replace( \"\\\"\", \"\\\\\\\"\" );\r\n\t}\r\n}\r\n"
},
{
"Ident": "sboxskinsgg.claudebridge",
"Path": "Editor/ProjectAuditHandlers.cs",
"FileName": "ProjectAuditHandlers.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 335526,
"Code": "using Editor;\r\nusing Sandbox;\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.IO;\r\nusing System.Linq;\r\nusing System.Text.Json;\r\nusing System.Threading.Tasks;\r\n\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n// Batch 51 \u2014 Project audit & batch operations (v2 relaunch wave 1)\r\n// find_broken_references \u2014 scene-wide broken/dead reference scan\r\n// batch_set_property \u2014 one property across many objects, with dry-run\r\n// describe_project \u2014 one-call project orientation summary\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n\r\n/// <summary>\r\n/// find_broken_references \u2014 scan the open scene for null models on renderers,\r\n/// destroyed-but-still-referenced GameObjects/Components in component properties,\r\n/// and unresolvable (null) component entries.\r\n/// </summary>\r\npublic class FindBrokenReferencesHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\tvar scene = SceneEditorSession.Active?.Scene;\r\n\t\tif ( scene == null )\r\n\t\t\treturn Task.FromResult<object>( new { error = \"No active scene\" } );\r\n\r\n\t\tint limit = p.TryGetProperty( \"limit\", out var l ) ? l.GetInt32() : 100;\r\n\t\tif ( limit < 1 ) limit = 1; if ( limit > 500 ) limit = 500;\r\n\r\n\t\tvar issues = new List<object>();\r\n\t\tint total = 0;\r\n\t\tint objectsScanned = 0;\r\n\r\n\t\tvoid AddIssue( GameObject go, string component, string kind, string detail )\r\n\t\t{\r\n\t\t\ttotal++;\r\n\t\t\tif ( issues.Count < limit )\r\n\t\t\t\tissues.Add( new { id = go.Id.ToString(), name = go.Name, component, kind, detail } );\r\n\t\t}\r\n\r\n\t\tforeach ( var go in scene.GetAllObjects( true ) )\r\n\t\t{\r\n\t\t\tif ( go == null ) continue;\r\n\t\t\tobjectsScanned++;\r\n\r\n\t\t\tforeach ( var comp in go.Components.GetAll() )\r\n\t\t\t{\r\n\t\t\t\tif ( comp == null )\r\n\t\t\t\t{\r\n\t\t\t\t\tAddIssue( go, \"(null)\", \"missing_component\", \"Component entry is null \u2014 its type may no longer exist/compile\" );\r\n\t\t\t\t\tcontinue;\r\n\t\t\t\t}\r\n\r\n\t\t\t\tif ( comp is ModelRenderer mr && mr.Model == null )\r\n\t\t\t\t\tAddIssue( go, comp.GetType().Name, \"missing_model\", \"Renderer has no Model assigned\" );\r\n\r\n\t\t\t\t// Destroyed-but-referenced objects/components: a null ref is usually a\r\n\t\t\t\t// legitimate 'unset optional', but a ref to a DESTROYED thing is broken.\r\n\t\t\t\tvar typeDesc = Game.TypeLibrary.GetType( comp.GetType().Name );\r\n\t\t\t\tif ( typeDesc == null ) continue;\r\n\t\t\t\tforeach ( var prop in typeDesc.Properties )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar pt = prop.PropertyType;\r\n\t\t\t\t\tbool isGo = pt == typeof( GameObject );\r\n\t\t\t\t\tbool isComp = typeof( Component ).IsAssignableFrom( pt );\r\n\t\t\t\t\tif ( !isGo && !isComp ) continue;\r\n\r\n\t\t\t\t\tobject val;\r\n\t\t\t\t\ttry { val = prop.GetValue( comp ); }\r\n\t\t\t\t\tcatch { continue; }\r\n\t\t\t\t\tif ( val == null ) continue;\r\n\r\n\t\t\t\t\tif ( val is GameObject g && !g.IsValid() )\r\n\t\t\t\t\t\tAddIssue( go, comp.GetType().Name, \"dead_gameobject_ref\", $\"{prop.Name} references a destroyed GameObject\" );\r\n\t\t\t\t\telse if ( val is Component c && !c.IsValid() )\r\n\t\t\t\t\t\tAddIssue( go, comp.GetType().Name, \"dead_component_ref\", $\"{prop.Name} references a destroyed Component\" );\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\t// v2 round 2: scan .scene/.prefab FILES for prefab references to files that no\r\n\t\t// longer exist ({\"_type\":\"gameobject\",\"prefab\":\"prefabs/x.prefab\"} with x deleted\r\n\t\t// or renamed) \u2014 the break class scene-level checks can't see.\r\n\t\tint filesScanned = 0;\r\n\t\tbool scanFiles = !( p.TryGetProperty( \"scanFiles\", out var sf ) && sf.ValueKind == JsonValueKind.False );\r\n\t\tif ( scanFiles )\r\n\t\t{\r\n\t\t\ttry\r\n\t\t\t{\r\n\t\t\t\tvar root = Project.Current?.GetRootPath();\r\n\t\t\t\tif ( root != null )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar rx = new System.Text.RegularExpressions.Regex( \"\\\"prefab\\\":\\\\s*\\\"([^\\\"]+)\\\"\" );\r\n\t\t\t\t\tvar files = Directory.GetFiles( root, \"*.scene\", SearchOption.AllDirectories )\r\n\t\t\t\t\t\t.Concat( Directory.GetFiles( root, \"*.prefab\", SearchOption.AllDirectories ) )\r\n\t\t\t\t\t\t.Where( f => { var r = Path.GetRelativePath( root, f ).Replace( '\\\\', '/' ); return !r.StartsWith( \"Libraries/\" ) && !r.StartsWith( \".sbox/\" ); } );\r\n\t\t\t\t\tforeach ( var file in files )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tfilesScanned++;\r\n\t\t\t\t\t\tvar rel = Path.GetRelativePath( root, file ).Replace( '\\\\', '/' );\r\n\t\t\t\t\t\tforeach ( System.Text.RegularExpressions.Match m in rx.Matches( File.ReadAllText( file ) ) )\r\n\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\tvar refPath = m.Groups[1].Value;\r\n\t\t\t\t\t\t\tbool exists = File.Exists( Path.Combine( root, refPath ) )\r\n\t\t\t\t\t\t\t\t|| File.Exists( Path.Combine( root, \"Assets\", refPath ) );\r\n\t\t\t\t\t\t\tif ( !exists )\r\n\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\ttotal++;\r\n\t\t\t\t\t\t\t\tif ( issues.Count < limit )\r\n\t\t\t\t\t\t\t\t\tissues.Add( new { id = (string)null, name = rel, component = \"(file)\", kind = \"missing_prefab_file\", detail = $\"references '{refPath}' which does not exist in the project\" } );\r\n\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t}\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t\tcatch { /* file scan is best-effort \u2014 scene checks above already reported */ }\r\n\t\t}\r\n\r\n\t\treturn Task.FromResult<object>( new\r\n\t\t{\r\n\t\t\ttotal,\r\n\t\t\tshowing = issues.Count,\r\n\t\t\ttruncated = total > issues.Count,\r\n\t\t\tobjectsScanned,\r\n\t\t\tfilesScanned,\r\n\t\t\tissues,\r\n\t\t\tnote = total == 0\r\n\t\t\t\t? \"No broken references found.\"\r\n\t\t\t\t: \"Fix missing_model with assign_model; clear dead refs with set_property (value null) or set_component_reference to a live target; missing_prefab_file means a .scene/.prefab references a deleted/renamed prefab \u2014 fix the path or recreate it with create_prefab.\"\r\n\t\t} );\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// batch_set_property \u2014 set one component property to the same value across many\r\n/// GameObjects, with a dry-run mode that validates and reports without applying.\r\n/// </summary>\r\npublic class BatchSetPropertyHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\tvar scene = SceneEditorSession.Active?.Scene;\r\n\t\tif ( scene == null )\r\n\t\t\treturn Task.FromResult<object>( new { error = \"No active scene\" } );\r\n\r\n\t\tif ( !p.TryGetProperty( \"ids\", out var idsEl ) || idsEl.ValueKind != JsonValueKind.Array || idsEl.GetArrayLength() == 0 )\r\n\t\t\treturn Task.FromResult<object>( new { error = \"ids (non-empty array of GameObject GUIDs) is required\" } );\r\n\t\tvar componentType = p.TryGetProperty( \"component\", out var ct ) ? ct.GetString() : null;\r\n\t\tif ( string.IsNullOrWhiteSpace( componentType ) )\r\n\t\t\treturn Task.FromResult<object>( new { error = \"component (type name) is required\" } );\r\n\t\tvar propertyName = p.TryGetProperty( \"property\", out var pn ) ? pn.GetString() : null;\r\n\t\tif ( string.IsNullOrWhiteSpace( propertyName ) )\r\n\t\t\treturn Task.FromResult<object>( new { error = \"property (name) is required\" } );\r\n\t\tif ( !p.TryGetProperty( \"value\", out var valueEl ) )\r\n\t\t\treturn Task.FromResult<object>( new { error = \"value is required\" } );\r\n\t\tbool dryRun = p.TryGetProperty( \"dryRun\", out var dr ) && dr.ValueKind == JsonValueKind.True;\r\n\r\n\t\tvar results = new List<object>();\r\n\t\tint succeeded = 0, failed = 0, changed = 0, unchanged = 0;\r\n\r\n\t\tforeach ( var idEl in idsEl.EnumerateArray() )\r\n\t\t{\r\n\t\t\tvar id = idEl.GetString();\r\n\t\t\tvoid Fail( string why ) { failed++; results.Add( new { id, ok = false, error = why } ); }\r\n\r\n\t\t\tvar go = ClaudeBridge.ResolveGameObject( scene, id );\r\n\t\t\tif ( go == null ) { Fail( \"GameObject not found\" ); continue; }\r\n\r\n\t\t\tvar component = go.Components.GetAll()\r\n\t\t\t\t.FirstOrDefault( c => c != null && c.GetType().Name.Equals( componentType, StringComparison.OrdinalIgnoreCase ) );\r\n\t\t\tif ( component == null ) { Fail( $\"No '{componentType}' component\" ); continue; }\r\n\r\n\t\t\tvar typeDesc = Game.TypeLibrary.GetType( component.GetType().Name );\r\n\t\t\tvar propDesc = typeDesc?.Properties.FirstOrDefault( pp => pp.Name.Equals( propertyName, StringComparison.OrdinalIgnoreCase ) );\r\n\t\t\tif ( propDesc == null ) { Fail( $\"Property '{propertyName}' not found on {componentType}\" ); continue; }\r\n\r\n\t\t\tobject current = null;\r\n\t\t\ttry { current = propDesc.GetValue( component ); } catch { }\r\n\r\n\t\t\t// Resolve the exact typed value before either a dry-run receipt or a write.\r\n\t\t\t// Previously dry-run skipped coercion entirely and always claimed a change.\r\n\t\t\tobject proposed = null;\r\n\t\t\tvar valueStr = ClaudeBridge.ElementToValueString( valueEl );\r\n\t\t\tif ( !ClaudeBridge.CoercePropertyAndSet(\r\n\t\t\t\tpropDesc.PropertyType,\r\n\t\t\t\tv => proposed = v,\r\n\t\t\t\tpropDesc.Name,\r\n\t\t\t\tvalueStr,\r\n\t\t\t\tout var coerceError ) )\r\n\t\t\t{\r\n\t\t\t\tFail( coerceError );\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\tbool wouldChange = !Equals( current, proposed );\r\n\r\n\t\t\tif ( dryRun )\r\n\t\t\t{\r\n\t\t\t\tsucceeded++;\r\n\t\t\t\tif ( wouldChange ) changed++; else unchanged++;\r\n\t\t\t\tresults.Add( new\r\n\t\t\t\t{\r\n\t\t\t\t\tid,\r\n\t\t\t\t\tok = true,\r\n\t\t\t\t\twouldChange,\r\n\t\t\t\t\tcurrentValue = current?.ToString(),\r\n\t\t\t\t\tproposedValue = proposed?.ToString()\r\n\t\t\t\t} );\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\ttry\r\n\t\t\t{\r\n\t\t\t\t// Keep apply aligned with dry-run and avoid needless setter side effects.\r\n\t\t\t\tif ( !wouldChange )\r\n\t\t\t\t{\r\n\t\t\t\t\tsucceeded++;\r\n\t\t\t\t\tunchanged++;\r\n\t\t\t\t\tresults.Add( new { id, ok = true, changed = false, previous = current?.ToString(), value = proposed?.ToString() } );\r\n\t\t\t\t\tcontinue;\r\n\t\t\t\t}\r\n\r\n\t\t\t\tpropDesc.SetValue( component, proposed );\r\n\t\t\t\tsucceeded++;\r\n\t\t\t\tchanged++;\r\n\t\t\t\tresults.Add( new { id, ok = true, changed = true, previous = current?.ToString(), value = proposed?.ToString() } );\r\n\t\t\t}\r\n\t\t\tcatch ( Exception ex )\r\n\t\t\t{\r\n\t\t\t\tFail( ex.Message );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn Task.FromResult<object>( new\r\n\t\t{\r\n\t\t\ttotal = results.Count,\r\n\t\t\tsucceeded,\r\n\t\t\tfailed,\r\n\t\t\tdryRun,\r\n\t\t\tchanged,\r\n\t\t\tunchanged,\r\n\t\t\tresults,\r\n\t\t\tnote = dryRun\r\n\t\t\t\t? $\"Dry run - nothing was changed. {changed} would change; {unchanged} already match.\"\r\n\t\t\t\t: $\"Applied {changed} change(s); {unchanged} object(s) already matched; {failed} failed.\"\r\n\t\t} );\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// describe_project \u2014 a one-call orientation summary: project identity, scenes,\r\n/// prefabs, code footprint, custom component types, and installed libraries.\r\n/// </summary>\r\npublic class DescribeProjectHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\tvar project = Project.Current;\r\n\t\tif ( project == null )\r\n\t\t\treturn Task.FromResult<object>( new { error = \"No current project\" } );\r\n\r\n\t\tvar root = project.GetRootPath();\r\n\t\tvar scene = SceneEditorSession.Active?.Scene;\r\n\r\n\t\tstring[] Rel( IEnumerable<string> paths, int cap ) =>\r\n\t\t\tpaths.Select( f => Path.GetRelativePath( root, f ).Replace( '\\\\', '/' ) )\r\n\t\t\t\t.Where( f => !f.StartsWith( \"Libraries/\" ) && !f.StartsWith( \".sbox/\" ) )\r\n\t\t\t\t.Take( cap ).ToArray();\r\n\r\n\t\tstring[] scenes = Array.Empty<string>(), prefabs = Array.Empty<string>();\r\n\t\tint codeFiles = 0, razorFiles = 0;\r\n\t\ttry { scenes = Rel( Directory.GetFiles( root, \"*.scene\", SearchOption.AllDirectories ), 50 ); } catch { }\r\n\t\ttry { prefabs = Rel( Directory.GetFiles( root, \"*.prefab\", SearchOption.AllDirectories ), 50 ); } catch { }\r\n\t\ttry { codeFiles = Directory.GetFiles( Path.Combine( root, \"Code\" ), \"*.cs\", SearchOption.AllDirectories ).Length; } catch { }\r\n\t\ttry { razorFiles = Directory.GetFiles( Path.Combine( root, \"Code\" ), \"*.razor\", SearchOption.AllDirectories ).Length; } catch { }\r\n\r\n\t\t// Custom components = Component subclasses outside the engine namespaces.\r\n\t\tstring[] customComponents = Array.Empty<string>();\r\n\t\ttry\r\n\t\t{\r\n\t\t\tcustomComponents = Game.TypeLibrary.GetTypes<Component>()\r\n\t\t\t\t.Where( t => !t.IsAbstract && t.FullName != null\r\n\t\t\t\t\t&& !t.FullName.StartsWith( \"Sandbox.\" ) && !t.FullName.StartsWith( \"Editor.\" )\r\n\t\t\t\t\t&& !t.FullName.StartsWith( \"Facepunch.\" ) )\r\n\t\t\t\t.Select( t => t.Name ).OrderBy( n => n ).Take( 100 ).ToArray();\r\n\t\t}\r\n\t\tcatch { }\r\n\r\n\t\tstring[] libraries = Array.Empty<string>();\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar libDir = Path.Combine( root, \"Libraries\" );\r\n\t\t\tif ( Directory.Exists( libDir ) )\r\n\t\t\t\tlibraries = Directory.GetDirectories( libDir ).Select( Path.GetFileName ).OrderBy( n => n ).ToArray();\r\n\t\t}\r\n\t\tcatch { }\r\n\r\n\t\treturn Task.FromResult<object>( new\r\n\t\t{\r\n\t\t\tname = project.Config.Title,\r\n\t\t\tident = project.Config.Ident,\r\n\t\t\torg = project.Config.Org,\r\n\t\t\ttype = project.Config.Type,\r\n\t\t\trootPath = root.Replace( '\\\\', '/' ),\r\n\t\t\topenScene = scene == null ? null : new { name = scene.Name, objectCount = scene.GetAllObjects( true ).Count() },\r\n\t\t\tscenes = new { total = scenes.Length, files = scenes },\r\n\t\t\tprefabs = new { total = prefabs.Length, files = prefabs },\r\n\t\t\tcode = new { csFiles = codeFiles, razorFiles },\r\n\t\t\tcustomComponents = new { total = customComponents.Length, names = customComponents },\r\n\t\t\tlibraries,\r\n\t\t\tnote = \"Orient here, then: get_scene_hierarchy for the open scene, describe_type for any component, list_prefabs/get_prefab_info for prefabs, find_broken_references for health.\"\r\n\t\t} );\r\n\t}\r\n}\r\n"
},
{
"Ident": "sboxskinsgg.claudebridge",
"Path": "Editor/AiSystemsHandlers.cs",
"FileName": "AiSystemsHandlers.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 335526,
"Code": "using Editor;\r\nusing Sandbox;\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.IO;\r\nusing System.Linq;\r\nusing System.Text;\r\nusing System.Text.Json;\r\nusing System.Threading.Tasks;\r\n\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n// AI & Systems \u2014 Feature Wave (create_needs_system / create_utility_ai /\r\n// create_npc_schedule_brain / create_event_bus / add_tts_voice)\r\n//\r\n// Compiles into the SAME editor assembly as MyEditorMenu.cs, so it uses the\r\n// shared helpers directly: ClaudeBridge.TryResolveProjectPath / SanitizeIdentifier /\r\n// ParseVector3 / SerializeGo, ScaffoldHelpers.PrepareCodeFile / WriteCode, and the\r\n// IBridgeHandler dispatch contract. Handler code here is UNSANDBOXED editor code.\r\n//\r\n// The C# *strings these handlers generate* run in the SANDBOX (the game). Every\r\n// template below was live-compile-verified on 2026-07-12 (written into the live\r\n// project with default params, hotloaded, compile clean, TypeLibrary-load confirmed\r\n// for every class, then deleted): sealed Components + [Sync(SyncFlags.FromHost)],\r\n// nested data classes in [Property] List<T>, an abstract Component base with virtual\r\n// members, a static (non-Component) class, a C# record, TypeLibrary.GetType(Type) +\r\n// PropertyDescription.GetValue in game code, Rotation.LookAt(Vector3),\r\n// Sandbox.Speech.Synthesizer (fluent TrySetVoice/WithText/WithRate/Play), and\r\n// SoundHandle (Stop(fade)/IsPlaying/IsValid/SetParent/ListenLocal/LipSync.Enabled).\r\n//\r\n// Registration lines + the _sceneMutatingCommands additions are wired by the main\r\n// agent in MyEditorMenu.cs (see this wave's summary) to avoid a merge conflict.\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n\r\n/// <summary>\r\n/// Shared helpers for the AI & Systems generators. Kept internal to this file so\r\n/// it does not collide with anything in MyEditorMenu.cs or sibling handler files.\r\n/// </summary>\r\ninternal static class AiSystemsHelpers\r\n{\r\n\t/// <summary>Read an optional float param \u2014 tolerates a JSON number OR a numeric string.</summary>\r\n\tpublic static float Float( JsonElement p, string key, float fallback )\r\n\t{\r\n\t\tif ( !p.TryGetProperty( key, out var e ) ) return fallback;\r\n\t\tif ( e.ValueKind == JsonValueKind.Number && e.TryGetSingle( out var f ) ) return f;\r\n\t\tif ( e.ValueKind == JsonValueKind.String\r\n\t\t && float.TryParse( e.GetString(), System.Globalization.NumberStyles.Float,\r\n\t\t System.Globalization.CultureInfo.InvariantCulture, out var fs ) ) return fs;\r\n\t\treturn fallback;\r\n\t}\r\n\r\n\tpublic static int Int( JsonElement p, string key, int fallback )\r\n\t{\r\n\t\tif ( !p.TryGetProperty( key, out var e ) ) return fallback;\r\n\t\tif ( e.ValueKind == JsonValueKind.Number && e.TryGetInt32( out var i ) ) return i;\r\n\t\tif ( e.ValueKind == JsonValueKind.String && int.TryParse( e.GetString(), out var iss ) ) return iss;\r\n\t\treturn fallback;\r\n\t}\r\n\r\n\tpublic static bool Bool( JsonElement p, string key, bool fallback )\r\n\t{\r\n\t\tif ( !p.TryGetProperty( key, out var e ) ) return fallback;\r\n\t\tif ( e.ValueKind == JsonValueKind.True ) return true;\r\n\t\tif ( e.ValueKind == JsonValueKind.False ) return false;\r\n\t\tif ( e.ValueKind == JsonValueKind.String && bool.TryParse( e.GetString(), out var b ) ) return b;\r\n\t\treturn fallback;\r\n\t}\r\n\r\n\tpublic static string Str( JsonElement p, string key, string fallback )\r\n\t{\r\n\t\tif ( p.TryGetProperty( key, out var e ) && e.ValueKind == JsonValueKind.String )\r\n\t\t{\r\n\t\t\tvar s = e.GetString();\r\n\t\t\tif ( !string.IsNullOrWhiteSpace( s ) ) return s;\r\n\t\t}\r\n\t\treturn fallback;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Format a float as an invariant-culture C# literal with an 'f' suffix (130 -> \"130f\").\r\n\t/// Invariant culture matters: a comma-decimal locale must not emit \"0,25f\".\r\n\t/// </summary>\r\n\tpublic static string F( float v )\r\n\t{\r\n\t\tvar s = v.ToString( \"0.0###\", System.Globalization.CultureInfo.InvariantCulture );\r\n\t\treturn s + \"f\";\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Escape a user string for embedding inside a REGULAR C# string literal (\"...\") in\r\n\t/// generated code: backslash-escape \\ and \", strip/escape control chars. (EscVerbatim-style\r\n\t/// quote-doubling is only valid inside @\"\" literals \u2014 the generated property defaults and\r\n\t/// list initializers are regular literals, caught live by the quote-in-need-name test.)\r\n\t/// </summary>\r\n\tpublic static string EscString( string raw )\r\n\t{\r\n\t\treturn ( raw ?? \"\" )\r\n\t\t\t.Replace( \"\\\\\", \"\\\\\\\\\" )\r\n\t\t\t.Replace( \"\\\"\", \"\\\\\\\"\" )\r\n\t\t\t.Replace( \"\\r\", \"\\\\r\" )\r\n\t\t\t.Replace( \"\\n\", \"\\\\n\" )\r\n\t\t\t.Replace( \"\\t\", \"\\\\t\" );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Attach the generated component to a scene GameObject by GUID \u2014 only possible if\r\n\t/// the type is ALREADY in the TypeLibrary (i.e. after a hotload). Mirrors the proven\r\n\t/// PlaceOnTarget in ScaffoldHandlers/EconomySaveHandlers.\r\n\t/// </summary>\r\n\tpublic static object PlaceOnTarget( string targetId, string className, out string note )\r\n\t{\r\n\t\tnote = null;\r\n\t\tvar scene = SceneEditorSession.Active?.Scene;\r\n\t\tif ( scene == null ) { note = \"No active scene to place into.\"; return null; }\r\n\t\tif ( !Guid.TryParse( targetId, out var guid ) ) { note = \"Invalid targetId GUID.\"; return null; }\r\n\t\tvar go = scene.Directory.FindByGuid( guid );\r\n\t\tif ( go == null ) { note = $\"Target GameObject not found: {targetId}\"; return null; }\r\n\t\tvar typeDesc = Game.TypeLibrary.GetType( className );\r\n\t\tif ( typeDesc == null )\r\n\t\t{\r\n\t\t\tnote = $\"Generated {className}.cs but it is not in the TypeLibrary yet \u2014 trigger_hotload, then add it with add_component_with_properties.\";\r\n\t\t\treturn null;\r\n\t\t}\r\n\t\ttry { go.Components.Create( typeDesc ); return ClaudeBridge.SerializeGo( go ); }\r\n\t\tcatch ( Exception ex ) { note = $\"Placement failed ({ex.Message}).\"; return null; }\r\n\t}\r\n}\r\n\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n// 1. create_needs_system (code-gen; scene-mutating)\r\n// Sim/tycoon needs engine: [Property] list of need definitions, per-need\r\n// 0..100 values decaying over Time.Delta, Satisfy(name, amount), weighted-\r\n// mean Happiness, static OnNeedCritical / OnHappinessChanged events.\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\npublic class CreateNeedsSystemHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tif ( !ScaffoldHelpers.PrepareCodeFile( p, \"NeedsSystem\", out var fullPath, out var relPath, out var className, out var err ) )\r\n\t\t\t\treturn Task.FromResult<object>( err );\r\n\r\n\t\t\tvar networked = AiSystemsHelpers.Bool( p, \"networked\", true );\r\n\r\n\t\t\t// \u2500\u2500 Need definitions: explicit `needs` array wins, else the classic sim trio.\r\n\t\t\tvar needLines = new StringBuilder();\r\n\t\t\tvar needNames = new List<string>();\r\n\t\t\tif ( p.TryGetProperty( \"needs\", out var arr ) && arr.ValueKind == JsonValueKind.Array && arr.GetArrayLength() > 0 )\r\n\t\t\t{\r\n\t\t\t\tforeach ( var e in arr.EnumerateArray() )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar nName = AiSystemsHelpers.Str( e, \"name\", \"Need\" );\r\n\t\t\t\t\tvar decay = AiSystemsHelpers.Float( e, \"decayPerSecond\", 0.5f );\r\n\t\t\t\t\tvar crit = AiSystemsHelpers.Float( e, \"criticalThreshold\", 20f );\r\n\t\t\t\t\tvar weight = AiSystemsHelpers.Float( e, \"weight\", 1f );\r\n\t\t\t\t\tneedNames.Add( nName );\r\n\t\t\t\t\tneedLines.Append( \"\\t\\tnew NeedDefinition { Name = \\\"\" + AiSystemsHelpers.EscString( nName )\r\n\t\t\t\t\t\t+ \"\\\", DecayPerSecond = \" + AiSystemsHelpers.F( decay )\r\n\t\t\t\t\t\t+ \", CriticalThreshold = \" + AiSystemsHelpers.F( crit )\r\n\t\t\t\t\t\t+ \", Weight = \" + AiSystemsHelpers.F( weight ) + \" },\\n\" );\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\tneedNames.AddRange( new[] { \"Hunger\", \"Energy\", \"Fun\" } );\r\n\t\t\t\tneedLines.Append( \"\\t\\tnew NeedDefinition { Name = \\\"Hunger\\\", DecayPerSecond = 0.8f, CriticalThreshold = 20f, Weight = 1f },\\n\" );\r\n\t\t\t\tneedLines.Append( \"\\t\\tnew NeedDefinition { Name = \\\"Energy\\\", DecayPerSecond = 0.5f, CriticalThreshold = 15f, Weight = 1f },\\n\" );\r\n\t\t\t\tneedLines.Append( \"\\t\\tnew NeedDefinition { Name = \\\"Fun\\\", DecayPerSecond = 0.3f, CriticalThreshold = 10f, Weight = 0.5f },\\n\" );\r\n\t\t\t}\r\n\r\n\t\t\tvar code = BuildSource( className, networked, needLines.ToString() );\r\n\t\t\tScaffoldHelpers.WriteCode( fullPath, code );\r\n\r\n\t\t\tobject placedOn = null; string placeNote = null;\r\n\t\t\tif ( p.TryGetProperty( \"targetId\", out var tid ) && tid.ValueKind == JsonValueKind.String )\r\n\t\t\t\tplacedOn = AiSystemsHelpers.PlaceOnTarget( tid.GetString(), className, out placeNote );\r\n\r\n\t\t\treturn Task.FromResult<object>( new\r\n\t\t\t{\r\n\t\t\t\tcreated = true,\r\n\t\t\t\tpath = relPath,\r\n\t\t\t\tclassName,\r\n\t\t\t\tnetworked,\r\n\t\t\t\tneeds = needNames,\r\n\t\t\t\tpropertyNames = new[] { \"Needs\", \"Happiness\" },\r\n\t\t\t\tplacedOn,\r\n\t\t\t\tplacementNote = placeNote,\r\n\t\t\t\tnote = \"Per-need values live on the simulating machine only (read with GetNeed(name), restore with Satisfy(name, amount)); \" +\r\n\t\t\t\t \"the aggregate Happiness (weighted mean 0..100) \" +\r\n\t\t\t\t ( networked\r\n\t\t\t\t ? \"is [Sync(FromHost)] so clients can read it. Host-authoritative: decay + Satisfy only run on the host \u2014 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). \"\r\n\t\t\t\t : \"updates locally (networked:false build \u2014 no [Sync], no proxy guard; ticks in a single-machine playtest). \" ) +\r\n\t\t\t\t \"OnNeedCritical is edge-triggered (fires once crossing below threshold, re-arms above it); OnHappinessChanged fires on >0.25-point moves. \" +\r\n\t\t\t\t \"Both static events fire on the simulating machine only. Needs list is inspector-editable per instance.\"\r\n\t\t\t} );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"create_needs_system failed: {ex.Message}\" } );\r\n\t\t}\r\n\t}\r\n\r\n\tprivate static string BuildSource( string className, bool networked, string needLines )\r\n\t{\r\n\t\tvar syncAttr = networked ? \"[Sync( SyncFlags.FromHost )] \" : \"\";\r\n\t\tvar updateGuard = networked ? \"\\t\\tif ( IsProxy ) return; // host-authoritative \u2014 only the host decays\\n\\n\" : \"\";\r\n\t\tvar satisfyGuard= networked ? \"\\t\\tif ( IsProxy ) return;\\n\" : \"\";\r\n\t\tvar headerNote = networked\r\n\t\t\t? \"// 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\"\r\n\t\t\t: \"// Local needs engine (networked:false \u2014 no [Sync], no proxy guard). Ticks in a\\n// single-machine playtest; every machine runs its own copy if used networked.\\n\";\r\n\r\n\t\treturn\r\n$@\"using Sandbox;\r\nusing System;\r\nusing System.Collections.Generic;\r\n\r\n{headerNote}public sealed class {className} : Component\r\n{{\r\n\t/// <summary>One tunable need: value starts at 100 and decays toward 0 at DecayPerSecond.</summary>\r\n\tpublic sealed class NeedDefinition\r\n\t{{\r\n\t\tpublic string Name {{ get; set; }} = \"\"Need\"\";\r\n\t\tpublic float DecayPerSecond {{ get; set; }} = 0.5f; // points lost per second (0..100 scale)\r\n\t\tpublic float CriticalThreshold {{ get; set; }} = 20f; // OnNeedCritical fires when value falls below this\r\n\t\tpublic float Weight {{ get; set; }} = 1f; // contribution to the Happiness weighted mean\r\n\t}}\r\n\r\n\t[Property] public List<NeedDefinition> Needs {{ get; set; }} = new()\r\n\t{{\r\n{needLines}\t}};\r\n\r\n\t/// <summary>Weighted mean of all need values, 0..100.</summary>\r\n\t{syncAttr}public float Happiness {{ get; private set; }} = 100f;\r\n\r\n\t/// <summary>Fires on the simulating machine when a need first crosses below its critical threshold. Re-arms when satisfied back above it.</summary>\r\n\tpublic static Action<{className}, string> OnNeedCritical {{ get; set; }}\r\n\t/// <summary>Fires when Happiness moves by more than 0.25 points. Arg = new happiness.</summary>\r\n\tpublic static Action<{className}, float> OnHappinessChanged {{ get; set; }}\r\n\r\n\tprivate readonly Dictionary<string, float> _values = new();\r\n\tprivate readonly HashSet<string> _critical = new();\r\n\tprivate float _lastHappiness = -1f;\r\n\r\n\tprotected override void OnStart()\r\n\t{{\r\n\t\tforeach ( var need in Needs )\r\n\t\t\tif ( need != null && !string.IsNullOrEmpty( need.Name ) && !_values.ContainsKey( need.Name ) )\r\n\t\t\t\t_values[need.Name] = 100f;\r\n\t}}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{{\r\n{updateGuard}\t\tforeach ( var need in Needs )\r\n\t\t{{\r\n\t\t\tif ( need == null || string.IsNullOrEmpty( need.Name ) ) continue;\r\n\t\t\tif ( !_values.TryGetValue( need.Name, out var v ) ) {{ v = 100f; }}\r\n\r\n\t\t\tvar nv = MathX.Clamp( v - need.DecayPerSecond * Time.Delta, 0f, 100f );\r\n\t\t\t_values[need.Name] = nv;\r\n\r\n\t\t\t// Edge-triggered: fires once on crossing below threshold, re-arms above it.\r\n\t\t\tif ( nv < need.CriticalThreshold )\r\n\t\t\t{{\r\n\t\t\t\tif ( _critical.Add( need.Name ) ) OnNeedCritical?.Invoke( this, need.Name );\r\n\t\t\t}}\r\n\t\t\telse\r\n\t\t\t{{\r\n\t\t\t\t_critical.Remove( need.Name );\r\n\t\t\t}}\r\n\t\t}}\r\n\r\n\t\tRecomputeHappiness();\r\n\t}}\r\n\r\n\t/// <summary>Current value (0..100) of a need by name, or -1 if unknown.</summary>\r\n\tpublic float GetNeed( string name )\r\n\t\t=> name != null && _values.TryGetValue( name, out var v ) ? v : -1f;\r\n\r\n\t/// <summary>Restore a need by amount (clamped 0..100).</summary>\r\n\tpublic void Satisfy( string name, float amount )\r\n\t{{\r\n{satisfyGuard}\t\tif ( name == null || !_values.ContainsKey( name ) ) return;\r\n\t\t_values[name] = MathX.Clamp( _values[name] + amount, 0f, 100f );\r\n\t\tRecomputeHappiness();\r\n\t}}\r\n\r\n\tprivate void RecomputeHappiness()\r\n\t{{\r\n\t\tfloat total = 0f, weight = 0f;\r\n\t\tforeach ( var need in Needs )\r\n\t\t{{\r\n\t\t\tif ( need == null || string.IsNullOrEmpty( need.Name ) ) continue;\r\n\t\t\tif ( !_values.TryGetValue( need.Name, out var v ) ) continue;\r\n\t\t\ttotal += v * need.Weight;\r\n\t\t\tweight += need.Weight;\r\n\t\t}}\r\n\t\tvar h = weight > 0f ? total / weight : 100f;\r\n\t\tif ( System.MathF.Abs( h - _lastHappiness ) > 0.25f )\r\n\t\t{{\r\n\t\t\t_lastHappiness = h;\r\n\t\t\tHappiness = h;\r\n\t\t\tOnHappinessChanged?.Invoke( this, h );\r\n\t\t}}\r\n\t}}\r\n}}\r\n\";\r\n\t}\r\n}\r\n\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n// 2. create_utility_ai (code-gen; scene-mutating)\r\n// Scored-action brain: abstract {Prefix}Action base (Score 0..1 +\r\n// Begin/Tick/End) + sealed {Prefix}Brain that picks the highest-scoring\r\n// sibling action every EvaluateInterval (hysteresis bonus prevents\r\n// flip-flopping) + two example actions (Idle, Wander).\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\npublic class CreateUtilityAiHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar rawName = AiSystemsHelpers.Str( p, \"name\", \"Utility\" );\r\n\t\t\tif ( rawName.EndsWith( \".cs\", StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\t\trawName = rawName.Substring( 0, rawName.Length - 3 );\r\n\t\t\tvar directory = AiSystemsHelpers.Str( p, \"directory\", \"Code\" );\r\n\r\n\t\t\tvar prefix = ClaudeBridge.SanitizeIdentifier( rawName, \"Utility\" );\r\n\t\t\tvar fileName = $\"{prefix}Ai.cs\";\r\n\t\t\tif ( !ClaudeBridge.TryResolveProjectPath( Path.Combine( directory, fileName ), out var fullPath, out var pathErr ) )\r\n\t\t\t\treturn Task.FromResult<object>( new { error = pathErr } );\r\n\t\t\tif ( File.Exists( fullPath ) )\r\n\t\t\t\treturn Task.FromResult<object>( new { error = $\"File already exists: {directory}/{fileName}. Choose a different name.\" } );\r\n\r\n\t\t\tvar evaluateInterval = AiSystemsHelpers.Float( p, \"evaluateInterval\", 0.25f );\r\n\t\t\tvar hysteresisBonus = AiSystemsHelpers.Float( p, \"hysteresisBonus\", 0.15f );\r\n\t\t\tvar moveSpeed = AiSystemsHelpers.Float( p, \"moveSpeed\", 80f );\r\n\t\t\tvar wanderRadius = AiSystemsHelpers.Float( p, \"wanderRadius\", 300f );\r\n\t\t\tvar networked = AiSystemsHelpers.Bool( p, \"networked\", true );\r\n\r\n\t\t\tvar brainName = $\"{prefix}Brain\";\r\n\t\t\tvar actionBase = $\"{prefix}Action\";\r\n\t\t\tvar idleName = $\"{prefix}IdleAction\";\r\n\t\t\tvar wanderName = $\"{prefix}WanderAction\";\r\n\r\n\t\t\tvar code = BuildSource( brainName, actionBase, idleName, wanderName, networked,\r\n\t\t\t\tevaluateInterval, hysteresisBonus, moveSpeed, wanderRadius );\r\n\r\n\t\t\tDirectory.CreateDirectory( Path.GetDirectoryName( fullPath ) );\r\n\t\t\tScaffoldHelpers.WriteCode( fullPath, code );\r\n\r\n\t\t\tobject placedOn = null; string placeNote = null;\r\n\t\t\tif ( p.TryGetProperty( \"targetId\", out var tid ) && tid.ValueKind == JsonValueKind.String )\r\n\t\t\t\tplacedOn = AiSystemsHelpers.PlaceOnTarget( tid.GetString(), brainName, out placeNote );\r\n\r\n\t\t\treturn Task.FromResult<object>( new\r\n\t\t\t{\r\n\t\t\t\tcreated = true,\r\n\t\t\t\tpath = $\"{directory}/{fileName}\",\r\n\t\t\t\tclassNames = new[] { actionBase, brainName, idleName, wanderName },\r\n\t\t\t\tnetworked,\r\n\t\t\t\tpropertyNames = new[] { \"EvaluateInterval\", \"HysteresisBonus\", \"ScoreWeight\", \"BaseScore\", \"MoveSpeed\", \"WanderRadius\", \"SecondsToFullDesire\" },\r\n\t\t\t\tplacedOn,\r\n\t\t\t\tplacementNote = placeNote,\r\n\t\t\t\tnote = $\"Utility AI vs create_npc_brain: the FSM brain has FIXED transitions (Idle\u2192Chase\u2192Search\u2026); this brain has NO transition table \u2014 \" +\r\n\t\t\t\t $\"every {actionBase} sibling self-scores 0..1 each EvaluateInterval and the highest (score \u00d7 ScoreWeight, current action +HysteresisBonus) wins, \" +\r\n\t\t\t\t \"so behavior emerges from the scores. Add behaviors by subclassing the abstract base ON THE SAME GameObject as the brain \" +\r\n\t\t\t\t \"(targetId placement attaches ONLY the brain \u2014 add the example actions with add_component_with_properties after a hotload). \" +\r\n\t\t\t\t \"The two examples alternate emergently: Wander desire builds while idle, collapses on arrival. Wander moves by direct transform walk (no navmesh, walks through walls). \" +\r\n\t\t\t\t ( networked\r\n\t\t\t\t ? \"Networked: host-authoritative (IsProxy guard) + [Sync] CurrentActionName \u2014 needs a host session; use networked:false to iterate solo.\"\r\n\t\t\t\t : \"Solo/local build: no proxy guard, ticks in a single-machine playtest.\" )\r\n\t\t\t} );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"create_utility_ai failed: {ex.Message}\" } );\r\n\t\t}\r\n\t}\r\n\r\n\tprivate static string BuildSource(\r\n\t\tstring brainName, string actionBase, string idleName, string wanderName, bool networked,\r\n\t\tfloat evaluateInterval, float hysteresisBonus, float moveSpeed, float wanderRadius )\r\n\t{\r\n\t\tstring F( float v ) => AiSystemsHelpers.F( v );\r\n\r\n\t\tvar syncAttr = networked ? \"[Sync( SyncFlags.FromHost )] \" : \"\";\r\n\t\tvar proxyGuard = networked ? \"\\t\\tif ( IsProxy ) return; // host-authoritative \u2014 only the host thinks\\n\\n\" : \"\";\r\n\t\tvar headerNote = networked\r\n\t\t\t? \"// 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 \u2014\\n// generate with networked:false to iterate solo.\\n\"\r\n\t\t\t: \"// Solo / local brain (networked:false \u2014 no proxy guard). Ticks in a single-machine playtest.\\n\";\r\n\r\n\t\treturn\r\n$@\"using Sandbox;\r\nusing System;\r\n\r\n// Utility AI \u2014 scored-action brain. Unlike an FSM (fixed transition table), actions\r\n// self-score 0..1 every EvaluateInterval and the highest score wins (emergent switching).\r\n// Add more actions by subclassing {actionBase} on the same GameObject.\r\n{headerNote}\r\n/// <summary>Base class for utility actions. Put subclasses on the SAME GameObject as the brain.</summary>\r\npublic abstract class {actionBase} : Component\r\n{{\r\n\t/// <summary>Multiplier applied to Score() \u2014 raise to bias this action.</summary>\r\n\t[Property] public float ScoreWeight {{ get; set; }} = 1f;\r\n\r\n\t/// <summary>Desirability this instant, 0..1. Highest-scoring sibling action wins.</summary>\r\n\tpublic abstract float Score();\r\n\r\n\t/// <summary>Called once when this action becomes the active one.</summary>\r\n\tpublic virtual void Begin() {{ }}\r\n\t/// <summary>Called every frame while this action is active.</summary>\r\n\tpublic virtual void Tick() {{ }}\r\n\t/// <summary>Called once when a better-scoring action takes over.</summary>\r\n\tpublic virtual void End() {{ }}\r\n}}\r\n\r\n/// <summary>Picks and runs the highest-scoring sibling {actionBase}.</summary>\r\npublic sealed class {brainName} : Component\r\n{{\r\n\t/// <summary>Seconds between score evaluations (the active action Ticks every frame regardless).</summary>\r\n\t[Property] public float EvaluateInterval {{ get; set; }} = {F( evaluateInterval )};\r\n\t/// <summary>Score bonus the CURRENT action gets during evaluation \u2014 hysteresis so near-ties don't flip-flop.</summary>\r\n\t[Property] public float HysteresisBonus {{ get; set; }} = {F( hysteresisBonus )};\r\n\r\n\t{syncAttr}public string CurrentActionName {{ get; private set; }} = \"\"\"\";\r\n\r\n\tpublic {actionBase} Current {{ get; private set; }}\r\n\r\n\t/// <summary>Fires on the simulating machine when the active action changes. Args = brain, new action type name.</summary>\r\n\tpublic static Action<{brainName}, string> OnActionChanged {{ get; set; }}\r\n\r\n\tprivate TimeSince _sinceEval;\r\n\r\n\tprotected override void OnStart()\r\n\t{{\r\n\t\t_sinceEval = 999f; // evaluate on the first eligible frame\r\n\t}}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{{\r\n{proxyGuard}\t\tif ( _sinceEval >= EvaluateInterval )\r\n\t\t{{\r\n\t\t\t_sinceEval = 0f;\r\n\t\t\tEvaluate();\r\n\t\t}}\r\n\r\n\t\tif ( Current != null && Current.IsValid() && Current.Active )\r\n\t\t\tCurrent.Tick();\r\n\t}}\r\n\r\n\tprivate void Evaluate()\r\n\t{{\r\n\t\t{actionBase} best = null;\r\n\t\tfloat bestScore = float.MinValue;\r\n\r\n\t\tforeach ( var action in Components.GetAll<{actionBase}>() )\r\n\t\t{{\r\n\t\t\tif ( action == null || !action.IsValid() || !action.Active ) continue;\r\n\t\t\tfloat score = MathX.Clamp( action.Score(), 0f, 1f ) * action.ScoreWeight;\r\n\t\t\tif ( action == Current ) score += HysteresisBonus;\r\n\t\t\tif ( score > bestScore ) {{ bestScore = score; best = action; }}\r\n\t\t}}\r\n\r\n\t\tif ( best == Current ) return;\r\n\r\n\t\tif ( Current != null && Current.IsValid() ) Current.End();\r\n\t\tCurrent = best;\r\n\t\tCurrentActionName = best != null ? best.GetType().Name : \"\"\"\";\r\n\t\tif ( best != null ) best.Begin();\r\n\t\tOnActionChanged?.Invoke( this, CurrentActionName );\r\n\t}}\r\n}}\r\n\r\n/// <summary>Example action: constant low score \u2014 the fallback when nothing else wants to run.</summary>\r\npublic sealed class {idleName} : {actionBase}\r\n{{\r\n\t[Property] public float BaseScore {{ get; set; }} = 0.1f;\r\n\r\n\tpublic override float Score() => BaseScore;\r\n}}\r\n\r\n/// <summary>Example action: desire builds while not wandering; walks to random points near home, then resets.</summary>\r\npublic sealed class {wanderName} : {actionBase}\r\n{{\r\n\t[Property] public float MoveSpeed {{ get; set; }} = {F( moveSpeed )};\r\n\t[Property] public float WanderRadius {{ get; set; }} = {F( wanderRadius )};\r\n\t/// <summary>Seconds of not-wandering until desire reaches 1.0.</summary>\r\n\t[Property] public float SecondsToFullDesire {{ get; set; }} = 6f;\r\n\r\n\tprivate Vector3 _home;\r\n\tprivate Vector3 _target;\r\n\tprivate TimeSince _sinceSatisfied;\r\n\r\n\tprotected override void OnStart()\r\n\t{{\r\n\t\t_home = WorldPosition;\r\n\t\t_target = WorldPosition;\r\n\t\t_sinceSatisfied = 0f;\r\n\t}}\r\n\r\n\tpublic override float Score()\r\n\t\t=> MathX.Clamp( _sinceSatisfied / System.MathF.Max( SecondsToFullDesire, 0.1f ), 0f, 1f );\r\n\r\n\tpublic override void Begin() => PickTarget();\r\n\r\n\tpublic override void Tick()\r\n\t{{\r\n\t\tvar flat = ( _target - WorldPosition ).WithZ( 0f );\r\n\t\tif ( flat.Length <= 8f )\r\n\t\t{{\r\n\t\t\t_sinceSatisfied = 0f; // reached \u2014 desire collapses, idle takes over until it rebuilds\r\n\t\t\tPickTarget();\r\n\t\t\treturn;\r\n\t\t}}\r\n\r\n\t\tvar step = flat.Normal * MoveSpeed * Time.Delta;\r\n\t\tif ( step.Length > flat.Length ) step = flat;\r\n\t\tWorldPosition += step;\r\n\t\tWorldRotation = Rotation.LookAt( flat.Normal );\r\n\t}}\r\n\r\n\tprivate void PickTarget()\r\n\t{{\r\n\t\t_target = _home + new Vector3(\r\n\t\t\tRandom.Shared.Float( -WanderRadius, WanderRadius ),\r\n\t\t\tRandom.Shared.Float( -WanderRadius, WanderRadius ),\r\n\t\t\t0f );\r\n\t}}\r\n}}\r\n\";\r\n\t}\r\n}\r\n\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n// 3. create_npc_schedule_brain (code-gen; scene-mutating)\r\n// Daily-routine NPC: schedule entries (startHour/endHour/task/target),\r\n// reads the hour from any create_day_night_clock component (capability\r\n// match: float TimeOfDay), falls back to an internal clock, walks to the\r\n// active task's target, idles outside the schedule. Static OnTaskChanged.\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\npublic class CreateNpcScheduleBrainHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tif ( !ScaffoldHelpers.PrepareCodeFile( p, \"NpcScheduleBrain\", out var fullPath, out var relPath, out var className, out var err ) )\r\n\t\t\t\treturn Task.FromResult<object>( err );\r\n\r\n\t\t\tvar moveSpeed = AiSystemsHelpers.Float( p, \"moveSpeed\", 100f );\r\n\t\t\tvar arriveDistance = AiSystemsHelpers.Float( p, \"arriveDistance\", 32f );\r\n\t\t\tvar fallbackDayLen = AiSystemsHelpers.Float( p, \"fallbackDayLengthSeconds\", 600f );\r\n\t\t\tvar fallbackStart = AiSystemsHelpers.Float( p, \"fallbackStartHour\", 8f );\r\n\t\t\tvar useNavMesh = AiSystemsHelpers.Bool( p, \"useNavMeshAgent\", false );\r\n\t\t\tvar networked = AiSystemsHelpers.Bool( p, \"networked\", true );\r\n\r\n\t\t\t// \u2500\u2500 Schedule entries: explicit `schedule` array wins, else a work/relax default.\r\n\t\t\tvar entryLines = new StringBuilder();\r\n\t\t\tvar taskNames = new List<string>();\r\n\t\t\tif ( p.TryGetProperty( \"schedule\", out var arr ) && arr.ValueKind == JsonValueKind.Array && arr.GetArrayLength() > 0 )\r\n\t\t\t{\r\n\t\t\t\tforeach ( var e in arr.EnumerateArray() )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar start = AiSystemsHelpers.Float( e, \"startHour\", 8f );\r\n\t\t\t\t\tvar end = AiSystemsHelpers.Float( e, \"endHour\", 17f );\r\n\t\t\t\t\tvar task = AiSystemsHelpers.Str( e, \"taskName\", \"Task\" );\r\n\t\t\t\t\tvar target = AiSystemsHelpers.Str( e, \"targetName\", \"\" );\r\n\t\t\t\t\ttaskNames.Add( task );\r\n\r\n\t\t\t\t\tvar line = \"\\t\\tnew ScheduleEntry { StartHour = \" + AiSystemsHelpers.F( start )\r\n\t\t\t\t\t\t+ \", EndHour = \" + AiSystemsHelpers.F( end )\r\n\t\t\t\t\t\t+ \", TaskName = \\\"\" + AiSystemsHelpers.EscString( task ) + \"\\\"\";\r\n\t\t\t\t\tif ( !string.IsNullOrEmpty( target ) )\r\n\t\t\t\t\t\tline += \", TargetName = \\\"\" + AiSystemsHelpers.EscString( target ) + \"\\\"\";\r\n\t\t\t\t\tif ( e.TryGetProperty( \"targetPosition\", out var posEl ) && posEl.ValueKind != JsonValueKind.Null )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tvar v = ClaudeBridge.ParseVector3( posEl );\r\n\t\t\t\t\t\tline += \", TargetPosition = new Vector3( \" + AiSystemsHelpers.F( v.x ) + \", \" + AiSystemsHelpers.F( v.y ) + \", \" + AiSystemsHelpers.F( v.z ) + \" )\";\r\n\t\t\t\t\t}\r\n\t\t\t\t\tentryLines.Append( line + \" },\\n\" );\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\ttaskNames.AddRange( new[] { \"Work\", \"Relax\" } );\r\n\t\t\t\tentryLines.Append( \"\\t\\tnew ScheduleEntry { StartHour = 8f, EndHour = 17f, TaskName = \\\"Work\\\", TargetName = \\\"WorkSpot\\\" },\\n\" );\r\n\t\t\t\tentryLines.Append( \"\\t\\tnew ScheduleEntry { StartHour = 17f, EndHour = 22f, TaskName = \\\"Relax\\\", TargetName = \\\"HomeSpot\\\" },\\n\" );\r\n\t\t\t}\r\n\r\n\t\t\tvar code = BuildSource( className, networked, useNavMesh, entryLines.ToString(),\r\n\t\t\t\tmoveSpeed, arriveDistance, fallbackDayLen, fallbackStart );\r\n\t\t\tScaffoldHelpers.WriteCode( fullPath, code );\r\n\r\n\t\t\tobject placedOn = null; string placeNote = null;\r\n\t\t\tif ( p.TryGetProperty( \"targetId\", out var tid ) && tid.ValueKind == JsonValueKind.String )\r\n\t\t\t\tplacedOn = AiSystemsHelpers.PlaceOnTarget( tid.GetString(), className, out placeNote );\r\n\r\n\t\t\treturn Task.FromResult<object>( new\r\n\t\t\t{\r\n\t\t\t\tcreated = true,\r\n\t\t\t\tpath = relPath,\r\n\t\t\t\tclassName,\r\n\t\t\t\tnetworked,\r\n\t\t\t\tuseNavMeshAgent = useNavMesh,\r\n\t\t\t\ttasks = taskNames,\r\n\t\t\t\tpropertyNames = new[] { \"Schedule\", \"MoveSpeed\", \"ArriveDistance\", \"FallbackDayLengthSeconds\", \"FallbackStartHour\" },\r\n\t\t\t\tplacedOn,\r\n\t\t\t\tplacementNote = placeNote,\r\n\t\t\t\tnote = \"Time source: binds by CAPABILITY to any component exposing a float TimeOfDay property (the create_day_night_clock contract) \u2014 \" +\r\n\t\t\t\t \"same GameObject first, then scene-wide, re-scanned every 5s while unbound. If NO clock exists it honestly falls back to its own \" +\r\n\t\t\t\t \"internal clock (FallbackDayLengthSeconds per 24h, starting at FallbackStartHour) \u2014 check UsingClockComponent at runtime. \" +\r\n\t\t\t\t \"A clock with a different shape (e.g. a 0..1 DayProgress) will NOT bind \u2014 generate a create_day_night_clock or match the contract. \" +\r\n\t\t\t\t \"Entries with EndHour < StartHour wrap past midnight. TargetName resolves a scene GameObject by name (case-insensitive, cached per task); \" +\r\n\t\t\t\t \"missing names mean the NPC idles. Outside every entry the NPC idles in place. \" +\r\n\t\t\t\t ( useNavMesh\r\n\t\t\t\t ? \"Movement: NavMeshAgent.MoveTo \u2014 REQUIRES a baked navmesh (bake_navmesh) or the NPC won't move. \"\r\n\t\t\t\t : \"Movement: direct transform walk (no navmesh, walks through walls \u2014 pass useNavMeshAgent:true for pathfinding). \" ) +\r\n\t\t\t\t ( networked\r\n\t\t\t\t ? \"Networked: host-authoritative (IsProxy guard) + [Sync] CurrentTask \u2014 needs a host session; use networked:false to iterate solo.\"\r\n\t\t\t\t : \"Solo/local build: no proxy guard, ticks in a single-machine playtest.\" )\r\n\t\t\t} );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"create_npc_schedule_brain failed: {ex.Message}\" } );\r\n\t\t}\r\n\t}\r\n\r\n\tprivate static string BuildSource(\r\n\t\tstring className, bool networked, bool useNavMesh, string entryLines,\r\n\t\tfloat moveSpeed, float arriveDistance, float fallbackDayLen, float fallbackStart )\r\n\t{\r\n\t\tstring F( float v ) => AiSystemsHelpers.F( v );\r\n\r\n\t\tvar syncAttr = networked ? \"[Sync( SyncFlags.FromHost )] \" : \"\";\r\n\t\tvar proxyGuard = networked ? \"\\t\\tif ( IsProxy ) return; // host-authoritative \u2014 only the host routes\\n\\n\" : \"\";\r\n\t\tvar headerNote = networked\r\n\t\t\t? \"// 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 \u2014 generate with networked:false to iterate solo.\\n\"\r\n\t\t\t: \"// Solo / local daily-routine brain (networked:false \u2014 no proxy guard).\\n\";\r\n\r\n\t\t// NavMeshAgent variant swaps the movement body; MoveTo/Stop/MaxSpeed are the same\r\n\t\t// calls the shipped create_npc_brain generator emits (proven sandbox surface).\r\n\t\tvar agentField = useNavMesh ? \"\\tprivate NavMeshAgent _agent;\\n\" : \"\";\r\n\t\tvar agentOnStart = useNavMesh ? \"\\t\\t_agent = GetOrAddComponent<NavMeshAgent>();\\n\" : \"\";\r\n\t\tvar moveBody = useNavMesh\r\n\t\t\t?\r\n@\"\t\tvar flat = ( target - WorldPosition ).WithZ( 0f );\r\n\t\tif ( flat.Length <= ArriveDistance ) { _agent.Stop(); return; } // arrived \u2014 idle at the task spot\r\n\t\t_agent.MaxSpeed = MoveSpeed;\r\n\t\t_agent.MoveTo( target );\"\r\n\t\t\t:\r\n@\"\t\tvar flat = ( target - WorldPosition ).WithZ( 0f );\r\n\t\tif ( flat.Length <= ArriveDistance ) return; // arrived \u2014 idle at the task spot\r\n\r\n\t\tvar step = flat.Normal * MoveSpeed * Time.Delta;\r\n\t\tif ( step.Length > flat.Length ) step = flat;\r\n\t\tWorldPosition += step;\r\n\t\tWorldRotation = Rotation.LookAt( flat.Normal );\";\r\n\r\n\t\treturn\r\n$@\"using Sandbox;\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\n\r\n// Daily-routine NPC brain. Reads the hour from any create_day_night_clock component\r\n// (capability match: a float TimeOfDay property) found on this GameObject or in the\r\n// scene; falls back to its own internal clock when none exists. Walks the NPC to the\r\n// active schedule entry's target and idles outside the schedule.\r\n{headerNote}public sealed class {className} : Component\r\n{{\r\n\t/// <summary>One routine block. EndHour smaller than StartHour wraps past midnight (e.g. 22 -> 6).</summary>\r\n\tpublic sealed class ScheduleEntry\r\n\t{{\r\n\t\tpublic float StartHour {{ get; set; }} = 8f; // inclusive, 0..24\r\n\t\tpublic float EndHour {{ get; set; }} = 17f; // exclusive\r\n\t\tpublic string TaskName {{ get; set; }} = \"\"Task\"\";\r\n\t\tpublic string TargetName {{ get; set; }} = \"\"\"\"; // named scene GameObject to walk to (wins over TargetPosition)\r\n\t\tpublic Vector3 TargetPosition {{ get; set; }} // fixed world position, used when TargetName is empty\r\n\t}}\r\n\r\n\t[Property] public List<ScheduleEntry> Schedule {{ get; set; }} = new()\r\n\t{{\r\n{entryLines}\t}};\r\n\r\n\t[Property] public float MoveSpeed {{ get; set; }} = {F( moveSpeed )};\r\n\t[Property] public float ArriveDistance {{ get; set; }} = {F( arriveDistance )};\r\n\r\n\t// Internal fallback clock \u2014 used ONLY when no TimeOfDay clock component is found.\r\n\t[Property] public float FallbackDayLengthSeconds {{ get; set; }} = {F( fallbackDayLen )};\r\n\t[Property] public float FallbackStartHour {{ get; set; }} = {F( fallbackStart )};\r\n\r\n\t{syncAttr}public string CurrentTask {{ get; private set; }} = \"\"\"\";\r\n\r\n\t/// <summary>The hour (0..24) currently driving the schedule.</summary>\r\n\tpublic float CurrentHour {{ get; private set; }}\r\n\t/// <summary>True when bound to a scene clock component, false when on the internal fallback.</summary>\r\n\tpublic bool UsingClockComponent => _clock != null && _clock.IsValid();\r\n\r\n\t/// <summary>Fires on the simulating machine when the active task changes. Args = brain, new task name (\"\"\"\" = idle).</summary>\r\n\tpublic static Action<{className}, string> OnTaskChanged {{ get; set; }}\r\n\r\n\tprivate Component _clock;\r\n\tprivate PropertyDescription _hourProp;\r\n\tprivate float _fallbackHour;\r\n\tprivate GameObject _targetGo;\r\n\tprivate string _resolvedTargetName;\r\n\tprivate RealTimeSince _sinceClockScan;\r\n{agentField}\r\n\tprotected override void OnStart()\r\n\t{{\r\n{agentOnStart}\t\t_fallbackHour = MathX.Clamp( FallbackStartHour, 0f, 24f );\r\n\t\t_sinceClockScan = 999f;\r\n\t}}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{{\r\n{proxyGuard}\t\t// Bind (and occasionally re-bind) to a clock \u2014 one may hotload/spawn later.\r\n\t\tif ( ( _clock == null || !_clock.IsValid() ) && _sinceClockScan > 5f )\r\n\t\t\tTryBindClock();\r\n\r\n\t\tCurrentHour = ReadHour();\r\n\r\n\t\tvar entry = ActiveEntry( CurrentHour );\r\n\t\tvar task = entry != null ? ( entry.TaskName ?? \"\"\"\" ) : \"\"\"\";\r\n\t\tif ( task != CurrentTask )\r\n\t\t{{\r\n\t\t\tCurrentTask = task;\r\n\t\t\t_targetGo = null;\r\n\t\t\t_resolvedTargetName = null;\r\n\t\t\tOnTaskChanged?.Invoke( this, task );\r\n\t\t}}\r\n\r\n\t\tif ( entry == null ) return; // outside every schedule block \u2014 idle in place\r\n\r\n\t\tvar target = ResolveTarget( entry );\r\n\t\tif ( target == null ) return;\r\n\t\tMoveToward( target.Value );\r\n\t}}\r\n\r\n\tprivate void TryBindClock()\r\n\t{{\r\n\t\t_sinceClockScan = 0f;\r\n\t\t_clock = null;\r\n\t\t_hourProp = null;\r\n\t\tif ( Scene == null ) return;\r\n\r\n\t\t// Same-GameObject components first, then the whole scene. Capability match:\r\n\t\t// a float TimeOfDay property (the create_day_night_clock contract).\r\n\t\tvar candidates = Components.GetAll<Component>().Concat( Scene.GetAllComponents<Component>() );\r\n\t\tforeach ( var c in candidates )\r\n\t\t{{\r\n\t\t\tif ( c == null || c == this || !c.IsValid() ) continue;\r\n\t\t\tvar td = TypeLibrary.GetType( c.GetType() );\r\n\t\t\tif ( td == null ) continue;\r\n\t\t\tvar hour = td.Properties.FirstOrDefault( x => x.Name == \"\"TimeOfDay\"\" && x.PropertyType == typeof( float ) );\r\n\t\t\tif ( hour == null ) continue;\r\n\t\t\t_clock = c;\r\n\t\t\t_hourProp = hour;\r\n\t\t\treturn;\r\n\t\t}}\r\n\t}}\r\n\r\n\tprivate float ReadHour()\r\n\t{{\r\n\t\tif ( _clock != null && _clock.IsValid() && _hourProp != null )\r\n\t\t{{\r\n\t\t\tvar v = _hourProp.GetValue( _clock );\r\n\t\t\tif ( v is float f ) return MathX.Clamp( f, 0f, 24f );\r\n\t\t}}\r\n\r\n\t\t// Internal fallback: 24 in-game hours elapse per FallbackDayLengthSeconds.\r\n\t\t_fallbackHour += ( 24f / MathX.Clamp( FallbackDayLengthSeconds, 1f, 86400f ) ) * Time.Delta;\r\n\t\twhile ( _fallbackHour >= 24f ) _fallbackHour -= 24f;\r\n\t\treturn _fallbackHour;\r\n\t}}\r\n\r\n\tprivate ScheduleEntry ActiveEntry( float hour )\r\n\t{{\r\n\t\tif ( Schedule == null ) return null;\r\n\t\tforeach ( var e in Schedule )\r\n\t\t{{\r\n\t\t\tif ( e == null ) continue;\r\n\t\t\tbool active = e.StartHour <= e.EndHour\r\n\t\t\t\t? hour >= e.StartHour && hour < e.EndHour\r\n\t\t\t\t: hour >= e.StartHour || hour < e.EndHour; // wraps past midnight\r\n\t\t\tif ( active ) return e;\r\n\t\t}}\r\n\t\treturn null;\r\n\t}}\r\n\r\n\tprivate Vector3? ResolveTarget( ScheduleEntry entry )\r\n\t{{\r\n\t\tif ( !string.IsNullOrEmpty( entry.TargetName ) )\r\n\t\t{{\r\n\t\t\tif ( _targetGo != null && _targetGo.IsValid() && _resolvedTargetName == entry.TargetName )\r\n\t\t\t\treturn _targetGo.WorldPosition;\r\n\r\n\t\t\t_targetGo = FindByNameRecursive( Scene, entry.TargetName );\r\n\t\t\t_resolvedTargetName = entry.TargetName;\r\n\t\t\tif ( _targetGo != null && _targetGo.IsValid() ) return _targetGo.WorldPosition;\r\n\t\t\treturn null; // named target missing from the scene \u2014 idle\r\n\t\t}}\r\n\t\treturn entry.TargetPosition;\r\n\t}}\r\n\r\n\tprivate static GameObject FindByNameRecursive( GameObject root, string name )\r\n\t{{\r\n\t\tif ( root == null ) return null;\r\n\t\tforeach ( var child in root.Children )\r\n\t\t{{\r\n\t\t\tif ( child == null ) continue;\r\n\t\t\tif ( string.Equals( child.Name, name, StringComparison.OrdinalIgnoreCase ) ) return child;\r\n\t\t\tvar found = FindByNameRecursive( child, name );\r\n\t\t\tif ( found != null ) return found;\r\n\t\t}}\r\n\t\treturn null;\r\n\t}}\r\n\r\n\tprivate void MoveToward( Vector3 target )\r\n\t{{\r\n{moveBody}\r\n\t}}\r\n}}\r\n\";\r\n\t}\r\n}\r\n\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n// 4. create_event_bus (code-gen; scene-mutating [writes a file])\r\n// Typed LOCAL pub/sub: static class with Subscribe<T>(owner, Action<T>),\r\n// Unsubscribe(owner), Publish<T>(evt). Plain owner-keyed handler lists \u2014\r\n// no weak refs; owners must Unsubscribe in OnDestroy. Not a Component.\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\npublic class CreateEventBusHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tif ( !ScaffoldHelpers.PrepareCodeFile( p, \"EventBus\", out var fullPath, out var relPath, out var className, out var err ) )\r\n\t\t\t\treturn Task.FromResult<object>( err );\r\n\r\n\t\t\tvar code = BuildSource( className );\r\n\t\t\tScaffoldHelpers.WriteCode( fullPath, code );\r\n\r\n\t\t\treturn Task.FromResult<object>( new\r\n\t\t\t{\r\n\t\t\t\tcreated = true,\r\n\t\t\t\tpath = relPath,\r\n\t\t\t\tclassName,\r\n\t\t\t\texampleEvent = $\"{className}Ping\",\r\n\t\t\t\tapi = new[] { \"Subscribe<T>(object owner, Action<T> handler)\", \"Unsubscribe(object owner)\", \"Publish<T>(T evt)\", \"Count<T>()\", \"Clear()\" },\r\n\t\t\t\tnote = \"Pure STATIC class \u2014 nothing to place in the scene (no targetId). LOCAL only: Publish runs handlers synchronously on the \" +\r\n\t\t\t\t \"publishing machine, exact-type-T subscribers only (no base-type dispatch); NOT networked \u2014 pair with [Rpc.Broadcast]/[Rpc.Host] \" +\r\n\t\t\t\t \"methods that Publish on arrival for networked events. Handler lists hold PLAIN references (no weak refs): every subscriber MUST \" +\r\n\t\t\t\t \"call Unsubscribe(this) in OnDestroy or the handler AND the owner leak for the scene's life; call Clear() on scene teardown. \" +\r\n\t\t\t\t $\"A tiny example event record ({className}Ping) is included \u2014 define your own events as small records/classes.\"\r\n\t\t\t} );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"create_event_bus failed: {ex.Message}\" } );\r\n\t\t}\r\n\t}\r\n\r\n\tprivate static string BuildSource( string className )\r\n\t{\r\n\t\treturn\r\n$@\"using System;\r\nusing System.Collections.Generic;\r\n\r\n/// <summary>\r\n/// {className} \u2014 typed LOCAL pub/sub. Subscribe with an owner object, publish typed\r\n/// events, handlers run synchronously on the publishing machine. NOT networked \u2014 pair\r\n/// with [Rpc.Broadcast] / [Rpc.Host] methods that Publish on arrival for networked events.\r\n///\r\n/// Handler lists hold PLAIN references (no weak refs): every subscriber MUST call\r\n/// Unsubscribe(this) in OnDestroy, or the handler AND the owner leak for the scene's life.\r\n/// </summary>\r\npublic static class {className}\r\n{{\r\n\tprivate static readonly Dictionary<Type, List<(object Owner, Delegate Handler)>> _subs = new();\r\n\r\n\t/// <summary>Register a handler for events of type T. owner is your component (used by Unsubscribe).</summary>\r\n\tpublic static void Subscribe<T>( object owner, Action<T> handler )\r\n\t{{\r\n\t\tif ( owner == null || handler == null ) return;\r\n\t\tif ( !_subs.TryGetValue( typeof( T ), out var list ) )\r\n\t\t{{\r\n\t\t\tlist = new List<(object, Delegate)>();\r\n\t\t\t_subs[typeof( T )] = list;\r\n\t\t}}\r\n\t\tlist.Add( (owner, handler) );\r\n\t}}\r\n\r\n\t/// <summary>Remove ALL handlers registered by this owner, across every event type. Call in OnDestroy.</summary>\r\n\tpublic static void Unsubscribe( object owner )\r\n\t{{\r\n\t\tif ( owner == null ) return;\r\n\t\tforeach ( var list in _subs.Values )\r\n\t\t\tlist.RemoveAll( s => ReferenceEquals( s.Owner, owner ) );\r\n\t}}\r\n\r\n\t/// <summary>Deliver evt to every exact-type-T subscriber, synchronously, in subscribe order.</summary>\r\n\tpublic static void Publish<T>( T evt )\r\n\t{{\r\n\t\tif ( !_subs.TryGetValue( typeof( T ), out var list ) || list.Count == 0 ) return;\r\n\r\n\t\t// Snapshot so a handler may Subscribe/Unsubscribe mid-publish safely.\r\n\t\tforeach ( var sub in list.ToArray() )\r\n\t\t{{\r\n\t\t\tif ( sub.Handler is Action<T> a ) a( evt );\r\n\t\t}}\r\n\t}}\r\n\r\n\t/// <summary>Handlers currently registered for T (diagnostics).</summary>\r\n\tpublic static int Count<T>() => _subs.TryGetValue( typeof( T ), out var l ) ? l.Count : 0;\r\n\r\n\t/// <summary>Drop every subscription \u2014 call on scene teardown / game restart.</summary>\r\n\tpublic static void Clear() => _subs.Clear();\r\n}}\r\n\r\n/// <summary>Example event \u2014 define your own as small records and Publish them.</summary>\r\npublic record {className}Ping( string Message );\r\n\";\r\n\t}\r\n}\r\n\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n// 5. add_tts_voice (code-gen; scene-mutating)\r\n// TTS speaker component over the verified Sandbox.Speech.Synthesizer:\r\n// Say(text) \u2192 TrySetVoice \u2192 WithText \u2192 WithRate \u2192 Play() \u2192 SoundHandle,\r\n// stop-previous-on-say, positional/2D routing, optional viseme-data\r\n// extraction (Handle.LipSync.Enabled). Audio-only \u2014 see note for why\r\n// Sandbox.LipSync is not auto-wired.\r\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\npublic class AddTtsVoiceHandler : IBridgeHandler\r\n{\r\n\tpublic Task<object> Execute( JsonElement p )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tif ( !ScaffoldHelpers.PrepareCodeFile( p, \"TtsSpeaker\", out var fullPath, out var relPath, out var className, out var err ) )\r\n\t\t\t\treturn Task.FromResult<object>( err );\r\n\r\n\t\t\tvar voiceName = AiSystemsHelpers.Str( p, \"voiceName\", \"\" );\r\n\t\t\tvar voiceGender = AiSystemsHelpers.Str( p, \"voiceGender\", \"\" );\r\n\t\t\tvar voiceAge = AiSystemsHelpers.Str( p, \"voiceAge\", \"\" );\r\n\t\t\tvar rate = AiSystemsHelpers.Int( p, \"rate\", 0 );\r\n\t\t\tvar volume = AiSystemsHelpers.Float( p, \"volume\", 1f );\r\n\t\t\tvar positional = AiSystemsHelpers.Bool( p, \"positional\", true );\r\n\t\t\tvar stopPrevious = AiSystemsHelpers.Bool( p, \"stopPreviousOnSay\", true );\r\n\t\t\tvar stopFade = AiSystemsHelpers.Float( p, \"stopFadeSeconds\", 0.1f );\r\n\t\t\tvar enableVisemes = AiSystemsHelpers.Bool( p, \"enableVisemeData\", false );\r\n\r\n\t\t\tvar code = BuildSource( className,\r\n\t\t\t\tAiSystemsHelpers.EscString( voiceName ),\r\n\t\t\t\tAiSystemsHelpers.EscString( voiceGender ),\r\n\t\t\t\tAiSystemsHelpers.EscString( voiceAge ),\r\n\t\t\t\trate, volume, positional, stopPrevious, stopFade, enableVisemes );\r\n\t\t\tScaffoldHelpers.WriteCode( fullPath, code );\r\n\r\n\t\t\tobject placedOn = null; string placeNote = null;\r\n\t\t\tif ( p.TryGetProperty( \"targetId\", out var tid ) && tid.ValueKind == JsonValueKind.String )\r\n\t\t\t\tplacedOn = AiSystemsHelpers.PlaceOnTarget( tid.GetString(), className, out placeNote );\r\n\r\n\t\t\treturn Task.FromResult<object>( new\r\n\t\t\t{\r\n\t\t\t\tcreated = true,\r\n\t\t\t\tpath = relPath,\r\n\t\t\t\tclassName,\r\n\t\t\t\tpropertyNames = new[] { \"VoiceName\", \"VoiceGender\", \"VoiceAge\", \"Rate\", \"Volume\", \"Positional\", \"StopPreviousOnSay\", \"StopFadeSeconds\", \"EnableVisemeData\" },\r\n\t\t\t\tplacedOn,\r\n\t\t\t\tplacementNote = placeNote,\r\n\t\t\t\tnote = \"Call <class>.Say(\\\"text\\\") from game code (LOCAL audio \u2014 wrap in [Rpc.Broadcast] for everyone to hear). \" +\r\n\t\t\t\t \"The Synthesizer API surface compiles (verified live) but the editor cannot playtest audio, so RUNTIME behavior \" +\r\n\t\t\t\t \"(actual speech, voice selection, viseme data) is UNVERIFIED \u2014 verify in play mode with your ears. \" +\r\n\t\t\t\t \"Voice availability is machine/OS-specific: call LogVoices() in play mode to list installed voices; TrySetVoice is \" +\r\n\t\t\t\t \"best-effort (falls back to the OS default). Gender/age hint strings (e.g. \\\"Female\\\"/\\\"Adult\\\") are passed through unvalidated. \" +\r\n\t\t\t\t \"LIPSYNC: audio-only by design \u2014 s&box's Sandbox.LipSync component consumes a BaseSoundComponent (verified), not the raw \" +\r\n\t\t\t\t \"SoundHandle TTS produces, and Synthesizer.OnVisemeReached's delegate arg types can't be confirmed via reflection, so neither is \" +\r\n\t\t\t\t \"auto-wired. enableVisemeData:true sets Handle.LipSync.Enabled so your own mouth-drive code can read Handle.LipSync.Visemes (runtime-unverified).\"\r\n\t\t\t} );\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\treturn Task.FromResult<object>( new { error = $\"add_tts_voice failed: {ex.Message}\" } );\r\n\t\t}\r\n\t}\r\n\r\n\tprivate static string BuildSource(\r\n\t\tstring className, string voiceNameLit, string voiceGenderLit, string voiceAgeLit,\r\n\t\tint rate, float volume, bool positional, bool stopPrevious, float stopFade, bool enableVisemes )\r\n\t{\r\n\t\tstring F( float v ) => AiSystemsHelpers.F( v );\r\n\t\tstring B( bool b ) => b ? \"true\" : \"false\";\r\n\r\n\t\treturn\r\n$@\"using Sandbox;\r\nusing System;\r\n\r\n/// <summary>\r\n/// {className} \u2014 speaks text through the OS speech synthesizer (Sandbox.Speech.Synthesizer).\r\n/// LOCAL audio only: Say() synthesizes and plays on the calling machine. For networked\r\n/// voice, call Say from inside an [Rpc.Broadcast] handler so every client speaks it.\r\n/// </summary>\r\npublic sealed class {className} : Component\r\n{{\r\n\t/// <summary>Exact installed OS voice name (see LogVoices). Empty = use VoiceGender/VoiceAge, or the OS default.</summary>\r\n\t[Property] public string VoiceName {{ get; set; }} = \"\"{voiceNameLit}\"\";\r\n\t/// <summary>Voice gender hint, used only when VoiceName is empty (e.g. \"\"Female\"\", \"\"Male\"\"). Needs VoiceAge too.</summary>\r\n\t[Property] public string VoiceGender {{ get; set; }} = \"\"{voiceGenderLit}\"\";\r\n\t/// <summary>Voice age hint paired with VoiceGender (e.g. \"\"Adult\"\", \"\"Child\"\", \"\"Senior\"\").</summary>\r\n\t[Property] public string VoiceAge {{ get; set; }} = \"\"{voiceAgeLit}\"\";\r\n\t/// <summary>Speaking rate offset: negative = slower, positive = faster, 0 = normal.</summary>\r\n\t[Property] public int Rate {{ get; set; }} = {rate};\r\n\t[Property] public float Volume {{ get; set; }} = {F( volume )};\r\n\t/// <summary>True: 3D sound parented to this GameObject (follows the speaker). False: flat 2D voice on the listener.</summary>\r\n\t[Property] public bool Positional {{ get; set; }} = {B( positional )};\r\n\t/// <summary>Fade out any still-playing previous line when Say is called again.</summary>\r\n\t[Property] public bool StopPreviousOnSay {{ get; set; }} = {B( stopPrevious )};\r\n\t[Property] public float StopFadeSeconds {{ get; set; }} = {F( stopFade )};\r\n\t/// <summary>Enable viseme extraction on the played handle (read Handle.LipSync.Visemes from your own mouth-drive code).</summary>\r\n\t[Property] public bool EnableVisemeData {{ get; set; }} = {B( enableVisemes )};\r\n\r\n\t/// <summary>The most recent line's SoundHandle (null before the first Say).</summary>\r\n\tpublic SoundHandle Handle {{ get; private set; }}\r\n\tpublic bool IsSpeaking => Handle != null && Handle.IsValid && Handle.IsPlaying;\r\n\r\n\t/// <summary>Synthesize and play a line. Repeated calls interrupt the previous line when StopPreviousOnSay.</summary>\r\n\tpublic void Say( string text )\r\n\t{{\r\n\t\tif ( string.IsNullOrWhiteSpace( text ) ) return;\r\n\r\n\t\tif ( StopPreviousOnSay && Handle != null && Handle.IsPlaying )\r\n\t\t\tHandle.Stop( StopFadeSeconds );\r\n\r\n\t\tvar synth = new Sandbox.Speech.Synthesizer();\r\n\r\n\t\tif ( !string.IsNullOrWhiteSpace( VoiceName ) )\r\n\t\t\tsynth.TrySetVoice( VoiceName );\r\n\t\telse if ( !string.IsNullOrWhiteSpace( VoiceGender ) && !string.IsNullOrWhiteSpace( VoiceAge ) )\r\n\t\t\tsynth.TrySetVoice( VoiceGender, VoiceAge );\r\n\r\n\t\tvar handle = synth.WithText( text ).WithRate( Rate ).Play();\r\n\t\tif ( handle == null ) return;\r\n\r\n\t\thandle.Volume = Volume;\r\n\t\tif ( Positional )\r\n\t\t{{\r\n\t\t\thandle.Position = WorldPosition;\r\n\t\t\thandle.SetParent( GameObject ); // follows the speaker as it moves\r\n\t\t}}\r\n\t\telse\r\n\t\t{{\r\n\t\t\thandle.ListenLocal = true;\r\n\t\t}}\r\n\r\n\t\tif ( EnableVisemeData )\r\n\t\t\thandle.LipSync.Enabled = true;\r\n\r\n\t\tHandle = handle;\r\n\t}}\r\n\r\n\t/// <summary>Fade out the current line (no-op when nothing is playing).</summary>\r\n\tpublic void StopSpeaking()\r\n\t{{\r\n\t\tif ( Handle != null && Handle.IsPlaying ) Handle.Stop( StopFadeSeconds );\r\n\t}}\r\n\r\n\t/// <summary>Log every installed OS voice + the currently selected one (voice availability is machine-specific).</summary>\r\n\tpublic void LogVoices()\r\n\t{{\r\n\t\tvar synth = new Sandbox.Speech.Synthesizer();\r\n\t\tif ( !string.IsNullOrWhiteSpace( VoiceName ) ) synth.TrySetVoice( VoiceName );\r\n\t\tforeach ( var v in synth.InstalledVoices )\r\n\t\t\tLog.Info( $\"\"[{className}] voice: {{v}}\"\" );\r\n\t\tLog.Info( $\"\"[{className}] selected: {{synth.CurrentVoice}}\"\" );\r\n\t}}\r\n\r\n\tprotected override void OnDestroy()\r\n\t{{\r\n\t\tif ( Handle != null && Handle.IsPlaying ) Handle.Stop( 0f );\r\n\t}}\r\n}}\r\n\";\r\n\t}\r\n}\r\n"
},
{
"Ident": "sboxskinsgg.claudebridge",
"Path": "Editor/Mcp/BridgeComponentTools.cs",
"FileName": "BridgeComponentTools.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 335526,
"Code": "// AUTO-GENERATED by scripts/emit-mcp-wrappers.mjs \u2014 DO NOT EDIT.\r\n// Regenerate: node scripts/extract-manifest.mjs && node scripts/emit-mcp-wrappers.mjs\r\n// Source of truth: sbox-mcp-server/src/tools/ (zod schemas) \u2192 scripts/tools-manifest.json\r\n\r\nusing System.Text.Json.Nodes;\r\nusing System.Threading.Tasks;\r\nusing Editor.Mcp;\r\n\r\n/// <summary>\r\n/// Add, configure, inspect and invoke components on GameObjects: set/get properties, wire\r\n/// cross-component references, call methods and editor buttons.\r\n/// </summary>\r\n[McpToolset( \"bridge_component\", \"Add, configure, inspect and invoke components on GameObjects: set/get properties, wire cross-component references, call methods and editor buttons.\" )]\r\npublic static class BridgeComponentTools\r\n{\r\n\t/// <summary>\r\n\t/// Create a new GameObject, add a component, set its properties, and optionally parent/position/tag\r\n\t/// it \u2014 all in one atomic call. Collapses the create_gameobject \u2192 add_component_with_properties \u2192\r\n\t/// set_parent sequence. NOTE: a freshly GENERATED component type only resolves after a\r\n\t/// trigger_hotload; generate the script, hotload, THEN call this.\r\n\t/// </summary>\r\n\t/// <param name=\"component\">Component type name to add (e.g. 'CameraComponent', 'ObjectiveManager'). Use list_available_components to find valid types.</param>\r\n\t/// <param name=\"name\">Display name for the new GameObject. Defaults to the component type name.</param>\r\n\t/// <param name=\"properties\">Key-value map of property names to values, auto-converted to the right type (same convention as add_component_with_properties). JSON value.</param>\r\n\t/// <param name=\"position\">World position. As \"x,y,z\" (or JSON {x,y,z}).</param>\r\n\t/// <param name=\"rotation\">World rotation. As \"pitch,yaw,roll\" degrees.</param>\r\n\t/// <param name=\"scale\">World scale (per-axis). As \"x,y,z\" (or JSON {x,y,z}).</param>\r\n\t/// <param name=\"parentId\">GUID of a parent GameObject. Omit for scene root.</param>\r\n\t/// <param name=\"tags\">Tags to add to the new GameObject (e.g. ['player']).</param>\r\n\t[McpTool( \"add_component_to_new_object\" )]\r\n\tpublic static Task<object> AddComponentToNewObject( string component, string name = null, JsonNode properties = null, string position = null, string rotation = null, string scale = null, string parentId = null, string[] tags = null )\r\n\t\t=> McpGate.Run( \"add_component_to_new_object\", McpGate.Args( ( \"component\", component ), ( \"name\", name ), ( \"properties\", properties ), ( \"position\", position ), ( \"rotation\", rotation ), ( \"scale\", scale ), ( \"parentId\", parentId ), ( \"tags\", tags ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Add a component to a GameObject and configure its properties in one call (properties PERSIST\r\n\t/// through save+reload). Use list_available_components to find valid types. Returns\r\n\t/// appliedProperties + failedProperties so you can see exactly what stuck.\r\n\t/// </summary>\r\n\t/// <param name=\"id\">GUID of the GameObject.</param>\r\n\t/// <param name=\"component\">Component type name (e.g. 'ModelRenderer', 'Rigidbody', 'BoxCollider').</param>\r\n\t/// <param name=\"properties\">Key-value map of property names to values, each auto-converted to the property's real type. Primitives '5'/true; Color/Vector3 as comma strings '1,0,0,1'; enum member names; ASSET refs as a path ('Model':'models/dev/box.vmdl', 'MaterialOverride':'materials/x.vmat'); GameObject/Component refs as a target GUID. Best-effort per key \u2014 failures are reported in failedProperties, not silently dropped. JSON value.</param>\r\n\t[McpTool( \"add_component_with_properties\" )]\r\n\tpublic static Task<object> AddComponentWithProperties( string id, string component, JsonNode properties = null )\r\n\t\t=> McpGate.Run( \"add_component_with_properties\", McpGate.Args( ( \"id\", id ), ( \"component\", component ), ( \"properties\", properties ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Dump all public properties of every component on a GameObject. Returns { id, components } where\r\n\t/// each entry is { component, properties: [{ name, type, value }] } \u2014 values are stringified\r\n\t/// (unreadable ones show '<error>'). Use the exact component/property names it reports with\r\n\t/// set_property or get_property; can be large on component-heavy objects.\r\n\t/// </summary>\r\n\t/// <param name=\"id\">GUID of the GameObject.</param>\r\n\t[McpTool.ReadOnly( \"get_all_properties\" )]\r\n\tpublic static Task<object> GetAllProperties( string id )\r\n\t\t=> McpGate.Run( \"get_all_properties\", McpGate.Args( ( \"id\", id ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Read a single property value from a component on a GameObject.\r\n\t/// </summary>\r\n\t/// <param name=\"id\">GUID of the GameObject.</param>\r\n\t/// <param name=\"component\">Component type name (e.g. 'ModelRenderer', 'PlayerController').</param>\r\n\t/// <param name=\"property\">Property name to read.</param>\r\n\t[McpTool.ReadOnly( \"get_property\" )]\r\n\tpublic static Task<object> GetProperty( string id, string component, string property )\r\n\t\t=> McpGate.Run( \"get_property\", McpGate.Args( ( \"id\", id ), ( \"component\", component ), ( \"property\", property ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Call a public method on a component. Matching is tried in order: (1) a [Button] attribute label,\r\n\t/// (2) the exact method NAME, (3) case-insensitive name with spaces stripped. Calls ANY public\r\n\t/// method, not only [Button]-attributed ones (e.g. 'StartGame'). Pass `args` to call methods that\r\n\t/// take parameters \u2014 the arg count must match and each value is coerced to the parameter type\r\n\t/// (primitives: string/number/bool work; complex types like Vector3 may not coerce). Omit args (or\r\n\t/// []) for parameterless methods. (list_component_buttons only lists [Button] methods, so a plain\r\n\t/// method may be invokable yet not appear there.).\r\n\t/// </summary>\r\n\t/// <param name=\"component\">Component type name (e.g. 'MapBuilder', 'SasquatchedGame').</param>\r\n\t/// <param name=\"button\">A [Button] label OR a public method name (e.g. 'Build Terrain', 'StartGame'); case- and space-insensitive.</param>\r\n\t/// <param name=\"id\">Optional GameObject GUID \u2014 if omitted, finds first matching component in scene.</param>\r\n\t/// <param name=\"args\">Arguments to pass (must match the method's parameter count); coerced to each parameter type. JSON array.</param>\r\n\t[McpTool( \"invoke_button\" )]\r\n\tpublic static Task<object> InvokeButton( string component, string button, string id = null, JsonNode args = null )\r\n\t\t=> McpGate.Run( \"invoke_button\", McpGate.Args( ( \"component\", component ), ( \"button\", button ), ( \"id\", id ), ( \"args\", args ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Call a public method BY NAME on a component of a live scene GameObject, passing ARGUMENTS. The\r\n\t/// with-args sibling of invoke_button (which only calls parameterless [Button]/methods on a scene\r\n\t/// component). Finds a public method matching name + arg-count, coerces each JSON arg to the\r\n\t/// parameter type (primitives/enums; Color/Vector3 as comma strings '1,0,0,1'; asset refs as a\r\n\t/// path; GameObject/Component refs as a target GUID), invokes it, and returns the method's return\r\n\t/// value as a string (null for void). Returns success=false with a clear error on\r\n\t/// resolve/coerce/throw.\r\n\t/// </summary>\r\n\t/// <param name=\"id\">GUID of the GameObject.</param>\r\n\t/// <param name=\"method\">Name of the public method to call (e.g. 'TakeDamage', 'AddGold').</param>\r\n\t/// <param name=\"component\">Component type name to target (e.g. 'Health', 'PlayerController'). Omit to search all components on the object for a method matching name + arg-count.</param>\r\n\t/// <param name=\"args\">Ordered arguments, each coerced to the matching parameter's type. Numbers/bools/strings pass through; Color/Vector3/Rotation as comma strings '1,0,0,1'; enum member names; ASSET refs as a path ('models/dev/box.vmdl'); GameObject/Component refs as a target GUID. Omit (or []) for a no-arg method. JSON array.</param>\r\n\t[McpTool( \"invoke_method\" )]\r\n\tpublic static Task<object> InvokeMethod( string id, string method, string component = null, JsonNode args = null )\r\n\t\t=> McpGate.Run( \"invoke_method\", McpGate.Args( ( \"id\", id ), ( \"method\", method ), ( \"component\", component ), ( \"args\", args ) ) );\r\n\r\n\t/// <summary>\r\n\t/// List all instantiable component types in the TypeLibrary \u2014 built-in AND your project's custom\r\n\t/// components (abstract types excluded); filter does a substring match on the type name. Returns {\r\n\t/// count, components } with { name, title, description, fullName } per type, sorted by name \u2014 the\r\n\t/// unfiltered list is LARGE, so pass filter. Use the returned name with\r\n\t/// add_component_with_properties, and describe_type for a type's full property list.\r\n\t/// </summary>\r\n\t/// <param name=\"filter\">Search filter \u2014 matches against component name and title.</param>\r\n\t/// <param name=\"category\">Filter by category/group (e.g. 'Rendering', 'Physics', 'Audio').</param>\r\n\t[McpTool.ReadOnly( \"list_available_components\" )]\r\n\tpublic static Task<object> ListAvailableComponents( string filter = null, string category = null )\r\n\t\t=> McpGate.Run( \"list_available_components\", McpGate.Args( ( \"filter\", filter ), ( \"category\", category ) ) );\r\n\r\n\t/// <summary>\r\n\t/// List the [Button]-attributed methods on a component. NOTE: this only finds methods decorated\r\n\t/// with [Button]; invoke_button can ALSO call any plain public no-arg method by name, so a method\r\n\t/// missing here may still be invokable. Use describe_type / get_method_signature to find non-button\r\n\t/// methods.\r\n\t/// </summary>\r\n\t/// <param name=\"component\">Component type name.</param>\r\n\t/// <param name=\"id\">Optional GameObject GUID.</param>\r\n\t[McpTool.ReadOnly( \"list_component_buttons\" )]\r\n\tpublic static Task<object> ListComponentButtons( string component, string id = null )\r\n\t\t=> McpGate.Run( \"list_component_buttons\", McpGate.Args( ( \"component\", component ), ( \"id\", id ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Wire a component's GameObject/Component-typed property to ANOTHER live object in the scene by\r\n\t/// GUID (e.g. ObjectiveManager.Player = the player, a camera's follow target, a door's hinge).\r\n\t/// Preferred for object/component refs (can pick a specific component type off the target via\r\n\t/// targetComponent, and validates). set_property also accepts a GUID for ref props; set_prefab_ref\r\n\t/// is for prefab assets. Set clear:true to null the reference.\r\n\t/// </summary>\r\n\t/// <param name=\"id\">GUID of the GameObject that HOLDS the component you're writing into.</param>\r\n\t/// <param name=\"component\">Component type name on that object (e.g. 'ObjectiveManager', 'CameraComponent').</param>\r\n\t/// <param name=\"property\">The property to set (must be a GameObject- or Component-typed property).</param>\r\n\t/// <param name=\"targetId\">GUID of the GameObject to reference. Required unless clear:true.</param>\r\n\t/// <param name=\"targetComponent\">If the property is a Component subtype, the specific component type to pull off the target object. Omit to auto-match by the property's type.</param>\r\n\t/// <param name=\"clear\">If true, set the reference to null instead of assigning a target.</param>\r\n\t[McpTool( \"set_component_reference\" )]\r\n\tpublic static Task<object> SetComponentReference( string id, string component, string property, string targetId = null, string targetComponent = null, bool? clear = null )\r\n\t\t=> McpGate.Run( \"set_component_reference\", McpGate.Args( ( \"id\", id ), ( \"component\", component ), ( \"property\", property ), ( \"targetId\", targetId ), ( \"targetComponent\", targetComponent ), ( \"clear\", clear ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Set a property value on a component (editor mode), and PERSIST it (survives save+reload).\r\n\t/// Handles primitives, enums, value types (Color/Vector3 as comma strings), AND references: pass an\r\n\t/// asset PATH for Model/Material/Texture/SoundEvent props, or a GameObject GUID for\r\n\t/// GameObject/Component-typed props (resolved like set_component_reference). Returns success=false\r\n\t/// with a clear error if a path/GUID can't be resolved (no more silent null). For wiring object\r\n\t/// refs prefer set_component_reference; for prefab refs use set_prefab_ref.\r\n\t/// </summary>\r\n\t/// <param name=\"id\">GUID of the GameObject.</param>\r\n\t/// <param name=\"component\">Component type name.</param>\r\n\t/// <param name=\"property\">Property name to set.</param>\r\n\t/// <param name=\"value\">New value. Primitive: '5', 'true'. Color/Vector3: a comma string ('1,0,0,1' / '0,0,200'), an array ([0,0,200]), or an object ({r,g,b,a} / {x,y,z}). Enum: the member name. Asset ref (Model/Material/...): the asset path e.g. 'models/dev/box.vmdl'. GameObject/Component ref: the target GameObject's GUID. Empty/'null' clears the property. JSON value.</param>\r\n\t[McpTool( \"set_property\" )]\r\n\tpublic static Task<object> SetProperty( string id, string component, string property, JsonNode value )\r\n\t\t=> McpGate.Run( \"set_property\", McpGate.Args( ( \"id\", id ), ( \"component\", component ), ( \"property\", property ), ( \"value\", value ) ) );\r\n}\r\n"
},
{
"Ident": "sboxskinsgg.claudebridge",
"Path": "Editor/Mcp/BridgeDiscoveryTools.cs",
"FileName": "BridgeDiscoveryTools.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 335526,
"Code": "// AUTO-GENERATED by scripts/emit-mcp-wrappers.mjs \u2014 DO NOT EDIT.\r\n// Regenerate: node scripts/extract-manifest.mjs && node scripts/emit-mcp-wrappers.mjs\r\n// Source of truth: sbox-mcp-server/src/tools/ (zod schemas) \u2192 scripts/tools-manifest.json\r\n\r\nusing System.Text.Json.Nodes;\r\nusing System.Threading.Tasks;\r\nusing Editor.Mcp;\r\n\r\n/// <summary>\r\n/// Reflect over the s&box API: describe types, search types, get method signatures, list\r\n/// installed libraries, and search project files. Use before writing C# against unfamiliar SDK\r\n/// types.\r\n/// </summary>\r\n[McpToolset( \"bridge_discovery\", \"Reflect over the s&box API: describe types, search types, get method signatures, list installed libraries, and search project files. Use before writing C# against unfamiliar SDK types.\" )]\r\npublic static class BridgeDiscoveryTools\r\n{\r\n\t/// <summary>\r\n\t/// Inspect a type's full surface \u2014 properties, methods, events, attributes \u2014 via reflection on\r\n\t/// Game.TypeLibrary and loaded assemblies. Use this before writing code touching an unfamiliar\r\n\t/// component or s&box API. Examples: 'MeshComponent', 'PlayerController', 'NetworkHelper',\r\n\t/// 'Vector3'.\r\n\t/// </summary>\r\n\t/// <param name=\"name\">Type name (short or fully-qualified).</param>\r\n\t[McpTool.ReadOnly( \"describe_type\" )]\r\n\tpublic static Task<object> DescribeType( string name )\r\n\t\t=> McpGate.Run( \"describe_type\", McpGate.Args( ( \"name\", name ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Grep the user's s&box project for a symbol (case-sensitive substring; skips .git/bin/obj).\r\n\t/// Useful for finding usage examples of an API or seeing how the project already does something.\r\n\t/// Returns `symbol`, `count`, and `results` [{file, line, text}], capped at `max_results` (default\r\n\t/// 25) \u2014 raise it if you may be missing hits. Follow up with read_file on a result's file path.\r\n\t/// </summary>\r\n\t/// <param name=\"symbol\">Substring or symbol to search for.</param>\r\n\t/// <param name=\"extension\">File extension filter. Default: \".cs\".</param>\r\n\t/// <param name=\"max_results\">Maximum hits to return (default 25); the search stops once reached.</param>\r\n\t[McpTool.ReadOnly( \"find_in_project\" )]\r\n\tpublic static Task<object> FindInProject( string symbol, string extension = \".cs\", int max_results = 25 )\r\n\t\t=> McpGate.Run( \"find_in_project\", McpGate.Args( ( \"symbol\", symbol ), ( \"extension\", extension ), ( \"max_results\", max_results ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Get the formal signature(s) of a method on a type \u2014 parameter names, types, defaults, return\r\n\t/// type, all overloads. Use before invoking an API you're unsure of.\r\n\t/// </summary>\r\n\t/// <param name=\"type\">Type name (e.g. 'Scene', 'GameObject').</param>\r\n\t/// <param name=\"method\">Method name (case-sensitive).</param>\r\n\t[McpTool.ReadOnly( \"get_method_signature\" )]\r\n\tpublic static Task<object> GetMethodSignature( string type, string method )\r\n\t\t=> McpGate.Run( \"get_method_signature\", McpGate.Args( ( \"type\", type ), ( \"method\", method ) ) );\r\n\r\n\t/// <summary>\r\n\t/// List the s&box libraries/addons installed in this project (reads Libraries/ + each .sbproj).\r\n\t/// Discovers what's available to build ON \u2014 e.g. character controllers (fish.scc = Shrimple\r\n\t/// Character Controller, facepunch.playercontroller), world/spline/road tools \u2014 so you can leverage\r\n\t/// an installed library (add its components via add_component_with_properties, or generate code\r\n\t/// against its API) instead of writing from scratch. Returns `count` and `libraries` [{folder,\r\n\t/// ident, org, title, type, enabled}] \u2014 ALL libraries, no limit or pagination; `enabled` is false\r\n\t/// when the library's .sbproj has been disabled (renamed .sbproj.disabled). Read-only.\r\n\t/// </summary>\r\n\t[McpTool.ReadOnly( \"list_libraries\" )]\r\n\tpublic static Task<object> ListLibraries()\r\n\t\t=> McpGate.Run( \"list_libraries\", McpGate.Args() );\r\n\r\n\t/// <summary>\r\n\t/// Find loaded types matching a name pattern. Useful for discovering 'is there a built-in X for\r\n\t/// this?'. Returns `count` and `matches` with each type's name, fullName, isComponent, and\r\n\t/// isAbstract \u2014 results are silently truncated at `limit` (default 50), so narrow the pattern if\r\n\t/// you hit the cap. Pass a match's name to describe_type for its full member surface.\r\n\t/// </summary>\r\n\t/// <param name=\"pattern\">Substring to match against type name (case-insensitive).</param>\r\n\t/// <param name=\"namespace\">Optional namespace filter (case-insensitive substring).</param>\r\n\t/// <param name=\"components_only\">Only return Component subclasses (default false).</param>\r\n\t/// <param name=\"limit\">Maximum matches to return (default 50); the search stops silently at this cap.</param>\r\n\t[McpTool.ReadOnly( \"search_types\" )]\r\n\tpublic static Task<object> SearchTypes( string pattern, string @namespace = null, bool components_only = false, int limit = 50 )\r\n\t\t=> McpGate.Run( \"search_types\", McpGate.Args( ( \"pattern\", pattern ), ( \"namespace\", @namespace ), ( \"components_only\", components_only ), ( \"limit\", limit ) ) );\r\n}\r\n"
},
{
"Ident": "sboxskinsgg.claudebridge",
"Path": "Editor/Mcp/BridgeMaterialTools.cs",
"FileName": "BridgeMaterialTools.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 335526,
"Code": "// AUTO-GENERATED by scripts/emit-mcp-wrappers.mjs \u2014 DO NOT EDIT.\r\n// Regenerate: node scripts/extract-manifest.mjs && node scripts/emit-mcp-wrappers.mjs\r\n// Source of truth: sbox-mcp-server/src/tools/ (zod schemas) \u2192 scripts/tools-manifest.json\r\n\r\nusing System.Text.Json.Nodes;\r\nusing System.Threading.Tasks;\r\nusing Editor.Mcp;\r\n\r\n/// <summary>\r\n/// Assign models and materials to renderers, author .vmat materials, and set material properties.\r\n/// </summary>\r\n[McpToolset( \"bridge_material\", \"Assign models and materials to renderers, author .vmat materials, and set material properties.\" )]\r\npublic static class BridgeMaterialTools\r\n{\r\n\t/// <summary>\r\n\t/// Apply a material to a GameObject by setting its ModelRenderer's MaterialOverride (overrides the\r\n\t/// whole model's material). Requires an existing ModelRenderer (assign_model first) and errors if\r\n\t/// the material path can't be loaded. Returns { assigned, id, material } \u2014 tweak values afterwards\r\n\t/// with set_material_property.\r\n\t/// </summary>\r\n\t/// <param name=\"id\">GUID of the GameObject.</param>\r\n\t/// <param name=\"material\">Material path (e.g. 'materials/walls/brick.vmat').</param>\r\n\t/// <param name=\"slot\">Material slot index. Defaults to 0 (first slot).</param>\r\n\t[McpTool( \"assign_material\" )]\r\n\tpublic static Task<object> AssignMaterial( string id, string material, double? slot = null )\r\n\t\t=> McpGate.Run( \"assign_material\", McpGate.Args( ( \"id\", id ), ( \"material\", material ), ( \"slot\", slot ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Set a 3D model on a GameObject's ModelRenderer. Creates the renderer component if it doesn't\r\n\t/// exist; errors if the model path can't be loaded. Returns { assigned, id, model } \u2014 follow with\r\n\t/// assign_material / set_material_property to style it, or take a screenshot to verify.\r\n\t/// </summary>\r\n\t/// <param name=\"id\">GUID of the GameObject.</param>\r\n\t/// <param name=\"model\">Model path (e.g. 'models/citizen/citizen.vmdl', 'models/dev/box.vmdl').</param>\r\n\t[McpTool( \"assign_model\" )]\r\n\tpublic static Task<object> AssignModel( string id, string model )\r\n\t\t=> McpGate.Run( \"assign_model\", McpGate.Args( ( \"id\", id ), ( \"model\", model ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Create a new material file (.vmat, KV1 format) with a shader and properties like color,\r\n\t/// roughness, metallic, texture. Errors if the file already exists; when no properties are given it\r\n\t/// writes sensible PBR defaults (g_flMetalness 0, g_flRoughness 1). Returns { created, path,\r\n\t/// shader, propertiesWritten } \u2014 pass the returned path to recompile_asset (so the editor compiles\r\n\t/// it) and then assign_material.\r\n\t/// </summary>\r\n\t/// <param name=\"path\">Relative path for the material (e.g. 'materials/walls/brick.vmat').</param>\r\n\t/// <param name=\"shader\">Shader to use. Defaults to 'shaders/complex.shader' (PBR).</param>\r\n\t/// <param name=\"properties\">Material properties as key-value pairs (e.g. { \"Color\": \"#ff0000\", \"Roughness\": 0.8 }). JSON value.</param>\r\n\t[McpTool( \"create_material\" )]\r\n\tpublic static Task<object> CreateMaterial( string path, string shader = null, JsonNode properties = null )\r\n\t\t=> McpGate.Run( \"create_material\", McpGate.Args( ( \"path\", path ), ( \"shader\", shader ), ( \"properties\", properties ) ) );\r\n\r\n\t/// <summary>\r\n\t/// Change a property on the material assigned to a GameObject \u2014 color, roughness, metallic,\r\n\t/// texture, etc. Operates on the ModelRenderer's MaterialOverride; if none is assigned it\r\n\t/// auto-creates one from the default complex shader (no separate assign_material step needed).\r\n\t/// Returns { set, id, property, autoCreatedMaterial } \u2014 screenshot to verify the visual change.\r\n\t/// </summary>\r\n\t/// <param name=\"id\">GUID of the GameObject.</param>\r\n\t/// <param name=\"property\">Material property name (e.g. 'Color', 'Roughness', 'Metalness', 'Normal').</param>\r\n\t/// <param name=\"value\">Property value \u2014 number for floats, string for texture paths/colors, {r,g,b,a} for colors. JSON value.</param>\r\n\t[McpTool( \"set_material_property\" )]\r\n\tpublic static Task<object> SetMaterialProperty( string id, string property, JsonNode value )\r\n\t\t=> McpGate.Run( \"set_material_property\", McpGate.Args( ( \"id\", id ), ( \"property\", property ), ( \"value\", value ) ) );\r\n}\r\n"
}
]
}