Editor bridge handlers that generate network-related C# scaffolds for the game assembly. It implements four handlers that write source files: a host-validated rate-limited RPC action, a targeted unicast RPC using Rpc.FilterInclude, a local-player resolver (proxy-safe), and a host-migration recovery component. Also includes small helpers to place generated components onto scene objects and escape string literals.
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
// =============================================================================
// Networking primitives pack (v1.20.0, Track B) -- four multiplayer scaffolds
// (code-gen; scene-mutating):
//
// create_host_rpc_action validated + rate-limited [Rpc.Host] action skeleton
// add_targeted_rpc Rpc.FilterInclude single-client (unicast) side-effect
// create_local_player_resolver proxy-safe "who is MY player" resolver (online + offline)
// add_host_migration_recovery proxy->authority transition detector + OnBecameHost hook
//
// Compiles into the SAME editor assembly as MyEditorMenu.cs / ScaffoldHandlers.cs,
// so it reuses the shared statics on ClaudeBridge (TryResolveProjectPath,
// SanitizeIdentifier, SerializeGo) and ScaffoldHelpers (PrepareCodeFile /
// WriteCode / Utf8NoBom). Handler code here is UNSANDBOXED editor code.
//
// The C# *strings these handlers WRITE TO DISK* are SANDBOXED game code and must
// obey the s&box sandbox rules:
// - System.Math/MathF/MathX all compile on the current SDK; Array.Clone() is
// whitelist-blocked (not used here).
// - Fully-qualify System.Collections.Generic.Dictionary (dodges a missing using).
// - TimeSince/TimeUntil for timers; float literals formatted InvariantCulture + 'f'.
// - Guard Networking access: check Networking.IsActive before Networking.IsHost
// (IsHost can throw with no session). Rpc.Caller re-resolved host-side, never
// trusting client args for identity.
// - VERIFIED live against the installed SDK before codegen (describe_type +
// networking-authority cookbook): Connection.Local (static), Connection.All,
// Connection.SteamId (NOTE: Connection has NO IsValid member on this SDK —
// null-check it; caught live by the v1.20.0 verify-gate), Rpc.Caller (Connection) / Rpc.CallerId (Guid),
// Rpc.FilterInclude(Connection) -> IDisposable, GameObject.Network (NetworkAccessor)
// -> Owner (Connection) / OwnerId (Guid) / IsOwner / IsProxy, [Sync(SyncFlags.FromHost)],
// [Rpc.Host] / [Rpc.Broadcast], (ulong)SteamId cast.
//
// Register(...) lines + the _sceneMutatingCommands additions live in
// MyEditorMenu.cs (Batch 45) to keep the files decoupled.
// =============================================================================
// -----------------------------------------------------------------------------
// create_host_rpc_action -- the validated, rate-limited host-action skeleton.
//
// The safe answer to "a client asks the host to DO something": a client-callable
// Request() forwards to an [Rpc.Host] body that re-resolves the caller via
// Rpc.Caller (NEVER trusting client args for identity), enforces a per-SteamId
// cooldown from a Dictionary<ulong, TimeSince>, runs a clearly-marked TODO hook,
// and fires a static OnActionExecuted event. Covers the backlog's
// add_rate_limited_rpc.
// -----------------------------------------------------------------------------
public class CreateHostRpcActionHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
if ( !ScaffoldHelpers.PrepareCodeFile( p, "HostRpcAction", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
float cooldown = p.TryGetProperty( "cooldownSeconds", out var cv ) && cv.TryGetSingle( out var cf ) ? cf : 1f;
if ( cooldown < 0f ) cooldown = 0f; // a negative cooldown would emit nonsense
var code = BuildCode( className, cooldown, ci );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = NetPrimitivesHelpers.PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
cooldownSeconds = cooldown,
placedOn,
note,
nextSteps = new[]
{
$"trigger_hotload to compile {className} into the game assembly.",
placedOn != null
? $"{className} was attached to the target GameObject."
: $"Attach it to the object that owns this action (a player, a station, or your game manager): add_component_with_properties (component=\"{className}\") after the hotload, or re-run with targetId.",
$"Fire it from the owning client (input / UI button): GetComponent<{className}>()?.Request(); -- it routes to the host, which re-validates and rate-limits.",
$"Fill in the TODO host block with your authoritative action (spend currency, NetworkSpawn, grant a reward). Re-clamp any gameplay args there -- forged client args bypass NetFlags.",
$"React to accepted actions: {className}.OnActionExecuted += conn => Log.Info( $\"action by {{conn.DisplayName}}\" ); (fires on the host). Wrap an [Rpc.Broadcast] if every client should react.",
"Tune CooldownSeconds with set_property. The per-SteamId cooldown is host-only runtime state (not [Sync])."
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_host_rpc_action failed: {ex.Message}" } );
}
}
static string BuildCode( string className, float cooldown, System.Globalization.CultureInfo ci )
{
string cd = cooldown.ToString( ci ) + "f";
return $@"using Sandbox;
using System;
/// <summary>
/// {className} -- a validated, rate-limited host action. The safe skeleton for
/// ""a client asks the host to DO something"" (buy, use, vote, interact).
///
/// Flow: client calls Request() -> [Rpc.Host] SubmitRequest() runs ON THE HOST
/// -> host re-resolves WHO called it via Rpc.Caller (never trusts client
/// args for identity) -> enforces a per-SteamId cooldown -> runs your
/// host-authoritative action -> fires OnActionExecuted.
///
/// [Rpc.Host] is callable by ANY client with forged args -- NetFlags restrict who
/// may INVOKE, which is not security. That is why identity + cooldown + your
/// validation all live INSIDE the host body. Single-player safe (no session -> the
/// RPC just runs locally; the caller falls back to Connection.Local).
///
/// Usage:
/// GetComponent<{className}>()?.Request(); // from input / a UI button, on the owning client
/// {className}.OnActionExecuted += conn => Log.Info( $""action by {{conn.DisplayName}}"" );
/// </summary>
public sealed class {className} : Component
{{
/// <summary>Minimum seconds between accepted requests, per calling player.</summary>
[Property] public float CooldownSeconds {{ get; set; }} = {cd};
/// <summary>Fires ON THE HOST after an accepted request. Arg = the validated caller.</summary>
public static Action<Connection> OnActionExecuted {{ get; set; }}
// Host-only runtime state: last-accept time keyed by the caller's SteamId.
// NOT [Sync] -- it is the host's own rate-limit bookkeeping, never replicated.
private readonly System.Collections.Generic.Dictionary<ulong, TimeSince> _cooldowns = new();
/// <summary>
/// Client entry point. Call this on the owning client (input handler / UI button).
/// It routes to the host; do NOT put authoritative logic here -- a client controls
/// this machine and could call anything. The real work happens host-side.
/// </summary>
public void Request()
{{
SubmitRequest(); // [Rpc.Host] -- executes on the host (or locally in solo)
}}
/// <summary>
/// Host-authoritative handler. Public so the RPC source generator is happy; the
/// re-validation below is what actually protects it. NEVER trust args passed from
/// the client for identity -- re-resolve the caller here.
/// </summary>
[Rpc.Host]
public void SubmitRequest()
{{
// Re-resolve the caller SERVER-SIDE. Read Rpc.Caller only when a session is
// active (offline it is meaningless); fall back to us in solo.
var caller = Networking.IsActive ? Rpc.Caller : Connection.Local;
if ( caller == null ) caller = Connection.Local;
if ( caller == null ) return; // no identity at all -- refuse
// FOOTGUN (some SDK builds): Rpc.Caller can return the HOST's own connection
// for a proxy-initiated call. If identity is security-critical, resolve the
// acting player from the OWNING component's Network.Owner instead.
ulong callerId = (ulong)caller.SteamId;
// Per-SteamId rate limit -- spamming the RPC cannot bypass the cooldown.
if ( _cooldowns.TryGetValue( callerId, out var since ) && since < CooldownSeconds )
return; // still cooling down for this caller
_cooldowns[callerId] = 0f; // reset this caller's timer
// --- TODO: your host-authoritative action goes here ---------------------
// Runs ONLY on the host. Re-validate + re-clamp any gameplay values, then
// mutate [Sync(SyncFlags.FromHost)] state / NetworkSpawn() / grant rewards.
// Example: GetComponent<Wallet>()?.AddMoney( 10 );
// ------------------------------------------------------------------------
OnActionExecuted?.Invoke( caller );
}}
}}
";
}
}
// -----------------------------------------------------------------------------
// add_targeted_rpc -- the Rpc.FilterInclude single-client (unicast) pattern.
//
// A host-side SendTo(Connection, string) wraps an [Rpc.Broadcast] call in
// using ( Rpc.FilterInclude( target ) ) so ONLY that one connection executes the
// body, which raises a static OnReceived event.
// -----------------------------------------------------------------------------
public class AddTargetedRpcHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "TargetedRpc", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
var code = BuildCode( className );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = NetPrimitivesHelpers.PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
placedOn,
note,
nextSteps = new[]
{
$"trigger_hotload to compile {className} into the game assembly.",
placedOn != null
? $"{className} was attached to the target GameObject."
: $"Attach it to a networked manager object: add_component_with_properties (component=\"{className}\") after the hotload, or re-run with targetId. The object must be NetworkSpawn'd for the RPC to route.",
$"Send to ONE player from the host: GetComponent<{className}>()?.SendTo( player.Network.Owner, \"You're up next!\" ); -- only that client runs the body.",
$"Receive on the target: {className}.OnReceived += msg => ShowToast( msg ); -- fires only on the filtered client (and locally in solo).",
"Use this instead of [Rpc.Broadcast] + a client-side 'is this for me?' check -- FilterInclude scopes it server-side, so no data leaks and no wasted bandwidth."
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"add_targeted_rpc failed: {ex.Message}" } );
}
}
static string BuildCode( string className )
{
return $@"using Sandbox;
using System;
/// <summary>
/// {className} -- send a message to exactly ONE client using Rpc.FilterInclude.
///
/// A normal [Rpc.Broadcast] runs on EVERY machine. Wrapping the call in
/// using ( Rpc.FilterInclude( target ) ) scopes it server-side so ONLY the target
/// connection executes the RPC body -- the right way to unicast (a private prompt,
/// a personal reward toast, a per-player cutscene) instead of broadcasting to all
/// and filtering on the client (which leaks data + wastes bandwidth).
///
/// Call SendTo on the host. Single-player safe (with no session it just runs locally).
///
/// Usage (host-side):
/// GetComponent<{className}>()?.SendTo( somePlayer.Network.Owner, ""You're up next!"" );
/// {className}.OnReceived += msg => Log.Info( $""(only me) {{msg}}"" );
/// </summary>
public sealed class {className} : Component
{{
/// <summary>Fires on the TARGET client only (and locally in solo) when a message arrives.</summary>
public static Action<string> OnReceived {{ get; set; }}
/// <summary>
/// Host-side: deliver <paramref name=""message""/> to exactly one connection.
/// FilterInclude scopes the broadcast so only <paramref name=""target""/> runs it.
/// </summary>
public void SendTo( Connection target, string message )
{{
if ( target == null ) return;
// Only the host should originate a targeted message in a host-authoritative
// game. Guarded behind IsActive because Networking.IsHost can throw with no
// session; in solo this falls through and just runs locally.
if ( Networking.IsActive && !Networking.IsHost ) return;
using ( Rpc.FilterInclude( target ) )
Receive( message );
}}
/// <summary>
/// The unicast body. Public so the RPC source generator is happy. Runs ONLY on the
/// filtered target connection (FilterInclude decided that server-side).
/// </summary>
[Rpc.Broadcast]
public void Receive( string message )
{{
OnReceived?.Invoke( message );
}}
}}
";
}
}
// -----------------------------------------------------------------------------
// create_local_player_resolver -- proxy-safe "who is MY player".
//
// Static Local property that lazily finds the player GameObject owned by the local
// connection ( Network.Owner == Connection.Local, or Network.IsOwner ) when
// networking is active, and falls back to the first/only tagged player when it is
// NOT (offline/solo). Cached with an IsValid() revalidation. The corpus footgun
// killer -- running "my player" logic against a proxy of someone else's player.
// -----------------------------------------------------------------------------
public class CreateLocalPlayerResolverHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "LocalPlayerResolver", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
var tag = p.TryGetProperty( "playerTag", out var tv ) && !string.IsNullOrWhiteSpace( tv.GetString() )
? tv.GetString().Trim() : "player";
var tagLiteral = NetPrimitivesHelpers.EscapeStringLiteral( tag );
var code = BuildCode( className, tagLiteral );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = NetPrimitivesHelpers.PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
playerTag = tag,
placedOn,
note,
nextSteps = new[]
{
$"trigger_hotload to compile {className} into the game assembly.",
placedOn != null
? $"{className} was attached to the target GameObject."
: $"Attach ONE to a persistent object (your game manager): add_component_with_properties (component=\"{className}\") after the hotload, or re-run with targetId. Placing it lets you set PlayerTag in the inspector.",
$"Tag each player GameObject with \"{tag}\" (set_tags) so the resolver can find them.",
$"Read your player from anywhere: var me = {className}.Local; -- online it is the object you OWN, offline it is the only player. Cached + revalidated automatically.",
$"Filter events to your own player: if ( {className}.IsLocal( someGameObject ) ) {{ ... }} -- kills the 'ran my UI/logic against a proxy' footgun."
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_local_player_resolver failed: {ex.Message}" } );
}
}
static string BuildCode( string className, string tagLiteral )
{
return $@"using Sandbox;
using System;
/// <summary>
/// {className} -- ""who is MY player?"", the proxy-safe way. Resolves the player
/// GameObject that belongs to THIS machine, both online and offline.
///
/// Online: your player is the tagged object whose Network.Owner is the local
/// connection ( Network.Owner == Connection.Local, or Network.IsOwner ). Offline /
/// solo (no session), there is exactly one player, so it returns the first tagged
/// object. The result is cached and revalidated with IsValid() so a destroyed /
/// respawned player is re-resolved automatically.
///
/// Attach ONE of these to a persistent object (your game manager) so PlayerTag is
/// configurable; the resolver itself is static and callable from anywhere:
/// var me = {className}.Local; // my player GameObject (or null)
/// if ( {className}.IsLocal( someGo ) ) ... // filter events to my own player
///
/// This kills the #1 multiplayer footgun -- running ""my player"" logic against a
/// proxy of someone else's player.
/// </summary>
public sealed class {className} : Component
{{
/// <summary>Tag that marks a player GameObject. Players must carry this tag.</summary>
[Property] public string PlayerTag {{ get; set; }} = ""{tagLiteral}"";
private static {className} _instance;
private static string _tag = ""{tagLiteral}"";
private static GameObject _cached;
protected override void OnEnabled()
{{
_instance = this;
_tag = PlayerTag;
}}
protected override void OnDisabled()
{{
if ( _instance == this ) _instance = null;
}}
/// <summary>The local machine's player GameObject, or null if not found yet.</summary>
public static GameObject Local
{{
get
{{
if ( IsLocal( _cached ) ) return _cached; // cache hit, still valid + still ours
_cached = Resolve();
return _cached;
}}
}}
/// <summary>True if <paramref name=""go""/> is the local machine's player.</summary>
public static bool IsLocal( GameObject go )
{{
if ( !go.IsValid() ) return false;
if ( !Networking.IsActive ) return true; // solo: the only player is mine
return go.Network.Owner == Connection.Local || go.Network.IsOwner;
}}
private static GameObject Resolve()
{{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return null;
if ( !Networking.IsActive )
{{
// Offline / solo: the first tagged player is ours.
foreach ( var go in scene.GetAllObjects( true ) )
if ( go.Tags.Has( _tag ) ) return go;
return null;
}}
// Online: our player is the tagged object owned by the local connection.
foreach ( var go in scene.GetAllObjects( true ) )
{{
if ( !go.Tags.Has( _tag ) ) continue;
if ( go.Network.Owner == Connection.Local || go.Network.IsOwner )
return go;
}}
return null;
}}
}}
";
}
}
// -----------------------------------------------------------------------------
// add_host_migration_recovery -- proxy->authority transition detector.
//
// Tracks previous IsProxy each frame; when it flips from true to false (we became
// the authority for this object, i.e. host migration promoted us), it fires a
// static OnBecameHost event and runs a virtual-style TODO rebuild hook, then -- a
// short settle delay later -- a deferred validation hook. Inert offline (IsProxy
// is always false with no session).
// -----------------------------------------------------------------------------
public class AddHostMigrationRecoveryHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
if ( !ScaffoldHelpers.PrepareCodeFile( p, "HostMigrationRecovery", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
// Settle delay is fixed at the cookbook-recommended ~1s but exposed as a
// [Property] so it is tunable; no param for it (keeps the schema to name/directory).
float settle = 1f;
var code = BuildCode( className, settle, ci );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = NetPrimitivesHelpers.PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
placedOn,
note,
nextSteps = new[]
{
$"trigger_hotload to compile {className} into the game assembly.",
placedOn != null
? $"{className} was attached to the target GameObject."
: $"Attach it to your host-authoritative manager object: add_component_with_properties (component=\"{className}\") after the hotload, or re-run with targetId. The object should be NetworkSpawn'd.",
$"React to becoming host: {className}.OnBecameHost += go => Log.Info( \"I am the host now -- rebuilding\" );",
"Fill in the RebuildAfterMigration() TODO region: re-arm host-only loops/timers against your clock, TakeOwnership of orphans, rebuild handle maps by world position, reconcile your [Sync] registry against the real scene.",
"Fill in the deferred ValidateAfterMigration() TODO: sanity-check expected-vs-actual and hard-reset the round if it looks corrupt (SettleSeconds delay lets in-flight packets land first).",
"Requires a real host migration to fire (a second client that becomes host when the first leaves) -- it is inert in solo/offline play."
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"add_host_migration_recovery failed: {ex.Message}" } );
}
}
static string BuildCode( string className, float settle, System.Globalization.CultureInfo ci )
{
string st = settle.ToString( ci ) + "f";
return $@"using Sandbox;
using System;
/// <summary>
/// {className} -- detects when THIS machine takes authority over this object
/// (proxy -> owner), which is what happens to a host-authoritative manager during
/// host migration, and gives you a clean hook to rebuild host-only state.
///
/// It tracks IsProxy each frame; when it flips from true (someone else was the
/// authority) to false (now it is us), it fires OnBecameHost and runs the rebuild
/// hook, then -- after a short settle delay so in-flight packets can land -- runs a
/// deferred validation hook. Inert offline (IsProxy is always false with no session).
///
/// Attach to your host-authoritative manager object. Fill in the two TODO regions.
///
/// Usage:
/// {className}.OnBecameHost += go => Log.Info( ""I am the host now -- rebuilding"" );
/// </summary>
public sealed class {className} : Component
{{
/// <summary>Seconds to wait after becoming host before the deferred validation runs.</summary>
[Property] public float SettleSeconds {{ get; set; }} = {st};
/// <summary>Fires on the machine that just gained authority. Arg = this GameObject.</summary>
public static Action<GameObject> OnBecameHost {{ get; set; }}
private bool _wasProxy;
private bool _initialized;
private bool _pendingValidate;
private TimeSince _sinceBecameHost;
protected override void OnEnabled()
{{
_wasProxy = IsProxy; // baseline so we only fire on a real transition
_initialized = true;
}}
protected override void OnUpdate()
{{
bool proxyNow = IsProxy;
if ( _initialized && _wasProxy && !proxyNow )
BecameHost();
_wasProxy = proxyNow;
if ( _pendingValidate && _sinceBecameHost > SettleSeconds )
{{
_pendingValidate = false;
ValidateAfterMigration();
}}
}}
private void BecameHost()
{{
_sinceBecameHost = 0f;
_pendingValidate = true;
RebuildAfterMigration();
OnBecameHost?.Invoke( GameObject );
}}
// virtual-style rebuild hook -- edit this body (the component is sealed, so there
// is nothing to override; this region IS your override point).
private void RebuildAfterMigration()
{{
// TODO: rebuild host-only state now that YOU are the authority. The previous
// host is gone; anything it owned or was mid-computing is now your job. Typical
// moves (networking-authority cookbook, pattern 17):
// - Re-arm host-only loops / spawners. A [Sync] TimeUntil stores the DEAD
// host's clock epoch -- read its .Relative remaining and re-arm it here.
// - Network.TakeOwnership() any orphaned objects you must now manage/destroy.
// - Rebuild handle->handle maps by world-position matching (object Ids do not
// survive migration).
// - Reconcile your [Sync] registry against the REAL scene (drop dead entries,
// add visible objects the list is missing).
}}
// deferred sanity check -- runs SettleSeconds after becoming host so in-flight
// packets that have not applied yet do not make a healthy scene look broken.
private void ValidateAfterMigration()
{{
// TODO: compare expected-vs-actual (child counts, roster tags) and hard-reset
// the round rather than limping along if it looks corrupt. (cookbook pattern 17)
}}
}}
";
}
}
/// <summary>
/// Shared helpers for the networking-primitives handlers -- mirrors the standard
/// scaffold placement (GameFeelHelpers / create_event_director) plus a tiny
/// string-literal escaper for baked-in tag defaults.
/// </summary>
internal static class NetPrimitivesHelpers
{
public static object PlaceOnTarget( string targetId, string className, out string note )
{
note = null;
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null ) { note = "No active scene to place into."; return null; }
if ( !Guid.TryParse( targetId, out var guid ) ) { note = "Invalid targetId GUID."; return null; }
var go = scene.Directory.FindByGuid( guid );
if ( go == null ) { note = $"Target GameObject not found: {targetId}"; return null; }
var typeDesc = Game.TypeLibrary.GetType( className );
if ( typeDesc == null )
{
note = $"Generated {className}.cs but it is not in the TypeLibrary yet -- trigger_hotload, then add it with add_component_with_properties.";
return null;
}
try { go.Components.Create( typeDesc ); return ClaudeBridge.SerializeGo( go ); }
catch ( Exception ex ) { note = $"Placement failed ({ex.Message})."; return null; }
}
/// <summary>Escape a user string so it can be baked as a C# double-quoted literal.</summary>
public static string EscapeStringLiteral( string s )
{
if ( string.IsNullOrEmpty( s ) ) return "player";
return s.Replace( "\\", "\\\\" ).Replace( "\"", "\\\"" );
}
}