Editor scaffold handlers for the s&box editor. This file implements a set of IBridgeHandler classes and helper routines that generate C# scaffold components and place them into an active scene, write generated .cs files into the project, and set up properties/refs on scene objects (examples include objective manager, health, wallet, pickups, phase machine, day-night clock, interactable, loot table, etc.).
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;
// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
// Playable Game Scaffolds — Phase 1
//
// This file lives in the SAME assembly as MyEditorMenu.cs, so it reuses the
// shared helpers on the `ClaudeBridge` static class (TryResolveProjectPath,
// SanitizeIdentifier, ParseVector3, ParseRotation, SerializeGo) and the
// IBridgeHandler dispatch contract. Handler code here is UNSANDBOXED editor
// code (System.* is fine).
//
// IMPORTANT — the C# *strings these handlers WRITE TO DISK* are SANDBOXED game
// code. That generated code must obey the s&box sandbox rules:
// • MathX preferred; System.Math/MathF also compile on the current SDK (verified 2026-06-09). Array.Clone() is still whitelist-blocked -- use .ToArray().
// • guard networking with IsProxy / try-catch (Networking.IsHost can throw)
// • model shape on CreateGameManager / CreateTriggerZone, which compile today
//
// All handlers are guarded in try/catch and return `new { error = ... }` on
// failure so the dispatch envelope reports success=false (see
// ClaudeBridge.ProcessRequest / TryGetHandlerError in MyEditorMenu.cs).
//
// Registration lines + the _sceneMutatingCommands additions are listed in the
// implementation summary — MyEditorMenu.cs owns those (this file is not edited
// into the Register() block here to keep the two files decoupled).
// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
/// <summary>
/// Shared helpers for the scaffold handlers. Kept internal to this file so it
/// doesn't collide with anything in MyEditorMenu.cs. Mirrors the property
/// coercion used by AddComponentWithPropertiesHandler / SetPropertyHandler.
/// </summary>
internal static class ScaffoldHelpers
{
/// <summary>
/// Resolve a component on a GameObject by its short type name (case-insensitive).
/// Mirrors the lookup used across MyEditorMenu.cs handlers.
/// </summary>
public static Component FindComponent( GameObject go, string typeName )
{
if ( go == null ) return null;
if ( string.IsNullOrEmpty( typeName ) )
return go.Components.GetAll().FirstOrDefault();
return go.Components.GetAll()
.FirstOrDefault( c => c.GetType().Name.Equals( typeName, StringComparison.OrdinalIgnoreCase ) );
}
/// <summary>
/// Apply a {name: value} JSON object of property overrides to a freshly created
/// component. Best-effort per property (matches AddComponentWithPropertiesHandler):
/// a bad single value never aborts the whole apply. Returns the names that were set.
/// </summary>
public static List<string> ApplyProperties( Component component, TypeDescription typeDesc, JsonElement props )
{
var applied = new List<string>();
if ( props.ValueKind != JsonValueKind.Object ) return applied;
foreach ( var prop in props.EnumerateObject() )
{
try
{
var pd = typeDesc.Properties.FirstOrDefault( pp => pp.Name == prop.Name );
if ( pd == null ) continue;
// Normalize the JSON token to a string, then route through the shared
// type-aware coercion (ClaudeBridge.CoercePropertyAndSet). This fixes the
// same reference/asset gap as set_property: a Model/Material/GameObject/
// Component property used to receive a raw string and silently stayed null.
// Now they're loaded/resolved to the right typed value so they persist.
// Numbers/bools/value-type strings keep working. Best-effort per property.
string valStr = prop.Value.ValueKind switch
{
JsonValueKind.String => prop.Value.GetString(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Null => "null",
_ => prop.Value.GetRawText()
};
if ( ClaudeBridge.CoercePropertyAndSet( pd.PropertyType, v => pd.SetValue( component, v ), pd.Name, valStr, out _ ) )
applied.Add( prop.Name );
}
catch { /* best-effort, same as AddComponentWithPropertiesHandler */ }
}
return applied;
}
/// <summary>Read a JSON token as a float — accepts a JSON number OR a numeric string.</summary>
static float CoerceFloat( JsonElement v )
{
if ( v.ValueKind == JsonValueKind.Number && v.TryGetSingle( out var f ) ) return f;
if ( v.ValueKind == JsonValueKind.String
&& float.TryParse( v.GetString(), System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var fs ) ) return fs;
return float.Parse( v.ToString(), System.Globalization.CultureInfo.InvariantCulture );
}
/// <summary>Read a JSON token as an int — accepts a JSON number OR a numeric string.</summary>
static int CoerceInt( JsonElement v )
{
if ( v.ValueKind == JsonValueKind.Number && v.TryGetInt32( out var i ) ) return i;
if ( v.ValueKind == JsonValueKind.String && int.TryParse( v.GetString(), out var iss ) ) return iss;
// Tolerate a float-shaped number/string for an int property (e.g. 2.0 -> 2).
return (int) CoerceFloat( v );
}
/// <summary>Read a JSON token as a bool — accepts true/false tokens OR a "true"/"false" string.</summary>
static bool CoerceBool( JsonElement v )
{
if ( v.ValueKind == JsonValueKind.True ) return true;
if ( v.ValueKind == JsonValueKind.False ) return false;
if ( v.ValueKind == JsonValueKind.String && bool.TryParse( v.GetString(), out var b ) ) return b;
return false;
}
/// <summary>
/// Standard "generate a .cs file" preamble shared by the system-scaffold
/// handlers: derive file name + class name, resolve + containment-check the
/// path, refuse if the file already exists. Returns false with an `error`
/// object on any failure.
/// </summary>
public static bool PrepareCodeFile(
JsonElement p, string defaultName, out string fullPath, out string relPath,
out string className, out object error )
{
fullPath = null; relPath = null; className = null; error = null;
var name = p.TryGetProperty( "name", out var n ) && !string.IsNullOrWhiteSpace( n.GetString() ) ? n.GetString() : defaultName;
var directory = p.TryGetProperty( "directory", out var d ) && !string.IsNullOrWhiteSpace( d.GetString() ) ? d.GetString() : "Code";
var fileName = name.EndsWith( ".cs" ) ? name : $"{name}.cs";
if ( !ClaudeBridge.TryResolveProjectPath( Path.Combine( directory, fileName ), out fullPath, out var pathErr ) )
{
error = new { error = pathErr };
return false;
}
if ( File.Exists( fullPath ) )
{
error = new { error = $"File already exists: {directory}/{fileName}. Choose a different name." };
return false;
}
Directory.CreateDirectory( Path.GetDirectoryName( fullPath ) );
className = ClaudeBridge.SanitizeIdentifier( Path.GetFileNameWithoutExtension( fileName ) );
relPath = $"{directory}/{fileName}";
return true;
}
/// <summary>UTF-8 without BOM — generated game code is read by the s&box compiler.</summary>
public static readonly Encoding Utf8NoBom = new UTF8Encoding( false );
public static void WriteCode( string fullPath, string code )
{
File.WriteAllText( fullPath, code, Utf8NoBom );
}
}
// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
// B. set_component_reference — wire a component property to a LIVE scene
// GameObject (or a component on it) by GUID. The highest-value gap:
// today set_property only does primitives and set_prefab_ref only assigns a
// PREFAB asset's GameObject. This assigns a scene object.
// Mirrors SetPrefabRefHandler's reflection (Game.TypeLibrary.GetType →
// Properties → propDesc.SetValue) but the value is a scene object.
// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
public class SetComponentReferenceHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
// The GameObject that holds the component we're writing into.
var id = p.GetProperty( "id" ).GetString();
if ( !Guid.TryParse( id, out var guid ) )
return Task.FromResult<object>( new { error = "Invalid GUID (id)" } );
var go = scene.Directory.FindByGuid( guid );
if ( go == null )
return Task.FromResult<object>( new { error = $"GameObject not found: {id}" } );
var componentType = p.GetProperty( "component" ).GetString();
var propertyName = p.GetProperty( "property" ).GetString();
var component = ScaffoldHelpers.FindComponent( go, componentType );
if ( component == null )
return Task.FromResult<object>( new { error = $"Component not found on object: {componentType}" } );
var typeDesc = Game.TypeLibrary.GetType( component.GetType().Name );
var propDesc = typeDesc?.Properties.FirstOrDefault( pp => pp.Name == propertyName );
if ( propDesc == null )
return Task.FromResult<object>( new { error = $"Property not found: {propertyName} on {componentType}" } );
var propType = propDesc.PropertyType;
if ( propType == null )
return Task.FromResult<object>( new { error = $"Could not resolve the type of property {propertyName}" } );
// Clear the reference (set null) when asked.
if ( p.TryGetProperty( "clear", out var clr ) && clr.ValueKind == JsonValueKind.True )
{
propDesc.SetValue( component, null );
return Task.FromResult<object>( new { set = true, cleared = true, id, component = componentType, property = propertyName } );
}
// Resolve the target GameObject by GUID.
if ( !p.TryGetProperty( "targetId", out var tid ) || tid.ValueKind != JsonValueKind.String )
return Task.FromResult<object>( new { error = "targetId is required (the GUID of the GameObject to reference), unless clear=true" } );
if ( !Guid.TryParse( tid.GetString(), out var targetGuid ) )
return Task.FromResult<object>( new { error = "Invalid GUID (targetId)" } );
var targetGo = scene.Directory.FindByGuid( targetGuid );
if ( targetGo == null )
return Task.FromResult<object>( new { error = $"Target GameObject not found: {tid.GetString()}" } );
// Two assignable shapes:
// 1. property type is GameObject (or assignable from it) → assign the GO directly
// 2. property type is a Component subtype → assign a component of that type off the GO
bool wantsGameObject = propType == typeof( GameObject ) || propType.IsAssignableFrom( typeof( GameObject ) );
bool wantsComponent = typeof( Component ).IsAssignableFrom( propType );
if ( wantsGameObject )
{
propDesc.SetValue( component, targetGo );
return Task.FromResult<object>( new
{
set = true, id, component = componentType, property = propertyName,
targetId = tid.GetString(), targetName = targetGo.Name, kind = "GameObject"
} );
}
if ( wantsComponent )
{
// Optionally the caller named the exact component type to pull off the target.
string targetComponentName = p.TryGetProperty( "targetComponent", out var tc ) ? tc.GetString() : null;
Component targetComp = targetGo.Components.GetAll().FirstOrDefault( c =>
( string.IsNullOrEmpty( targetComponentName )
? propType.IsAssignableFrom( c.GetType() )
: c.GetType().Name.Equals( targetComponentName, StringComparison.OrdinalIgnoreCase ) )
&& propType.IsAssignableFrom( c.GetType() ) );
if ( targetComp == null )
return Task.FromResult<object>( new
{
error = $"Target object '{targetGo.Name}' has no component assignable to property type '{propType.Name}'" +
( string.IsNullOrEmpty( targetComponentName ) ? "" : $" matching '{targetComponentName}'" )
} );
propDesc.SetValue( component, targetComp );
return Task.FromResult<object>( new
{
set = true, id, component = componentType, property = propertyName,
targetId = tid.GetString(), targetName = targetGo.Name,
targetComponent = targetComp.GetType().Name, kind = "Component"
} );
}
return Task.FromResult<object>( new
{
error = $"Property '{propertyName}' has type '{propType.Name}', which is neither a GameObject nor a Component. " +
"set_component_reference only wires object/component references; use set_property for primitives."
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"set_component_reference failed: {ex.Message}" } );
}
}
}
// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
// C. add_component_to_new_object — atomic create-GO + add-component +
// set-props (+ optional parent/transform/tags) in one round-trip.
// Combines CreateGameObjectHandler + AddComponentWithPropertiesHandler.
// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
public class AddComponentToNewObjectHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var typeName = p.GetProperty( "component" ).GetString();
var typeDesc = Game.TypeLibrary.GetType( typeName );
if ( typeDesc == null )
return Task.FromResult<object>( new { error = $"Component type not found: {typeName}. (A freshly generated component is only in the TypeLibrary after a hotload — generate + trigger_hotload first, then place.)" } );
var go = scene.CreateObject( true );
go.Name = p.TryGetProperty( "name", out var n ) && !string.IsNullOrWhiteSpace( n.GetString() )
? n.GetString()
: typeName;
if ( p.TryGetProperty( "position", out var pos ) )
go.WorldPosition = ClaudeBridge.ParseVector3( pos );
if ( p.TryGetProperty( "rotation", out var rot ) )
go.WorldRotation = ClaudeBridge.ParseRotation( rot );
if ( p.TryGetProperty( "scale", out var scl ) )
go.WorldScale = ClaudeBridge.ParseVector3( scl );
if ( p.TryGetProperty( "parentId", out var pid ) && pid.ValueKind == JsonValueKind.String
&& Guid.TryParse( pid.GetString(), out var parentGuid ) )
{
var parent = scene.Directory.FindByGuid( parentGuid );
if ( parent != null )
go.SetParent( parent, keepWorldPosition: true );
}
if ( p.TryGetProperty( "tags", out var tags ) && tags.ValueKind == JsonValueKind.Array )
{
foreach ( var tag in tags.EnumerateArray() )
{
var t = tag.GetString();
if ( !string.IsNullOrWhiteSpace( t ) ) go.Tags.Add( t );
}
}
var component = go.Components.Create( typeDesc );
if ( component == null )
return Task.FromResult<object>( new { error = $"Failed to create component instance: {typeName}" } );
List<string> appliedProps = new();
if ( p.TryGetProperty( "properties", out var props ) )
appliedProps = ScaffoldHelpers.ApplyProperties( component, typeDesc, props );
return Task.FromResult<object>( new
{
created = true,
component = typeName,
appliedProperties = appliedProps,
gameObject = ClaudeBridge.SerializeGo( go )
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"add_component_to_new_object failed: {ex.Message}" } );
}
}
}
// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
// E. create_objective_system — the win/lose primitive (ObjectiveManager).
// Writes a self-contained Component singleton. Optionally places it on a
// scene GameObject (only if the type is already in the TypeLibrary, i.e.
// after a hotload — same constraint as add_component_to_new_object).
// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
public class CreateObjectiveSystemHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "ObjectiveManager", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
var objective = p.TryGetProperty( "objective", out var ob ) ? ob.GetString() : "reach_goal";
var loseOn = p.TryGetProperty( "loseOn", out var lo ) ? lo.GetString() : "fall";
int targetCount = p.TryGetProperty( "targetCount", out var tcv ) && tcv.TryGetInt32( out var tci ) ? tci : 3;
float timeLimit = p.TryGetProperty( "timeLimit", out var tlv ) && tlv.TryGetSingle( out var tlf ) ? tlf : 60f;
float killZ = p.TryGetProperty( "killZ", out var kzv ) && kzv.TryGetSingle( out var kzf ) ? kzf : -1000f;
int lives = p.TryGetProperty( "lives", out var lvv ) && lvv.TryGetInt32( out var lvi ) ? lvi : 1;
var code = BuildCode( className, objective, loseOn, targetCount, timeLimit, killZ, lives );
ScaffoldHelpers.WriteCode( fullPath, code );
object placed = MaybePlace( p, className, out var placeNote );
return Task.FromResult<object>( new
{
created = true, path = relPath, className,
gameObject = placed, note = placeNote
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_objective_system failed: {ex.Message}" } );
}
}
// Placement only succeeds if the just-generated type is already in the
// TypeLibrary (true only after a hotload). We surface a clear note so the
// skill knows to hotload then place via add_component_to_new_object.
static object MaybePlace( JsonElement p, string className, out string note )
{
note = null;
bool place = !p.TryGetProperty( "placeInScene", out var pis ) || pis.ValueKind != JsonValueKind.False;
if ( !place ) { note = "Not placed (placeInScene=false). Add it after hotload."; return null; }
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null ) { note = "No active scene to place into."; 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 place it with add_component_to_new_object (component=\"{className}\").";
return null;
}
try
{
var go = scene.CreateObject( true );
go.Name = className;
go.Components.Create( typeDesc );
return ClaudeBridge.SerializeGo( go );
}
catch ( Exception ex )
{
note = $"Generated {className}.cs; placement failed ({ex.Message}). Place after hotload.";
return null;
}
}
static string BuildCode( string className, string objective, string loseOn, int targetCount, float timeLimit, float killZ, int lives )
{
// Sanitize objective/loseOn into a known set (defensive — they come from a tool schema enum).
objective = objective?.ToLowerInvariant() switch
{
"collect_all" or "reach_goal" or "survive_time" or "eliminate_all" => objective.ToLowerInvariant(),
_ => "reach_goal"
};
loseOn = loseOn?.ToLowerInvariant() switch
{
"fall" or "timer" or "lives" or "none" => loseOn.ToLowerInvariant(),
_ => "fall"
};
// String-format invariant so floats never emit a comma decimal separator.
string tc = targetCount.ToString();
string tl = timeLimit.ToString( System.Globalization.CultureInfo.InvariantCulture ) + "f";
string kz = killZ.ToString( System.Globalization.CultureInfo.InvariantCulture ) + "f";
string lv = lives.ToString();
return $@"using Sandbox;
using System;
/// <summary>
/// {className} — the win/lose brain for a scaffolded game. Drop ONE of these in
/// a scene; gameplay systems talk to it through {className}.Instance.
///
/// Objective: {objective} Lose condition: {loseOn}
///
/// How other systems use it:
/// {className}.Instance?.RegisterPickup(); // a Pickup was collected
/// {className}.Instance?.RegisterKill(); // an enemy was eliminated
/// {className}.Instance?.ReachGoal(); // the player touched the goal trigger
/// Single-player safe; [Sync] keeps the score consistent if you network it later.
/// </summary>
public sealed class {className} : Component
{{
// Singleton — systems find the manager without a scene reference.
public static {className} Instance {{ get; private set; }}
[Property] public string Objective {{ get; set; }} = ""{objective}"";
[Property] public string LoseOn {{ get; set; }} = ""{loseOn}"";
[Property] public int TargetCount {{ get; set; }} = {tc};
[Property] public float TimeLimit {{ get; set; }} = {tl};
[Property] public float KillZ {{ get; set; }} = {kz};
[Property] public int Lives {{ get; set; }} = {lv};
// The player the lose-on-fall check watches. Wire this with set_component_reference.
[Property] public GameObject Player {{ get; set; }}
// Live progress — synced so a HUD on any client reads the same numbers.
[Sync] public int Progress {{ get; set; }}
[Sync] public int LivesRemaining {{ get; set; }}
[Sync] public bool IsWon {{ get; set; }}
[Sync] public bool IsLost {{ get; set; }}
// Fired once when the game ends. Hook a HUD / menu here.
public Action OnWin {{ get; set; }}
public Action OnLose {{ get; set; }}
// TimeSince starts at 0 — fine here, we WANT the survive timer to begin at spawn.
private TimeSince _sinceStart;
protected override void OnStart()
{{
Instance = this;
Progress = 0;
LivesRemaining = Lives;
IsWon = false;
IsLost = false;
_sinceStart = 0f;
}}
protected override void OnDestroy()
{{
if ( Instance == this ) Instance = null;
}}
protected override void OnUpdate()
{{
if ( IsWon || IsLost ) return;
// ---- Lose conditions ----
switch ( LoseOn )
{{
case ""fall"":
if ( Player.IsValid() && Player.WorldPosition.z < KillZ )
Lose();
break;
case ""timer"":
if ( _sinceStart > TimeLimit )
Lose();
break;
case ""lives"":
if ( LivesRemaining <= 0 )
Lose();
break;
}}
// ---- Win-by-survival ----
if ( Objective == ""survive_time"" && _sinceStart > TimeLimit )
Win();
}}
/// <summary>A collectible was picked up; advances collect_all objectives.</summary>
public void RegisterPickup()
{{
if ( IsWon || IsLost ) return;
Progress++;
if ( Objective == ""collect_all"" && Progress >= TargetCount )
Win();
}}
/// <summary>An enemy was eliminated; advances eliminate_all objectives.</summary>
public void RegisterKill()
{{
if ( IsWon || IsLost ) return;
Progress++;
if ( Objective == ""eliminate_all"" && Progress >= TargetCount )
Win();
}}
/// <summary>The player reached the goal; wins reach_goal objectives.</summary>
public void ReachGoal()
{{
if ( IsWon || IsLost ) return;
if ( Objective == ""reach_goal"" )
Win();
}}
/// <summary>Call when the player dies / takes fatal damage. Loses a life;
/// loses the game if out of lives (only matters when LoseOn == ""lives"").</summary>
public void LoseLife()
{{
if ( IsWon || IsLost ) return;
LivesRemaining--;
if ( LivesRemaining <= 0 )
Lose();
}}
public void Win()
{{
if ( IsWon || IsLost ) return;
IsWon = true;
Log.Info( $""[{className}] YOU WIN — objective '{{Objective}}' complete."" );
OnWin?.Invoke();
}}
public void Lose()
{{
if ( IsWon || IsLost ) return;
IsLost = true;
Log.Info( ""[{className}] GAME OVER."" );
OnLose?.Invoke();
}}
}}
";
}
}
// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
// F. create_health_system — a Health component with damage/heal/death.
// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
public class CreateHealthSystemHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "Health", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
float maxHealth = p.TryGetProperty( "maxHealth", out var mhv ) && mhv.TryGetSingle( out var mhf ) ? mhf : 100f;
bool regen = p.TryGetProperty( "regen", out var rv ) && rv.ValueKind == JsonValueKind.True;
bool respawn = p.TryGetProperty( "respawn", out var sv ) && sv.ValueKind == JsonValueKind.True;
var code = BuildCode( className, maxHealth, regen, respawn );
ScaffoldHelpers.WriteCode( fullPath, code );
// Optional placement on an existing GameObject (the target must exist;
// the Health type must be in the TypeLibrary, i.e. after a hotload).
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new { created = true, path = relPath, className, placedOn, note } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_health_system failed: {ex.Message}" } );
}
}
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; }
}
static string BuildCode( string className, float maxHealth, bool regen, bool respawn )
{
string mh = maxHealth.ToString( System.Globalization.CultureInfo.InvariantCulture ) + "f";
// Optional regen block (uses MathX.Clamp — System.Math is unavailable in the sandbox).
string regenFields = regen
? @"
[Property] public float RegenPerSecond { get; set; } = 5f;
[Property] public float RegenDelay { get; set; } = 3f;
private TimeSince _sinceDamage = 100f; // start high so regen isn't blocked at spawn"
: "";
string regenUpdate = regen
? @"
// Passive regeneration after a delay since the last hit.
if ( !IsDead && _sinceDamage > RegenDelay && CurrentHealth < MaxHealth )
CurrentHealth = MathX.Clamp( CurrentHealth + RegenPerSecond * Time.Delta, 0f, MaxHealth );"
: "";
string updateMethod = regen
? $@"
protected override void OnUpdate()
{{{regenUpdate}
}}"
: "";
string respawnFields = respawn
? @"
// Where to respawn. Wire this with set_component_reference (e.g. a spawn-point GameObject).
[Property] public GameObject RespawnPoint { get; set; }"
: "";
// Death body: respawn-or-disable, and notify the objective manager if one exists.
string deathBody = respawn
? @"
// Respawn at the spawn point if we have one, else just disable.
if ( RespawnPoint.IsValid() )
{
WorldPosition = RespawnPoint.WorldPosition;
CurrentHealth = MaxHealth;
IsDead = false;
ObjectiveManagerLoseLife();
return;
}
GameObject.Enabled = false;
ObjectiveManagerLoseLife();"
: @"
// No respawn configured — disable the object and tell the objective system.
GameObject.Enabled = false;
ObjectiveManagerLoseLife();";
return $@"using Sandbox;
using System;
/// <summary>
/// {className} — hit points with damage, healing and death for any GameObject.
///
/// Usage from other code:
/// GetComponent<{className}>()?.TakeDamage( 25f );
/// GetComponent<{className}>()?.Heal( 10f );
/// Subscribe to OnDeath for custom death FX. [Sync] keeps health consistent in
/// multiplayer; single-player safe with no networking active.
/// </summary>
public sealed class {className} : Component
{{
[Property] public float MaxHealth {{ get; set; }} = {mh};
// [Sync] so all clients agree on the value; harmless offline.
[Sync] public float CurrentHealth {{ get; set; }}
[Sync] public bool IsDead {{ get; set; }}{regenFields}{respawnFields}
// Fired once when health hits zero. Hook ragdoll / VFX / score here.
public Action OnDeath {{ get; set; }}
protected override void OnStart()
{{
CurrentHealth = MaxHealth;
IsDead = false;
}}{updateMethod}
/// <summary>Apply damage. Host-authoritative when networking is active so a
/// proxy can't fake a hit; always runs offline.</summary>
public void TakeDamage( float amount, GameObject attacker = null )
{{
if ( IsDead || amount <= 0f ) return;
// Only the owner/host mutates health when networked. IsProxy is false offline.
if ( IsProxy ) return;
{( regen ? "\t\t_sinceDamage = 0f;\n" : "" )} CurrentHealth = MathX.Clamp( CurrentHealth - amount, 0f, MaxHealth );
if ( CurrentHealth <= 0f )
Die();
}}
/// <summary>Restore health, clamped to MaxHealth.</summary>
public void Heal( float amount )
{{
if ( IsDead || amount <= 0f ) return;
if ( IsProxy ) return;
CurrentHealth = MathX.Clamp( CurrentHealth + amount, 0f, MaxHealth );
}}
private void Die()
{{
if ( IsDead ) return;
IsDead = true;
Log.Info( $""[{className}] {{GameObject.Name}} died."" );
OnDeath?.Invoke();
{deathBody}
}}
// Notify an ObjectiveManager if the project has one (loose coupling via reflection-free
// static lookup would require knowing the type; instead we no-op safely if absent).
private void ObjectiveManagerLoseLife()
{{
// If you scaffolded an ObjectiveManager, call its LoseLife() from your own
// death handler, e.g.: ObjectiveManager.Instance?.LoseLife();
// Left as a hook so {className} stays self-contained with no hard dependency.
}}
}}
";
}
}
// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
// G. create_pickup — a trigger-based collectible. Mirrors CreateTriggerZone's
// Component.ITriggerListener pattern. Optionally builds a visible GO with a
// SphereCollider(trigger) + ModelRenderer.
// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
public class CreatePickupHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "Pickup", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
var action = p.TryGetProperty( "action", out var av ) ? av.GetString() : "score";
float amount = p.TryGetProperty( "amount", out var amv ) && amv.TryGetSingle( out var amf ) ? amf : 1f;
var filterTag = p.TryGetProperty( "filterTag", out var ftv ) && !string.IsNullOrWhiteSpace( ftv.GetString() ) ? ftv.GetString() : "player";
var code = BuildCode( className, action, amount, filterTag );
ScaffoldHelpers.WriteCode( fullPath, code );
// Optional in-scene placement: build a GO with a trigger sphere + (model) +
// the Pickup component (component only attaches if it's in the TypeLibrary post-hotload).
object placed = null; string note = null;
bool place = p.TryGetProperty( "placeInScene", out var pis ) && pis.ValueKind == JsonValueKind.True;
if ( place )
placed = BuildPickupObject( p, className, out note );
return Task.FromResult<object>( new { created = true, path = relPath, className, gameObject = placed, note } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_pickup failed: {ex.Message}" } );
}
}
static object BuildPickupObject( JsonElement p, string className, out string note )
{
note = null;
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null ) { note = "No active scene to place into."; return null; }
var go = scene.CreateObject( true );
go.Name = className;
if ( p.TryGetProperty( "position", out var pos ) )
go.WorldPosition = ClaudeBridge.ParseVector3( pos );
// Trigger collider so OnTriggerEnter fires.
try
{
var sphere = go.AddComponent<SphereCollider>();
sphere.Radius = p.TryGetProperty( "radius", out var rv ) && rv.TryGetSingle( out var rf ) ? rf : 24f;
sphere.IsTrigger = true;
}
catch ( Exception ex ) { note = $"SphereCollider add failed: {ex.Message}"; }
// Optional visible model.
if ( p.TryGetProperty( "model", out var mp ) && !string.IsNullOrWhiteSpace( mp.GetString() ) )
{
try
{
var model = Model.Load( mp.GetString() );
if ( model != null )
{
var r = go.AddComponent<ModelRenderer>();
r.Model = model;
}
else note = AppendNote( note, $"Model not found: {mp.GetString()} (cloud assets need install_asset)." );
}
catch ( Exception ex ) { note = AppendNote( note, $"Model load failed: {ex.Message}" ); }
}
// Attach the Pickup component if the freshly generated type is loaded.
var typeDesc = Game.TypeLibrary.GetType( className );
if ( typeDesc != null )
{
try { go.Components.Create( typeDesc ); }
catch ( Exception ex ) { note = AppendNote( note, $"Component attach failed: {ex.Message}" ); }
}
else
{
note = AppendNote( note, $"Built the pickup object + trigger, but {className} is not in the TypeLibrary yet — trigger_hotload, then add_component_with_properties (component=\"{className}\") on this GameObject." );
}
return ClaudeBridge.SerializeGo( go );
}
static string AppendNote( string existing, string add )
=> string.IsNullOrEmpty( existing ) ? add : existing + " " + add;
static string BuildCode( string className, string action, float amount, string filterTag )
{
action = action?.ToLowerInvariant() switch
{
"score" or "heal" or "item" or "custom" => action.ToLowerInvariant(),
_ => "score"
};
string amt = amount.ToString( System.Globalization.CultureInfo.InvariantCulture ) + "f";
// Every action is SELF-CONTAINED and always compiles — no hard dependency on
// a Health / Inventory / ObjectiveManager type that may not exist in the
// project. The OnCollected event is the loose-coupling seam: the scaffold
// skill wires it (e.g. to ObjectiveManager.Instance.RegisterPickup or
// Health.Heal) after generation. The comment in each branch shows the
// direct typed call if you DO have the companion system.
string itemField = action == "item"
? "\n\t[Property] public string ItemName { get; set; } = \"item\";"
: "";
string effect = action switch
{
"heal" => $@"
// Heal the collector if you also scaffolded a Health system:
// other.GameObject.GetComponent<Health>()?.Heal( Amount );
Log.Info( $""[{className}] Healed {{other.GameObject.Name}} for {{Amount}}."" );",
"item" => $@"
// Add to the collector's inventory if you also scaffolded an Inventory:
// other.GameObject.GetComponent<Inventory>()?.Add( ItemName );
Log.Info( $""[{className}] {{other.GameObject.Name}} picked up '{{ItemName}}'."" );",
"custom" => $@"
// TODO: your custom effect here.
Log.Info( $""[{className}] {{other.GameObject.Name}} collected a pickup."" );",
_ => $@"
// Score / objective progress. Wire OnCollected to ObjectiveManager.RegisterPickup
// (the scaffold does this), or call it directly if you have the type:
// ObjectiveManager.Instance?.RegisterPickup();
Log.Info( $""[{className}] {{other.GameObject.Name}} collected (+{{Amount}})."" );"
};
return $@"using Sandbox;
using System;
/// <summary>
/// {className} — a trigger-based collectible. Put it on a GameObject that has a
/// trigger Collider (e.g. a SphereCollider with IsTrigger=true). When a
/// GameObject tagged '{filterTag}' enters, it applies an effect and despawns.
///
/// Action: {action}. OnCollected fires for any listener (the scaffold wires it
/// to your objective/score system) — keeps {className} dependency-free.
/// </summary>
public sealed class {className} : Component, Component.ITriggerListener
{{
[Property] public string FilterTag {{ get; set; }} = ""{filterTag}"";
[Property] public float Amount {{ get; set; }} = {amt};
[Property] public bool DestroyOnPickup {{ get; set; }} = true;{itemField}
/// <summary>Raised when a matching object collects this. Hook score/objective here.</summary>
public Action<GameObject> OnCollected {{ get; set; }}
protected override void OnStart()
{{
// Ensure our collider is a trigger so OnTriggerEnter fires.
var collider = GetComponent<Collider>();
if ( collider != null ) collider.IsTrigger = true;
}}
public void OnTriggerEnter( Collider other )
{{
if ( other?.GameObject == null ) return;
if ( !other.GameObject.Tags.Has( FilterTag ) ) return;
{effect}
OnCollected?.Invoke( other.GameObject );
if ( DestroyOnPickup )
GameObject.Destroy();
}}
public void OnTriggerExit( Collider other ) {{ }}
}}
";
}
}
// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
// H. create_economy_wallet — a host-authoritative currency component.
// The #1 economy exploit is plain [Sync] money a client can author, so Money
// here is [Sync(SyncFlags.FromHost)] (the Ten Laws of the cookbook). Add /
// TrySpend / SetMoney / CanAfford + an OnMoneyChanged event. Mirrors the
// CreateHealthSystem scaffold pattern. (Mined from 51 games — currency was the
// most-requested scaffold with no existing tool; pairs with create_save_system
// [v1.11.0] for persistence.)
// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
public class CreateEconomyWalletHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "Wallet", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
long start = p.TryGetProperty( "startingMoney", out var smv ) && smv.TryGetInt64( out var sm ) ? sm : 0L;
var code = BuildCode( className, start );
ScaffoldHelpers.WriteCode( fullPath, code );
// Optional placement on an existing GameObject (only if the type is already
// in the TypeLibrary, i.e. after a hotload — same contract as create_health_system).
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new { created = true, path = relPath, className, placedOn, note } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_economy_wallet failed: {ex.Message}" } );
}
}
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; }
}
static string BuildCode( string className, long startingMoney )
{
return $@"using Sandbox;
using System;
/// <summary>
/// {className} — a host-authoritative currency wallet for any GameObject.
///
/// Money is [Sync(SyncFlags.FromHost)] so only the host writes it — a client can't
/// author their own balance (plain [Sync] money is the classic economy exploit).
/// Clients that want to spend should call a [Rpc.Host] on their own component that
/// re-validates and calls TrySpend host-side. Single-player safe (IsProxy is false
/// with no networking active).
///
/// Usage:
/// GetComponent<{className}>()?.AddMoney( 100 );
/// if ( GetComponent<{className}>().TrySpend( 50 ) ) {{ /* grant the thing */ }}
/// bool ok = GetComponent<{className}>().CanAfford( 50 );
/// Hook OnMoneyChanged to drive a HUD label.
/// </summary>
public sealed class {className} : Component
{{
[Property] public long StartingMoney {{ get; set; }} = {startingMoney}L;
// Host-authoritative balance.
[Sync( SyncFlags.FromHost )] public long Money {{ get; set; }}
// Fired (on the writing machine) whenever the balance changes — bind a HUD here.
public Action<long> OnMoneyChanged {{ get; set; }}
protected override void OnStart()
{{
if ( IsProxy ) return; // only the authority seeds the balance
Money = StartingMoney;
OnMoneyChanged?.Invoke( Money );
}}
/// <summary>Add money (host-authoritative). Non-positive amounts are ignored.</summary>
public void AddMoney( long amount )
{{
if ( IsProxy || amount <= 0 ) return;
Money += amount;
Changed();
}}
/// <summary>Spend if affordable; returns false and changes nothing if not.</summary>
public bool TrySpend( long amount )
{{
if ( IsProxy || amount <= 0 ) return false;
if ( Money < amount ) return false;
Money -= amount;
Changed();
return true;
}}
/// <summary>Set the balance directly (host-authoritative), clamped to >= 0.</summary>
public void SetMoney( long amount )
{{
if ( IsProxy ) return;
Money = amount < 0 ? 0 : amount;
Changed();
}}
public bool CanAfford( long amount ) => Money >= amount;
private void Changed()
{{
if ( Money < 0 ) Money = 0;
OnMoneyChanged?.Invoke( Money );
}}
}}
";
}
}
// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
// I. create_round_phase_machine — a host-authoritative round/phase machine.
// [Sync(FromHost)] CurrentPhase cycled on a per-phase timer; a static
// OnPhaseChanged event fires on every machine. The easy single-component
// variant of the most-requested mined scaffold (round/match flow, day-night
// cycles, match phases). Mined from despawn.murder / suspectra / minigolf / etc.
// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
public class CreateRoundPhaseMachineHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "GameDirector", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
var phases = new System.Collections.Generic.List<string>();
if ( p.TryGetProperty( "phases", out var ph ) && ph.ValueKind == JsonValueKind.Array )
{
foreach ( var e in ph.EnumerateArray() )
{
var s = e.ValueKind == JsonValueKind.String ? e.GetString() : null;
if ( string.IsNullOrWhiteSpace( s ) ) continue;
var id = ClaudeBridge.SanitizeIdentifier( s );
if ( !string.IsNullOrEmpty( id ) && !phases.Contains( id ) ) phases.Add( id );
}
}
if ( phases.Count == 0 ) { phases.Add( "Lobby" ); phases.Add( "Active" ); phases.Add( "Ended" ); }
float dur = p.TryGetProperty( "duration", out var dv ) && dv.TryGetSingle( out var df ) ? df : 60f;
bool loop = !( p.TryGetProperty( "loop", out var lv ) && lv.ValueKind == JsonValueKind.False );
var code = BuildCode( className, phases, dur, loop );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new { created = true, path = relPath, className, phases = phases.ToArray(), placedOn, note } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_round_phase_machine failed: {ex.Message}" } );
}
}
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; }
}
static string BuildCode( string className, System.Collections.Generic.List<string> phases, float dur, bool loop )
{
string d = dur.ToString( System.Globalization.CultureInfo.InvariantCulture ) + "f";
string enumBody = string.Join( ", ", phases );
string firstPhase = phases[0];
string durationProps = "";
foreach ( var ph in phases )
durationProps += $"\n\t[Property] public float {ph}Duration {{ get; set; }} = {d};";
string durationSwitch = "";
foreach ( var ph in phases )
durationSwitch += $"\n\t\t\tPhase.{ph} => {ph}Duration,";
string nextSwitch = "";
for ( int i = 0; i < phases.Count; i++ )
{
string nxt = ( i + 1 < phases.Count ) ? phases[i + 1] : ( loop ? phases[0] : phases[phases.Count - 1] );
nextSwitch += $"\n\t\t\tPhase.{phases[i]} => Phase.{nxt},";
}
return $@"using Sandbox;
using System;
/// <summary>
/// {className} — a host-authoritative round / phase machine for any GameObject.
///
/// Cycles a [Sync(SyncFlags.FromHost)] CurrentPhase through your named phases on a
/// per-phase timer (host-only). Other systems react via the static OnPhaseChanged
/// event, which fires on EVERY machine when the phase replicates. Single-player safe.
///
/// Usage:
/// {className}.OnPhaseChanged += p => Log.Info( $""phase -> {{p}}"" );
/// GetComponent<{className}>()?.StartPhase( {className}.Phase.{firstPhase} ); // host-only jump
/// </summary>
public sealed class {className} : Component
{{
public enum Phase {{ {enumBody} }}
// Host-authoritative current phase + its countdown.
[Sync( SyncFlags.FromHost )] public Phase CurrentPhase {{ get; set; }}
[Sync( SyncFlags.FromHost )] public TimeUntil PhaseTimer {{ get; set; }}
// Per-phase durations in seconds — tune in the inspector.{durationProps}
[Property] public bool Loop {{ get; set; }} = {(loop ? "true" : "false")};
// Fires on every machine when the phase changes (host writes it, all detect it). Hook game systems here.
public static Action<Phase> OnPhaseChanged {{ get; set; }}
private Phase _lastSeen;
private bool _started;
protected override void OnStart()
{{
if ( !IsProxy ) StartPhase( default ); // 'default' = the first phase
}}
protected override void OnUpdate()
{{
// Change-detect so OnPhaseChanged fires uniformly on host + proxies.
if ( !_started || CurrentPhase != _lastSeen )
{{
_started = true;
_lastSeen = CurrentPhase;
OnPhaseChanged?.Invoke( CurrentPhase );
}}
if ( IsProxy ) return;
if ( PhaseTimer <= 0f ) Advance();
}}
/// <summary>Host-only: jump to a phase and arm its timer.</summary>
public void StartPhase( Phase phase )
{{
if ( IsProxy ) return;
CurrentPhase = phase;
PhaseTimer = DurationFor( phase );
}}
private void Advance()
{{
if ( IsProxy ) return;
var next = NextPhase( CurrentPhase );
if ( !Loop && next == CurrentPhase ) return; // not looping: hold on the last phase
CurrentPhase = next;
PhaseTimer = DurationFor( CurrentPhase );
}}
private float DurationFor( Phase p ) => p switch
{{{durationSwitch}
_ => {d}
}};
private Phase NextPhase( Phase p ) => p switch
{{{nextSwitch}
_ => p
}};
}}
";
}
}
// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
// J. create_day_night_clock — a host-authoritative time-of-day clock.
// [Sync(FromHost)] TimeOfDay (0..24) + Day advancing by Time.Delta; IsDay/
// IsNight + static OnNewDay / OnDayNightChanged events. Pairs with
// create_round_phase_machine for continuous time (lighting, NPC schedules).
// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
public class CreateDayNightClockHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "DayNightClock", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
float dayLen = p.TryGetProperty( "dayLengthSeconds", out var dl ) && dl.TryGetSingle( out var dlf ) ? dlf : 600f;
float startHr = p.TryGetProperty( "startHour", out var sh ) && sh.TryGetSingle( out var shf ) ? shf : 8f;
float sunrise = p.TryGetProperty( "sunriseHour", out var sr ) && sr.TryGetSingle( out var srf ) ? srf : 6f;
float sunset = p.TryGetProperty( "sunsetHour", out var ss ) && ss.TryGetSingle( out var ssf ) ? ssf : 20f;
var code = BuildCode( className, dayLen, startHr, sunrise, sunset );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new { created = true, path = relPath, className, placedOn, note } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_day_night_clock failed: {ex.Message}" } );
}
}
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; }
}
static string BuildCode( string className, float dayLen, float startHr, float sunrise, float sunset )
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
string dl = dayLen.ToString( ci ) + "f";
string sh = startHr.ToString( ci ) + "f";
string sr = sunrise.ToString( ci ) + "f";
string ssH = sunset.ToString( ci ) + "f";
return $@"using Sandbox;
using System;
/// <summary>
/// {className} — a host-authoritative time-of-day clock for any GameObject.
///
/// TimeOfDay (0..24) and Day advance on the host by Time.Delta; both are
/// [Sync(SyncFlags.FromHost)] so clients agree. IsDay/IsNight derive from the
/// sunrise/sunset hours. Subscribe to the static events to drive lighting, NPC
/// schedules, spawns, etc. Single-player safe.
///
/// Usage:
/// {className}.OnNewDay += day => Log.Info( $""day {{day}}"" );
/// {className}.OnDayNightChanged += isDay => SetLights( isDay );
/// </summary>
public sealed class {className} : Component
{{
[Property] public float DayLengthSeconds {{ get; set; }} = {dl}; // real seconds per in-game day
[Property] public float StartHour {{ get; set; }} = {sh};
[Property] public float SunriseHour {{ get; set; }} = {sr};
[Property] public float SunsetHour {{ get; set; }} = {ssH};
[Sync( SyncFlags.FromHost )] public float TimeOfDay {{ get; set; }} // 0..24
[Sync( SyncFlags.FromHost )] public int Day {{ get; set; }} = 1;
public bool IsNight => TimeOfDay < SunriseHour || TimeOfDay >= SunsetHour;
public bool IsDay => !IsNight;
/// <summary>Fires (every machine) when the day number increments.</summary>
public static Action<int> OnNewDay {{ get; set; }}
/// <summary>Fires (every machine) when crossing sunrise/sunset. Arg = isDay.</summary>
public static Action<bool> OnDayNightChanged {{ get; set; }}
private bool _started;
private int _lastDay;
private bool _lastIsNight;
protected override void OnStart()
{{
if ( !IsProxy ) {{ TimeOfDay = MathX.Clamp( StartHour, 0f, 24f ); Day = 1; }}
}}
protected override void OnUpdate()
{{
if ( !IsProxy )
{{
// 24 in-game hours elapse per DayLengthSeconds.
TimeOfDay += ( 24f / MathX.Clamp( DayLengthSeconds, 1f, 86400f ) ) * Time.Delta;
while ( TimeOfDay >= 24f ) {{ TimeOfDay -= 24f; Day++; }}
}}
// Change-detect so the events fire uniformly on host + proxies.
if ( !_started ) {{ _started = true; _lastDay = Day; _lastIsNight = IsNight; }}
if ( Day != _lastDay ) {{ _lastDay = Day; OnNewDay?.Invoke( Day ); }}
bool night = IsNight;
if ( night != _lastIsNight ) {{ _lastIsNight = night; OnDayNightChanged?.Invoke( !night ); }}
}}
}}
";
}
}
// =============================================================================
// K. create_interactable -- a Component.IPressable that any player controller's
// "use" key drives. Emits a static OnPressed event plus a private OnPress()
// hook. Optional cooldown (TimeUntil) + targetId placement.
// Mined from interaction patterns across shipped s&box games.
// =============================================================================
public class CreateInteractableHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "Interactable", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
var prompt = p.TryGetProperty( "prompt", out var pr ) && !string.IsNullOrWhiteSpace( pr.GetString() ) ? pr.GetString() : "Press";
float cd = p.TryGetProperty( "cooldownSeconds", out var cdv ) && cdv.TryGetSingle( out var cdf ) ? cdf : 0f;
var code = BuildCode( className, prompt, cd );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new { created = true, path = relPath, className, placedOn, note } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_interactable failed: {ex.Message}" } );
}
}
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; }
}
static string BuildCode( string className, string prompt, float cooldown )
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
string cd = cooldown.ToString( ci ) + "f";
return $@"using Sandbox;
using System;
/// <summary>
/// {className} -- an interactable object driven by Component.IPressable.
///
/// Any player controller that implements the s&box ""use"" interaction (the built-in
/// PlayerController drives IPressable on the object the player is looking at) will
/// automatically call Press(), Hover() and Blur() -- no custom player code needed.
///
/// Subscribe to the static OnPressed event or add logic to the private OnPress()
/// method to react to a press. For host-authoritative effects (damage, spawning,
/// score changes), call an [Rpc.Host] method from inside OnPress() so only the
/// host applies the change.
///
/// The UI prompt/tooltip is left to your game's HUD -- read the Prompt property
/// and display it when the player looks at the object (e.g. via a WorldPanel).
/// </summary>
public sealed class {className} : Component, Component.IPressable
{{
[Property] public string Prompt {{ get; set; }} = ""{prompt}"";
[Property] public float CooldownSeconds {{ get; set; }} = {cd};
/// <summary>Fires (on the machine that pressed) after a successful press.</summary>
public static Action<GameObject, {className}> OnPressed {{ get; set; }}
private TimeUntil _cooldownDone;
public bool CanPress( Component.IPressable.Event e ) => CooldownSeconds <= 0f || _cooldownDone;
public bool Press( Component.IPressable.Event e )
{{
if ( !CanPress( e ) ) return false;
_cooldownDone = CooldownSeconds;
OnPressed?.Invoke( GameObject, this );
OnPress( e );
return true;
}}
public void Hover( Component.IPressable.Event e ) {{ }}
public void Blur( Component.IPressable.Event e ) {{ }}
/// <summary>
/// Called after a successful press on the pressing machine. Add your
/// behaviour here. For host-authoritative side-effects call an [Rpc.Host]
/// method from this body so only the host applies the change.
/// </summary>
private void OnPress( Component.IPressable.Event e ) {{ }}
}}
";
}
}
// =============================================================================
// L. create_weighted_loot_table -- cumulative-weight random loot picker.
// Parallel Name/Weight lists (inspector-editable), Roll() returning the
// winning entry name + firing a static OnLoot event. Optional pity system
// (guarantee the last/rarest entry after PityAfter consecutive non-rare
// rolls). Roll() is host-authoritative -- replicate the result, never let
// clients roll their own loot.
// Mined from pickup/reward patterns across shipped s&box games.
// =============================================================================
public class CreateWeightedLootTableHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "LootTable", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
// Parse entries: array of {name,weight} OR "name:weight,name:weight" string.
var entries = ParseEntries( p );
bool pity = p.TryGetProperty( "pity", out var pv ) && pv.ValueKind == JsonValueKind.True;
var code = BuildCode( className, entries, pity );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new { created = true, path = relPath, className, entries = entries.Count, pity, placedOn, note } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_weighted_loot_table failed: {ex.Message}" } );
}
}
static List<(string Name, float Weight)> ParseEntries( JsonElement p )
{
var result = new List<(string, float)>();
if ( p.TryGetProperty( "entries", out var ev ) )
{
if ( ev.ValueKind == JsonValueKind.Array )
{
foreach ( var item in ev.EnumerateArray() )
{
var n = item.TryGetProperty( "name", out var nv ) ? nv.GetString() : "item";
var w = item.TryGetProperty( "weight", out var wv ) && wv.TryGetSingle( out var wf ) ? wf : 1f;
result.Add( (n, w) );
}
}
else if ( ev.ValueKind == JsonValueKind.String )
{
foreach ( var part in ev.GetString().Split( ',' ) )
{
var kv = part.Split( ':' );
if ( kv.Length == 2 && float.TryParse( kv[1].Trim(),
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var wf ) )
result.Add( (kv[0].Trim(), wf) );
}
}
}
if ( result.Count == 0 )
{
result.Add( ("common", 70f) );
result.Add( ("uncommon", 25f) );
result.Add( ("rare", 5f) );
}
return result;
}
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; }
}
static string BuildCode( string className, List<(string Name, float Weight)> entries, bool pity )
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
// Build inspector-default array initialisers.
var nameLiterals = string.Join( ", ", entries.Select( e => $"\"{e.Name}\"" ) );
var weightLiterals = string.Join( ", ", entries.Select( e => e.Weight.ToString( ci ) + "f" ) );
int lastIdx = entries.Count - 1;
string pityBlock = pity ? $@"
// Pity: guarantee the last (rarest) entry after PityAfter consecutive non-rare rolls.
[Property] public int PityAfter {{ get; set; }} = 10;
private int _consecutiveNonRare;" : "";
string pityRoll = pity ? $@"
// Pity check before the weighted roll.
if ( _consecutiveNonRare >= PityAfter ) {{ _consecutiveNonRare = 0; return Names[{lastIdx}]; }}" : "";
string pityTrack = pity ? $@"
if ( winner == Names[{lastIdx}] ) _consecutiveNonRare = 0;
else _consecutiveNonRare++;" : "";
return $@"using Sandbox;
using System;
using System.Collections.Generic;
/// <summary>
/// {className} -- a cumulative-weight random loot picker.
///
/// Parallel Names / Weights lists are inspector-editable so designers can tune
/// drop rates without touching code. Roll() is HOST-AUTHORITATIVE -- call it
/// only on the host and replicate the result. Clients rolling their own loot is
/// the same exploit as clients writing their own money balance.
///
/// Usage:
/// string drop = GetComponent<{className}>().Roll();
/// {className}.OnLoot += (go, drop) => Log.Info($""{{go.Name}} got {{drop}}"");
/// </summary>
public sealed class {className} : Component
{{
[Property] public List<string> Names {{ get; set; }} = new List<string> {{ {nameLiterals} }};
[Property] public List<float> Weights {{ get; set; }} = new List<float> {{ {weightLiterals} }};
{pityBlock}
/// Fires (on the rolling machine) when Roll() picks a winner.
public static Action<GameObject, string> OnLoot {{ get; set; }}
/// <summary>
/// Pick one entry by cumulative weight. HOST-AUTHORITATIVE: only the host
/// should call this; replicate the result string to clients via [Sync] or
/// an [Rpc.Broadcast] so all machines show the same drop.
/// Returns null when the table is empty or all weights are zero.
/// </summary>
public string Roll()
{{
if ( Names == null || Weights == null || Names.Count == 0 ) return null;
int count = Math.Min( Names.Count, Weights.Count );
if ( count == 0 ) return null;
{pityRoll}
float total = 0f;
for ( int i = 0; i < count; i++ ) total += Weights[i];
if ( total <= 0f ) return Names[0];
float roll = Game.Random.Float( 0f, total );
float cumulative = 0f;
string winner = Names[count - 1]; // fallback
for ( int i = 0; i < count; i++ )
{{
cumulative += Weights[i];
if ( roll < cumulative ) {{ winner = Names[i]; break; }}
}}
{pityTrack}
OnLoot?.Invoke( GameObject, winner );
return winner;
}}
}}
";
}
}
// =============================================================================
// M. create_save_system -- versioned POCO + dirty-flag autosave + clamp-on-load
// + delete-on-version-mismatch, host/owner-only, per the save-persistence
// cookbook recipe. FileSystem.Data.ReadJsonOrDefault<T>/WriteJson<T>
// verified live on this SDK.
// =============================================================================
public class CreateSaveSystemHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "SaveSystem", 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.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 autoStr = autosave.ToString( ci ) + "f";
string verStr = version.ToString();
var code = BuildCode( className, fileName, verStr, autoStr );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new { created = true, path = relPath, className, placedOn, note } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_save_system failed: {ex.Message}" } );
}
}
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; }
}
static string BuildCode( string className, string fileName, string version, string autosave )
{
return $@"using Sandbox;
using System;
/// <summary>
/// {className} -- versioned POCO + dirty-flag autosave + clamp-on-load +
/// delete-on-version-mismatch, host/owner-only, per the save-persistence
/// cookbook recipe. FileSystem.Data.ReadJsonOrDefault/WriteJson verified live.
///
/// Add your own fields to the SaveData inner class. Bump Version when the
/// shape changes so old saves start fresh instead of crashing.
/// Wire OnLoaded to rebuild runtime state after a load (e.g. restore money,
/// rebuild inventory). Wire OnSaved for analytics/cloud-sync hooks.
/// </summary>
public sealed class {className} : Component
{{
[Property] public string FileName {{ get; set; }} = ""{fileName}"";
[Property] public float AutosaveSeconds {{ get; set; }} = {autosave};
/// The save payload. Add your own fields here, bump Version when the shape changes.
public class SaveData
{{
public int Version {{ get; set; }} = {version};
public int Money {{ get; set; }}
public int Day {{ get; set; }}
// Add game fields here.
}}
public SaveData Data {{ get; private set; }} = new SaveData();
public bool IsDirty {{ get; private set; }}
/// Fires after a successful Load() with the loaded data.
public static Action<SaveData> OnLoaded {{ get; set; }}
/// Fires after every Save().
public static Action<SaveData> OnSaved {{ 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();
}}
}}
/// Mark the data changed so the next autosave tick writes it.
public void MarkDirty() => IsDirty = true;
public void Load()
{{
var loaded = FileSystem.Data.ReadJsonOrDefault<SaveData>( FileName, null );
if ( loaded == null || loaded.Version != {version} )
{{
// Missing, corrupt, or old-version save: start fresh (add migrations here later).
Data = new SaveData();
IsDirty = true;
}}
else
{{
Data = Sanitize( loaded );
IsDirty = false;
}}
OnLoaded?.Invoke( Data );
}}
public void Save()
{{
FileSystem.Data.WriteJson( FileName, Data );
IsDirty = false;
OnSaved?.Invoke( Data );
}}
/// Clamp-on-load: keep loaded values inside sane ranges so a hand-edited or
/// corrupt save cannot break the game. Extend per field.
private SaveData Sanitize( SaveData d )
{{
if ( d.Money < 0 ) d.Money = 0;
if ( d.Day < 1 ) d.Day = 1;
return d;
}}
protected override void OnDestroy()
{{
if ( !IsProxy && IsDirty ) Save();
}}
}}
";
}
}
// =============================================================================
// N. create_leaderboard_panel -- Razor PanelComponent that fetches and displays
// a Sandbox.Services leaderboard derived from a stat name. Uses the exact
// Leaderboards.Get() / board.Refresh(CancellationToken) API verified live via describe_type.
// Generates TWO files: {name}.razor + {name}.razor.scss.
// RAZOR-LINT CLEAN: BuildHash override included, no switch expressions in
// @code, ASCII-only, class selectors only in SCSS.
// =============================================================================
public class CreateLeaderboardPanelHandler : IBridgeHandler
{
public async Task<object> Execute( JsonElement p )
{
try
{
// Parse params
var name = p.TryGetProperty( "name", out var n ) && !string.IsNullOrWhiteSpace( n.GetString() ) ? n.GetString() : "LeaderboardPanel";
var directory = p.TryGetProperty( "directory", out var d ) && !string.IsNullOrWhiteSpace( d.GetString() ) ? d.GetString() : "Code/UI";
var statName = p.TryGetProperty( "statName", out var sn ) && !string.IsNullOrWhiteSpace( sn.GetString() ) ? sn.GetString() : "score";
var title = p.TryGetProperty( "title", out var t ) && !string.IsNullOrWhiteSpace( t.GetString() ) ? t.GetString() : "Leaderboard";
int maxRows = p.TryGetProperty( "maxRows", out var mr ) && mr.TryGetInt32( out var mri ) ? mri : 10;
var className = ClaudeBridge.SanitizeIdentifier( name.EndsWith( ".razor" ) ? Path.GetFileNameWithoutExtension( name ) : name );
var razorFile = className + ".razor";
var scssFile = className + ".razor.scss";
// Resolve both paths
if ( !ClaudeBridge.TryResolveProjectPath( Path.Combine( directory, razorFile ), out var razorPath, out var razorErr ) )
return new { error = razorErr };
if ( !ClaudeBridge.TryResolveProjectPath( Path.Combine( directory, scssFile ), out var scssPath, out var scssErr ) )
return new { error = scssErr };
if ( File.Exists( razorPath ) )
return new { error = $"File already exists: {directory}/{razorFile}. Choose a different name." };
Directory.CreateDirectory( Path.GetDirectoryName( razorPath ) );
var ci = System.Globalization.CultureInfo.InvariantCulture;
string maxStr = maxRows.ToString( ci );
string razor = BuildRazor( className, statName, title, maxStr );
string scss = BuildScss();
ScaffoldHelpers.WriteCode( razorPath, razor );
ScaffoldHelpers.WriteCode( scssPath, scss );
var rootPath = Project.Current?.GetRootPath() ?? "";
var relRazor = Path.GetRelativePath( rootPath, razorPath ).Replace( '\\', '/' );
var relScss = Path.GetRelativePath( rootPath, scssPath ).Replace( '\\', '/' );
string note = "Panel renders only under a ScreenPanel/WorldPanel host -- use add_screen_panel. " +
"Stats need Sandbox.Services configured for the project ident.";
return new { created = true, razorPath = relRazor, scssPath = relScss, className, note };
}
catch ( Exception ex )
{
return new { error = $"create_leaderboard_panel failed: {ex.Message}" };
}
}
static string BuildRazor( string className, string statName, string title, string maxRows )
{
// NOTE: all {{ }} inside the $@"" are C# string-escape doubled braces.
// The generated .razor uses single { } for Razor/C# expressions.
return $@"@using Sandbox;
@using Sandbox.UI;
@using System.Collections.Generic;
@inherits PanelComponent;
@* {className} -- leaderboard panel backed by Sandbox.Services.
Host it under a ScreenPanel or WorldPanel component.
Stats must be configured for the project ident on sbox.game.
Call RefreshAsync() manually or let the 30-second auto-refresh tick. *@
@if ( _loading )
{{
<div class=""leaderboard"">
<div class=""title"">@Title</div>
<div class=""row""><span class=""name"">Loading...</span></div>
</div>
}}
else
{{
<div class=""leaderboard"">
<div class=""title"">@Title</div>
@foreach ( var row in _rows )
{{
<div class=""row"">
<span class=""rank"">#@row.Rank</span>
<span class=""name"">@row.DisplayName</span>
<span class=""value"">@row.Value</span>
</div>
}}
@if ( _rows.Count == 0 )
{{
<div class=""row""><span class=""name"">No entries yet.</span></div>
}}
</div>
}}
@code {{
[Property] public string StatName {{ get; set; }} = ""{statName}"";
[Property] public string Title {{ get; set; }} = ""{title}"";
[Property] public int MaxRows {{ get; set; }} = {maxRows};
private struct LeaderboardRow {{ public int Rank; public string DisplayName; public long Value; }}
private List<LeaderboardRow> _rows = new List<LeaderboardRow>();
private bool _loading = false;
private RealTimeSince _lastFetch;
private bool _fetchedOnce = false;
protected override void OnUpdate()
{{
// Auto-refresh at most every 30 seconds; also fetch on first tick.
if ( !_fetchedOnce || _lastFetch > 30f )
{{
_fetchedOnce = true;
_ = RefreshAsync();
}}
}}
public async System.Threading.Tasks.Task RefreshAsync()
{{
if ( _loading ) return;
_loading = true;
_lastFetch = 0f;
StateHasChanged();
try
{{
var board = Sandbox.Services.Leaderboards.Get( StatName );
board.MaxEntries = MaxRows;
await board.Refresh( default );
var newRows = new List<LeaderboardRow>();
if ( board.Entries != null )
{{
foreach ( var e in board.Entries )
{{
newRows.Add( new LeaderboardRow
{{
Rank = (int) e.Rank,
DisplayName = e.DisplayName,
Value = (long)e.Value
}} );
}}
}}
_rows = newRows;
}}
catch ( System.Exception ) {{ /* Services offline or project not configured -- show empty */ }}
finally
{{
_loading = false;
StateHasChanged();
}}
}}
protected override int BuildHash() => System.HashCode.Combine( _rows.Count, _loading );
}}
";
}
static string BuildScss()
{
return @".leaderboard {
display: flex;
flex-direction: column;
background-color: rgba(0,0,0,0.7);
border-radius: 4px;
padding: 8px;
min-width: 260px;
}
.title {
font-size: 18px;
font-weight: bold;
color: white;
text-align: center;
margin-bottom: 6px;
}
.row {
display: flex;
flex-direction: row;
padding: 3px 4px;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.rank {
color: rgba(255,255,255,0.5);
min-width: 36px;
}
.name {
flex-grow: 1;
color: white;
}
.value {
color: #ffe080;
min-width: 60px;
text-align: right;
}
";
}
}
// =============================================================================
// O. create_inventory -- parallel List<string>/List<int> slot-based inventory
// component. Stack-first TryAdd, TryRemove, CountOf, Move (swap/merge),
// Clear. Static OnChanged event. Host-authoritative usage note.
// Mined from 12+ shipped s&box game inventory patterns.
// =============================================================================
public class CreateInventoryHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "Inventory", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
var ci = System.Globalization.CultureInfo.InvariantCulture;
int capacity = p.TryGetProperty( "capacity", out var cv ) && cv.TryGetInt32( out var ci2 ) ? ci2 : 24;
int maxStack = p.TryGetProperty( "maxStack", out var ms ) && ms.TryGetInt32( out var msi ) ? msi : 99;
string capStr = capacity.ToString( ci );
string msStr = maxStack.ToString( ci );
var code = BuildCode( className, capStr, msStr );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new { created = true, path = relPath, className, placedOn, note } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_inventory failed: {ex.Message}" } );
}
}
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; }
}
static string BuildCode( string className, string capacity, string maxStack )
{
return $@"using Sandbox;
using System;
using System.Collections.Generic;
/// <summary>
/// {className} -- parallel List slot inventory: ItemIds[i] / Counts[i] per slot.
///
/// Host-authoritative usage: in multiplayer, mutate only on the host (guard with
/// IsProxy or [Rpc.Host]) and replicate via your own [Sync]/RPC or a NetList.
/// Pairs naturally with create_pickup (the pickup calls TryAdd on the host).
///
/// TryAdd: stack-first onto existing same-id slots up to MaxStack, then first
/// empty slot. Partial adds are NOT supported -- returns false if not ALL count
/// fits; caller should retry with a smaller amount or reject the pickup.
///
/// OnChanged fires after every successful mutation with the owning GameObject.
/// </summary>
public sealed class {className} : Component
{{
[Property] public int Capacity {{ get; set; }} = {capacity};
[Property] public int MaxStack {{ get; set; }} = {maxStack};
/// Parallel slot lists -- index i is one slot. """" means empty.
[Property] public List<string> ItemIds {{ get; set; }} = new List<string>();
[Property] public List<int> Counts {{ get; set; }} = new List<int>();
/// Fires after every successful mutation with the owning GameObject.
public static Action<GameObject> OnChanged {{ get; set; }}
protected override void OnStart()
{{
// Pre-size to capacity so inspector slots are always visible.
while ( ItemIds.Count < Capacity ) {{ ItemIds.Add( """" ); Counts.Add( 0 ); }}
}}
/// <summary>
/// Add count of item id. Stack-first onto existing same-id slots, then first
/// empty slot. Returns true only if ALL of count was added.
/// Partial adds are not supported -- call CountOf first to pre-check capacity.
/// </summary>
public bool TryAdd( string id, int count )
{{
if ( string.IsNullOrEmpty( id ) || count <= 0 ) return false;
EnsureSize();
int remaining = count;
// Pass 1: top up existing stacks of the same id.
for ( int i = 0; i < Capacity && remaining > 0; i++ )
{{
if ( ItemIds[i] == id )
{{
int space = MaxStack - Counts[i];
if ( space <= 0 ) continue;
int add = (remaining < space) ? remaining : space;
Counts[i] += add;
remaining -= add;
}}
}}
// Pass 2: fill empty slots.
for ( int i = 0; i < Capacity && remaining > 0; i++ )
{{
if ( string.IsNullOrEmpty( ItemIds[i] ) )
{{
int add = (remaining < MaxStack) ? remaining : MaxStack;
ItemIds[i] = id;
Counts[i] = add;
remaining -= add;
}}
}}
if ( remaining > 0 )
{{
// Could not fit all -- roll back the partial add.
int excess = count - remaining;
for ( int i = 0; i < Capacity && excess > 0; i++ )
{{
if ( ItemIds[i] == id )
{{
int remove = (Counts[i] < excess) ? Counts[i] : excess;
Counts[i] -= remove;
excess -= remove;
if ( Counts[i] == 0 ) ItemIds[i] = """";
}}
}}
return false;
}}
OnChanged?.Invoke( GameObject );
return true;
}}
/// <summary>Remove count of item id. Returns true if all were removed.</summary>
public bool TryRemove( string id, int count )
{{
if ( string.IsNullOrEmpty( id ) || count <= 0 ) return false;
if ( CountOf( id ) < count ) return false;
EnsureSize();
int remaining = count;
for ( int i = 0; i < Capacity && remaining > 0; i++ )
{{
if ( ItemIds[i] == id )
{{
int remove = (Counts[i] < remaining) ? Counts[i] : remaining;
Counts[i] -= remove;
remaining -= remove;
if ( Counts[i] == 0 ) ItemIds[i] = """";
}}
}}
OnChanged?.Invoke( GameObject );
return true;
}}
/// <summary>Total count of a given item across all slots.</summary>
public int CountOf( string id )
{{
if ( string.IsNullOrEmpty( id ) ) return 0;
EnsureSize();
int total = 0;
for ( int i = 0; i < Capacity; i++ )
if ( ItemIds[i] == id ) total += Counts[i];
return total;
}}
/// <summary>
/// Move (swap or merge) two slots by index. If both slots hold the same id
/// the counts are merged into the target up to MaxStack, remainder stays in
/// source. If different ids the entire slots are swapped.
/// </summary>
public bool Move( int from, int to )
{{
EnsureSize();
if ( from < 0 || from >= Capacity || to < 0 || to >= Capacity || from == to ) return false;
if ( ItemIds[from] == ItemIds[to] && !string.IsNullOrEmpty( ItemIds[from] ) )
{{
// Merge same-id stacks.
int space = MaxStack - Counts[to];
int move = (Counts[from] < space) ? Counts[from] : space;
Counts[to] += move;
Counts[from] -= move;
if ( Counts[from] == 0 ) ItemIds[from] = """";
}}
else
{{
// Swap different slots.
string tmpId = ItemIds[from]; ItemIds[from] = ItemIds[to]; ItemIds[to] = tmpId;
int tmpCnt = Counts[from]; Counts[from] = Counts[to]; Counts[to] = tmpCnt;
}}
OnChanged?.Invoke( GameObject );
return true;
}}
/// <summary>Empty all slots.</summary>
public void Clear()
{{
EnsureSize();
for ( int i = 0; i < Capacity; i++ ) {{ ItemIds[i] = """"; Counts[i] = 0; }}
OnChanged?.Invoke( GameObject );
}}
private void EnsureSize()
{{
while ( ItemIds.Count < Capacity ) {{ ItemIds.Add( """" ); Counts.Add( 0 ); }}
while ( Counts.Count < Capacity ) Counts.Add( 0 );
}}
}}
";
}
}
// =============================================================================
// P. create_stat_modifier_system -- enum-keyed stats with layered modifiers:
// SET (highest-priority overrides base), ADD (sum), MULT (product).
// Modifier storage as parallel private Lists of simple types to stay
// serialization-safe. Static OnStatChanged event.
// Mined from buff/debuff and RPG stat patterns across shipped s&box games.
// =============================================================================
public class CreateStatModifierSystemHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "StatSystem", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
// Parse stat names: accept a JSON array OR a comma-separated string.
var statNames = new List<string>();
if ( p.TryGetProperty( "stats", out var statsEl ) )
{
if ( statsEl.ValueKind == JsonValueKind.Array )
{
foreach ( var el in statsEl.EnumerateArray() )
{
var s = el.GetString();
if ( !string.IsNullOrWhiteSpace( s ) ) statNames.Add( ClaudeBridge.SanitizeIdentifier( s ) );
}
}
else if ( statsEl.ValueKind == JsonValueKind.String )
{
foreach ( var s in statsEl.GetString().Split( ',' ) )
{
var trimmed = s.Trim();
if ( !string.IsNullOrWhiteSpace( trimmed ) ) statNames.Add( ClaudeBridge.SanitizeIdentifier( trimmed ) );
}
}
}
if ( statNames.Count == 0 ) statNames = new List<string> { "Health", "Speed", "Damage" };
var code = BuildCode( className, statNames );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new { created = true, path = relPath, className, stats = statNames, placedOn, note } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_stat_modifier_system failed: {ex.Message}" } );
}
}
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; }
}
static string BuildCode( string className, List<string> statNames )
{
var enumEntries = string.Join( ",\n\t", statNames );
int count = statNames.Count;
var ci = System.Globalization.CultureInfo.InvariantCulture;
// Build the default BaseValues initialiser: 100f for all stats.
var defaults = string.Join( ", ", System.Linq.Enumerable.Repeat( "100f", count ) );
string enumName = className + "Stat";
return $@"using Sandbox;
using System;
using System.Collections.Generic;
/// <summary>
/// {enumName} -- the stats this system tracks.
/// Add or remove values here, then regenerate BaseValues defaults in {className}.
/// </summary>
public enum {enumName}
{{
{enumEntries}
}}
/// <summary>
/// {className} -- layered stat modifier system.
///
/// Each stat starts at its BaseValues entry. Modifiers are applied in three layers:
/// SET (ModifierType.Set, priority-highest-wins): hard-overrides the base.
/// ADD (ModifierType.Add): all additive bonuses summed on top of the resolved base.
/// MULT (ModifierType.Mult): all multiplicative factors applied last (product of all).
///
/// If any SET modifier is active the highest-priority SET value replaces the base
/// before the ADD and MULT passes (conventional ss1/ss2 stat convention).
///
/// Modifiers are keyed by an arbitrary object source (e.g. the Component that
/// applied them). RemoveModifiersFrom(source) cleans up all mods from that source
/// when a buff/debuff expires or the applier is destroyed.
///
/// OnStatChanged fires (stat, newValue) after every Add or Remove.
/// </summary>
public sealed class {className} : Component
{{
/// Base (unmodified) value for each stat. Index matches {enumName} cast to int.
[Property] public List<float> BaseValues {{ get; set; }} = new List<float> {{ {defaults} }};
/// Modifier type constants.
public enum ModifierType {{ Set = 0, Add = 1, Mult = 2 }}
// Parallel private Lists keep modifier storage serialization-safe (no custom
// [Property] class needed). Each index i is one modifier entry.
private List<object> _modSources = new List<object>();
private List<int> _modStats = new List<int>(); // (int){enumName}
private List<int> _modTypes = new List<int>(); // (int)ModifierType
private List<float> _modValues = new List<float>();
private List<int> _modPriorities = new List<int>();
/// Fires after every successful AddModifier / RemoveModifiersFrom call.
public static Action<{enumName}, float> OnStatChanged {{ get; set; }}
/// Add a modifier. source is any object -- used as the removal key later.
public void AddModifier( object source, {enumName} stat, ModifierType type, float value, int priority = 0 )
{{
_modSources.Add( source );
_modStats.Add( (int)stat );
_modTypes.Add( (int)type );
_modValues.Add( value );
_modPriorities.Add( priority );
OnStatChanged?.Invoke( stat, GetStat( stat ) );
}}
/// Remove all modifiers whose source equals the given object (by reference).
public void RemoveModifiersFrom( object source )
{{
var affected = new List<{enumName}>();
for ( int i = _modSources.Count - 1; i >= 0; i-- )
{{
if ( ReferenceEquals( _modSources[i], source ) )
{{
affected.Add( ({enumName})_modStats[i] );
_modSources.RemoveAt( i );
_modStats.RemoveAt( i );
_modTypes.RemoveAt( i );
_modValues.RemoveAt( i );
_modPriorities.RemoveAt( i );
}}
}}
foreach ( var s in affected )
OnStatChanged?.Invoke( s, GetStat( s ) );
}}
/// <summary>
/// Resolve the final value for a stat:
/// 1. If any SET modifier exists, the one with the highest priority wins (ties:
/// last-added). That value replaces the base.
/// 2. All ADD modifiers are summed on top.
/// 3. All MULT modifiers are multiplied together and applied last.
/// </summary>
public float GetStat( {enumName} stat )
{{
int idx = (int)stat;
float b = (idx >= 0 && idx < BaseValues.Count) ? BaseValues[idx] : 0f;
// Pass 1: SET -- highest-priority override.
bool hasSet = false;
float setVal = 0f;
int setPrio = int.MinValue;
for ( int i = 0; i < _modStats.Count; i++ )
{{
if ( _modStats[i] != idx || _modTypes[i] != (int)ModifierType.Set ) continue;
if ( !hasSet || _modPriorities[i] > setPrio )
{{
hasSet = true;
setVal = _modValues[i];
setPrio = _modPriorities[i];
}}
}}
if ( hasSet ) b = setVal;
// Pass 2: ADD -- sum all additive mods.
float addSum = 0f;
for ( int i = 0; i < _modStats.Count; i++ )
if ( _modStats[i] == idx && _modTypes[i] == (int)ModifierType.Add )
addSum += _modValues[i];
b += addSum;
// Pass 3: MULT -- product of all multiplicative mods.
float multProd = 1f;
for ( int i = 0; i < _modStats.Count; i++ )
if ( _modStats[i] == idx && _modTypes[i] == (int)ModifierType.Mult )
multProd *= _modValues[i];
b *= multProd;
return b;
}}
}}
";
}
}
// =============================================================================
// Q. create_placement_mode -- ghost-preview + commit placement component.
// Single component manages its own ghost GameObject. Ray from scene camera
// through mouse position, optional grid-snap, commit on attack1.
// API verified live: camera.ScreenPixelToRay(Mouse.Position) /
// Scene.Trace.Ray().IgnoreGameObjectHierarchy(ghost).Run(),
// ModelRenderer tint for ghost, colliders disabled on ghost.
// Mined from enifun.shop_manager / thefancylads.restaurant_dev / others.
// =============================================================================
public class CreatePlacementModeHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "PlacementMode", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
var ci = System.Globalization.CultureInfo.InvariantCulture;
float gridSize = p.TryGetProperty( "gridSize", out var gs ) && gs.TryGetSingle( out var gsf ) ? gsf : 0f;
string gsStr = gridSize.ToString( ci ) + "f";
var code = BuildCode( className, gsStr );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new { created = true, path = relPath, className, placedOn, note } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_placement_mode failed: {ex.Message}" } );
}
}
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; }
}
static string BuildCode( string className, string gridSize )
{
return $@"using Sandbox;
using System;
using System.Collections.Generic;
/// <summary>
/// {className} -- two-phase ghost-preview + commit placement system.
///
/// Pattern (per building-placement cookbook, grounded in enifun.shop_manager):
/// 1. StartPlacing() clones GhostPrefab as a cosmetic, non-networked preview.
/// The ghost has all Colliders disabled so it never blocks its own trace.
/// Its ModelRenderers are tinted semi-transparent to signal preview mode.
/// 2. Each OnUpdate() frame while placing: cast a mouse ray from the scene
/// camera, ignore the ghost's own hierarchy, snap the hit position to
/// GridSize (0 = freeform), move the ghost there.
/// 3. On Input.Pressed(""attack1"") TryPlace() re-validates distance, then
/// clones GhostPrefab for real at the confirmed position.
/// IN MULTIPLAYER: move the real-clone step into an [Rpc.Host] method and
/// re-validate distance there -- client validity is advisory only.
/// 4. StopPlacing() destroys the ghost.
///
/// Subscribe to the static OnPlaced event to react to a confirmed placement.
/// Assign GhostPrefab in the inspector before calling StartPlacing().
/// </summary>
public sealed class {className} : Component
{{
[Property] public GameObject GhostPrefab {{ get; set; }}
[Property] public float GridSize {{ get; set; }} = {gridSize};
[Property] public float MaxPlaceDistance {{ get; set; }} = 500f;
/// Fires after a successful commit placement with the newly placed GameObject
/// and the world position it was placed at.
public static Action<GameObject, Vector3> OnPlaced {{ get; set; }}
private bool _isPlacing = false;
private GameObject _ghost = null;
/// <summary>
/// Begin placement: clone GhostPrefab as a local preview, disable its
/// colliders so it never blocks its own placement trace.
/// </summary>
public void StartPlacing()
{{
if ( _isPlacing ) return;
if ( GhostPrefab == null ) {{ Log.Warning( ""{className}: GhostPrefab is not assigned."" ); return; }}
_isPlacing = true;
_ghost = GhostPrefab.Clone();
_ghost.NetworkMode = NetworkMode.Never;
// Disable all colliders so the ghost does not block its own trace.
foreach ( var col in _ghost.Components.GetAll<Collider>( FindMode.EverythingInSelfAndDescendants ) )
col.Enabled = false;
// Tint every ModelRenderer semi-transparent to signal preview.
foreach ( var mr in _ghost.Components.GetAll<ModelRenderer>( FindMode.EverythingInSelfAndDescendants ) )
mr.Tint = mr.Tint.WithAlpha( 0.5f );
}}
/// <summary>Abort placement and destroy the ghost preview.</summary>
public void StopPlacing()
{{
_isPlacing = false;
if ( _ghost != null ) {{ _ghost.Destroy(); _ghost = null; }}
}}
protected override void OnUpdate()
{{
if ( !_isPlacing || _ghost == null ) return;
// Cast a ray from the scene camera through the mouse position.
// API: camera.ScreenPixelToRay(Mouse.Position) (verified live via describe_type;
// enifun.shop_manager Code/Shop/ShopBuilder.cs).
var camera = Scene.Camera;
if ( camera == null ) return;
var ray = camera.ScreenPixelToRay( Mouse.Position );
var trace = Scene.Trace.Ray( ray, MaxPlaceDistance )
.IgnoreGameObjectHierarchy( _ghost )
.Run();
if ( !trace.Hit ) return;
var pos = trace.HitPosition;
// Snap to grid when GridSize > 0 (MathX rounding with explicit int casts).
if ( GridSize > 0f )
{{
pos = new Vector3(
MathX.FloorToInt( pos.x / GridSize ) * GridSize,
MathX.FloorToInt( pos.y / GridSize ) * GridSize,
pos.z );
}}
_ghost.WorldPosition = pos;
// Confirm placement on attack1.
if ( Input.Pressed( ""attack1"" ) )
TryPlace( pos, _ghost.WorldRotation );
}}
/// <summary>
/// Validate distance and commit the placement. Clones GhostPrefab for real.
///
/// MULTIPLAYER NOTE: Move this clone step into an [Rpc.Host] method and
/// re-validate distance on the host before calling NetworkSpawn() -- the
/// client-side check here is advisory only (lag + cheating).
/// </summary>
private void TryPlace( Vector3 pos, Rotation rot )
{{
var camera = Scene.Camera;
if ( camera == null ) return;
if ( Vector3.DistanceBetween( camera.WorldPosition, pos ) > MaxPlaceDistance ) return;
if ( GhostPrefab == null ) return;
// Clone the real prefab at the confirmed position.
var placed = GhostPrefab.Clone();
placed.WorldPosition = pos;
placed.WorldRotation = rot;
// placed.NetworkSpawn(); // un-comment + move into [Rpc.Host] for multiplayer
OnPlaced?.Invoke( placed, pos );
// Stop and restart so the player can place another.
StopPlacing();
StartPlacing();
}}
protected override void OnDestroy()
{{
StopPlacing();
}}
}}
";
}
}