Editor handlers that generate three sandboxed game component source files: a host-authoritative gacha drop table, a networked currency pickup with optional magnet behavior, and an offline-progress idle component. Each handler prepares file content, writes it to disk, optionally attaches the generated component to a scene GameObject, and returns metadata for the editor UI.
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;
// =============================================================================
// Loot / Economy variants pack (v1.20.0, Track D) -- three Tier-2 scaffolds
// (code-gen; scene-mutating):
//
// create_gacha_drop_table per-rarity roll + pity counter + duplicate detection,
// host-authoritative ([Rpc.Host] roll -> [Rpc.Broadcast])
// create_currency_pickup networked coin: optional magnet, host-validated grant into
// create_economy_wallet's AddMoney, replicated despawn
// create_offline_progress DateTime delta on enable + clamp + deterministic tick replay
// (the idle-game staple)
//
// Compiles into the SAME editor assembly as MyEditorMenu.cs / ScaffoldHandlers.cs,
// so it reuses the shared statics on ClaudeBridge (TryResolveProjectPath,
// SanitizeIdentifier, SerializeGo) and ScaffoldHelpers (PrepareCodeFile /
// WriteCode / Utf8NoBom). Handler code here is UNSANDBOXED editor code (System.* fine).
//
// The C# *strings these handlers WRITE TO DISK* are SANDBOXED game code and must
// obey the s&box sandbox rules:
// - MathX preferred; System.Math/MathF also compile on the current SDK. Array.Clone()
// is still whitelist-blocked (not used here); .ToArray() is the replacement.
// - only sandbox-proven APIs:
// * Component, [Property], List<T>/HashSet<T> (compile-verified in
// create_weighted_loot_table / create_inventory), Game.Random.Float (loot table).
// * [Sync(SyncFlags.FromHost)] + [Rpc.Host] + [Rpc.Broadcast] + Rpc.Caller
// (compile-verified by create_economy_wallet / add_interaction_station).
// * Component.ITriggerListener OnTriggerEnter/OnTriggerExit (compile-verified by
// create_pickup), Scene.GetAllComponents<Collider>() (create_npc_brain),
// GameObject.Tags.Has, Vector3.DistanceSquared/.Normal, OnFixedUpdate.
// * FileSystem.Data.ReadJsonOrDefault<T>/WriteJson + DateTime (compile-verified by
// create_save_system).
// - GameObject.NetworkDestroy does NOT exist on this SDK -- the host calls
// GameObject.Destroy() and the destroy replicates network-wide (same as the
// compile-verified create_event_director self-destruct companion).
//
// Register(...) lines + the _sceneMutatingCommands additions live in MyEditorMenu.cs
// (Batch 45) to keep the files decoupled -- see this tool family's handoff summary.
// =============================================================================
// -----------------------------------------------------------------------------
// create_gacha_drop_table -- a sealed gacha roller. Two parallel [Property] lists
// pick a RARITY by cumulative weight; a flat "Rarity:Item" [Property] list picks an
// ITEM uniformly within the chosen rarity. A pity counter guarantees the rarest tier
// after PityAfter rolls without it (and resets on a rarest hit). Owned-item set drives
// duplicate detection with an OnDuplicate host hook. Host-authoritative: the roll runs
// inside an [Rpc.Host] (Rpc.Caller re-validated) and the result is announced via an
// [Rpc.Broadcast] so every machine fires the static OnRolled event.
//
// Builds on the create_weighted_loot_table shape (cumulative-weight Roll + pity) but
// adds the two-level rarity->item pick, dupe detection, and full host authority.
// -----------------------------------------------------------------------------
public class CreateGachaDropTableHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
if ( !ScaffoldHelpers.PrepareCodeFile( p, "GachaDropTable", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
int pityAfter = p.TryGetProperty( "pityAfter", out var pv ) && pv.TryGetInt32( out var pi ) ? pi : 50;
if ( pityAfter < 0 ) pityAfter = 0; // 0 disables pity
var code = BuildCode( className, pityAfter, ci );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = LootEconomyHelpers.PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
pityAfter,
placedOn,
note,
nextSteps = new[]
{
$"trigger_hotload to compile {className} into the game assembly.",
placedOn != null
? $"{className} was attached to the target GameObject."
: $"Place it on a per-player or manager GameObject: add_component_to_new_object (component=\"{className}\") after the hotload, or re-run with targetId.",
$"Fill RarityNames / RarityWeights (parallel, cumulative-weight) and Items (\"Rarity:Item\" entries, e.g. \"Legendary:Dragon Fang\") in the inspector or with set_property. The LAST rarity in the list is treated as the rarest tier (the pity target) -- order them Common -> Legendary.",
$"Roll from any game code: GetComponent<{className}>()?.Roll(); -- Roll() routes to the host via [Rpc.Host] RequestRoll (re-validate the caller inside CanRollFor for a competitive game), and the result fans out via [Rpc.Broadcast] so every machine fires the static event.",
$"Subscribe to results: {className}.OnRolled += (rarity, item, isDuplicate) => {{ /* play the reveal, update the pull HUD */ }};",
$"Convert duplicates to shards/currency in the host-side OnDuplicate hook (marked TODO in the file): GetComponent<{className}>().OnDuplicate = item => wallet?.AddMoney( shardValue );",
pityAfter > 0
? $"PityAfter is {pityAfter}: after that many rolls with no rarest-tier hit, the next roll is forced to the rarest tier and the counter resets. Tune with set_property."
: "PityAfter is 0 (pity disabled). Set it with set_property to guarantee the rarest tier after N dry rolls."
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_gacha_drop_table failed: {ex.Message}" } );
}
}
static string BuildCode( string className, int pityAfter, System.Globalization.CultureInfo ci )
{
string pity = pityAfter.ToString( ci );
return $@"using Sandbox;
using System;
using System.Collections.Generic;
/// <summary>
/// {className} -- a host-authoritative gacha / loot-box roller.
///
/// TWO-LEVEL PICK: RarityNames + RarityWeights are parallel, inspector-editable lists --
/// a cumulative-weight roll selects a RARITY (like create_weighted_loot_table). Items is a
/// flat ""Rarity:Item"" list (e.g. ""Legendary:Dragon Fang""); once a rarity is chosen an
/// item is picked UNIFORMLY from the entries with that prefix. Keep it simple + designer-tunable.
///
/// PITY: PityAfter counts rolls since the last rarest-tier hit. When it reaches PityAfter the
/// next roll is forced to the rarest tier (the LAST entry in RarityNames) and the counter resets.
/// PityAfter = 0 disables pity.
///
/// DUPLICATES: an owned-items set drives duplicate detection. On a dupe the host-side
/// OnDuplicate hook fires (convert it to shards / pity currency -- see the TODO); on a new
/// item it's added to the set.
///
/// HOST-AUTHORITATIVE: Roll() routes to the host via [Rpc.Host] RequestRoll (re-validate the
/// caller -- NetFlags is not security), the host computes the result, and [Rpc.Broadcast]
/// AnnounceRoll fires the static OnRolled event on every machine. Single-player safe
/// (IsProxy is false / Networking inactive -> the RPCs run locally).
///
/// Usage:
/// GetComponent<{className}>()?.Roll();
/// {className}.OnRolled += ( rarity, item, isDup ) => Log.Info( $""{{rarity}} :: {{item}} (dup={{isDup}})"" );
/// GetComponent<{className}>().OnDuplicate = item => {{ /* grant shards host-side */ }};
/// </summary>
public sealed class {className} : Component
{{
/// Rarities, rarest LAST (the pity target). Parallel to RarityWeights.
[Property] public List<string> RarityNames {{ get; set; }} = new List<string> {{ ""Common"", ""Rare"", ""Epic"", ""Legendary"" }};
/// Cumulative-weight rarity chances, parallel to RarityNames. Bigger = more common.
[Property] public List<float> RarityWeights {{ get; set; }} = new List<float> {{ 79f, 15f, 5f, 1f }};
/// The item pool as flat ""Rarity:Item"" entries -- an item is picked uniformly among
/// the entries whose prefix matches the rolled rarity.
[Property] public List<string> Items {{ get; set; }} = new List<string>
{{
""Common:Wooden Sword"", ""Common:Cloth Cap"",
""Rare:Steel Blade"", ""Rare:Iron Shield"",
""Epic:Frost Bow"", ""Legendary:Dragon Fang""
}};
/// Rolls without a rarest-tier hit before the next roll is guaranteed rarest. 0 = off.
[Property] public int PityAfter {{ get; set; }} = {pity};
/// Fires on EVERY machine after a roll (via [Rpc.Broadcast]) -- drive the reveal / HUD here.
public static Action<string, string, bool> OnRolled {{ get; set; }}
/// Host-side hook fired when a rolled item is already owned. Convert dupes to shards/currency.
public Action<string> OnDuplicate {{ get; set; }}
// Host-authoritative runtime state (only ever written on the host inside DoRoll).
private int _sinceRarest;
private readonly HashSet<string> _owned = new HashSet<string>();
/// <summary>Public entry: request a roll. Routes to the host; the result is broadcast.</summary>
public void Roll() => RequestRoll();
/// <summary>
/// Host-authoritative roll. [Rpc.Host] is invokable by any client with forged args, so
/// NetFlags is NOT security -- CanRollFor re-validates the caller before we roll.
/// </summary>
[Rpc.Host]
public void RequestRoll()
{{
if ( Networking.IsActive )
{{
var caller = Rpc.Caller;
if ( caller == null ) return;
if ( !CanRollFor( caller ) ) return;
}}
DoRoll();
}}
/// <summary>
/// Tighten this to your game: confirm the caller may roll (owns this roller / can afford
/// the pull / isn't rate-limited). Default allows any valid caller (and single-player).
/// </summary>
private bool CanRollFor( Connection caller ) => true;
// The actual roll -- runs ONLY on the host (or locally in single-player).
private void DoRoll()
{{
if ( RarityNames == null || RarityNames.Count == 0 ) return;
int rarestIdx = RarityNames.Count - 1;
int rarityIdx;
if ( PityAfter > 0 && _sinceRarest >= PityAfter )
rarityIdx = rarestIdx; // pity: force the rarest tier
else
rarityIdx = PickRarityIndex();
if ( rarityIdx < 0 ) rarityIdx = 0;
if ( rarityIdx > rarestIdx ) rarityIdx = rarestIdx;
// Pity tracking: reset on a rarest hit, otherwise advance.
if ( rarityIdx == rarestIdx ) _sinceRarest = 0;
else _sinceRarest++;
string rarity = RarityNames[rarityIdx];
string item = PickItem( rarity );
bool isDuplicate = _owned.Contains( item );
if ( isDuplicate )
{{
// ── DUPLICATE ────────────────────────────────────────────────
// TODO: convert the dupe into shards / pity currency here (host-side), e.g.
// GetComponent<Wallet>()?.AddMoney( shardValue );
OnDuplicate?.Invoke( item );
}}
else
{{
_owned.Add( item );
}}
AnnounceRoll( rarity, item, isDuplicate );
}}
// Cumulative-weight rarity pick over the parallel lists; -1 if unusable.
private int PickRarityIndex()
{{
int count = Math.Min( RarityNames.Count, RarityWeights?.Count ?? 0 );
if ( count == 0 ) return 0; // no weights -> first rarity
float total = 0f;
for ( int i = 0; i < count; i++ ) total += RarityWeights[i];
if ( total <= 0f ) return 0;
float roll = Game.Random.Float( 0f, total );
float cumulative = 0f;
for ( int i = 0; i < count; i++ )
{{
cumulative += RarityWeights[i];
if ( roll < cumulative ) return i;
}}
return count - 1;
}}
// Uniform item pick within a rarity, reading the ""Rarity:Item"" prefix. Falls back to the
// rarity name itself if that tier has no items.
private string PickItem( string rarity )
{{
string prefix = rarity + "":"";
var pool = new List<string>();
if ( Items != null )
{{
foreach ( var entry in Items )
{{
if ( entry != null && entry.StartsWith( prefix, StringComparison.OrdinalIgnoreCase ) )
pool.Add( entry.Substring( prefix.Length ) );
}}
}}
if ( pool.Count == 0 ) return rarity;
// Float-based index dodges any inclusive/exclusive ambiguity in the int overload.
int idx = (int) Game.Random.Float( 0f, pool.Count );
if ( idx >= pool.Count ) idx = pool.Count - 1;
return pool[idx];
}}
/// <summary>
/// Fires OnRolled on EVERY machine (host + all proxies). Broadcast from the host after a
/// roll so all clients see the same drop. Public because [Rpc] wrappers require it.
/// </summary>
[Rpc.Broadcast]
public void AnnounceRoll( string rarity, string item, bool isDuplicate )
{{
OnRolled?.Invoke( rarity, item, isDuplicate );
}}
}}
";
}
}
// -----------------------------------------------------------------------------
// create_currency_pickup -- a sealed networked coin/pickup. Host-spawned; on a
// player entering its trigger the HOST validates and grants Value into a wallet
// component on the player, then destroys the pickup network-wide. Optional magnet:
// within MagnetRadius it accelerates toward the nearest player each FixedUpdate.
//
// Deposit is reflection-free: a static Grant seam (wired once to the direct typed
// AddMoney call) keeps the pickup compiling with NO hard dependency on a specific
// wallet class -- mirrors create_pickup's self-contained convention. Pairs with
// create_economy_wallet (AddMoney/TrySpend/CanAfford API shape).
// -----------------------------------------------------------------------------
public class CreateCurrencyPickupHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
if ( !ScaffoldHelpers.PrepareCodeFile( p, "CurrencyPickup", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
int value = p.TryGetProperty( "value", out var vv ) && vv.TryGetInt32( out var vi ) ? vi : 1;
float magnetRadius = p.TryGetProperty( "magnetRadius", out var mv ) && mv.TryGetSingle( out var mf ) ? mf : 0f;
string walletName = p.TryGetProperty( "walletComponentName", out var wv ) && !string.IsNullOrWhiteSpace( wv.GetString() )
? wv.GetString() : "EconomyWallet";
// Defensive clamps.
if ( value < 0 ) value = 0;
if ( magnetRadius < 0f ) magnetRadius = 0f;
walletName = ClaudeBridge.SanitizeIdentifier( walletName ); // it's used as a type name in a comment
var code = BuildCode( className, value, magnetRadius, walletName, ci );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = LootEconomyHelpers.PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
value,
magnetRadius,
walletComponentName = walletName,
placedOn,
note,
nextSteps = new[]
{
$"trigger_hotload to compile {className} into the game assembly.",
placedOn != null
? $"{className} was attached to the target GameObject -- make sure that object has a trigger Collider (SphereCollider, IsTrigger=true) so OnTriggerEnter fires."
: $"Place it on a coin prop that has a trigger Collider (SphereCollider, IsTrigger=true) + a ModelRenderer: add_component_to_new_object / create_pickup-style, or re-run with targetId.",
$"Wire the deposit ONCE at startup (this is the reflection-free direct call -- rename {walletName} if your wallet class differs): {className}.Grant = ( player, amount ) => player.Components.Get<{walletName}>()?.AddMoney( amount );",
"MULTIPLAYER: NetworkSpawn the coin on the host (network_spawn) so the grant is host-authoritative -- the trigger grant + Destroy run only on the host and the despawn replicates. Single-player works with no networking.",
magnetRadius > 0f
? $"MagnetRadius is {magnetRadius.ToString( ci )}: the coin accelerates toward the nearest GameObject tagged '{"player"}' within that range each FixedUpdate (host-side). Tune MagnetRadius / MagnetAccel / MaxMagnetSpeed / PlayerTag with set_property."
: $"MagnetRadius is 0 (magnet off). Set it > 0 (and confirm players carry the PlayerTag '{"player"}') to make coins fly to the player.",
$"Value is {value}. OnCollected fires host-side after a grant: {className}.OnCollected += ( player, amount ) => {{ /* SFX / +N popup */ }};"
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_currency_pickup failed: {ex.Message}" } );
}
}
static string BuildCode( string className, int value, float magnetRadius, string walletName, System.Globalization.CultureInfo ci )
{
string val = value.ToString( ci );
string mag = magnetRadius.ToString( ci ) + "f";
return $@"using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
/// <summary>
/// {className} -- a networked coin / currency pickup. HOST-spawned; when a player enters its
/// trigger the HOST validates and grants Value into the player's wallet, then destroys the
/// pickup network-wide (the host Destroy() replicates -- there is no NetworkDestroy on this SDK).
///
/// MAGNET (optional): while MagnetRadius > 0 the coin accelerates toward the nearest player
/// (a GameObject carrying PlayerTag) each FixedUpdate, host-side, up to MaxMagnetSpeed.
///
/// DEPOSIT is reflection-free and dependency-free: wire the static Grant seam ONCE to your
/// wallet's AddMoney (the direct typed call is in GrantTo below) so {className} compiles with no
/// hard reference to a specific wallet class. Pairs with create_economy_wallet.
///
/// Put it on a GameObject with a trigger Collider (SphereCollider, IsTrigger=true). In
/// multiplayer NetworkSpawn it on the host so IsProxy makes the grant host-authoritative.
/// Single-player works with no networking (IsProxy is false).
///
/// Usage (wire once at startup):
/// {className}.Grant = ( player, amount ) => player.Components.Get<{walletName}>()?.AddMoney( amount );
/// </summary>
public sealed class {className} : Component, Component.ITriggerListener
{{
/// How much currency this pickup is worth.
[Property] public int Value {{ get; set; }} = {val};
/// Magnet range in world units. 0 = magnet off.
[Property] public float MagnetRadius {{ get; set; }} = {mag};
/// Acceleration (units/sec^2) toward the player while magnetised.
[Property] public float MagnetAccel {{ get; set; }} = 512f;
/// Cap on magnet speed (units/sec).
[Property] public float MaxMagnetSpeed {{ get; set; }} = 900f;
/// Tag a GameObject must carry to be treated as a player (for the magnet + trigger filter).
[Property] public string PlayerTag {{ get; set; }} = ""player"";
/// The wallet component's type name -- used to locate it (and to name the fix if Grant is unwired).
[Property] public string WalletComponentName {{ get; set; }} = ""{walletName}"";
/// <summary>
/// How the pickup deposits Value. Wire ONCE at startup to your wallet's AddMoney -- this is
/// the reflection-free direct call (rename {walletName} to YOUR wallet class if it differs):
/// {className}.Grant = ( player, amount ) => player.Components.Get<{walletName}>()?.AddMoney( amount );
/// Left as a static seam so {className} has no hard dependency on a specific wallet type.
/// </summary>
public static Action<GameObject, int> Grant {{ get; set; }}
/// <summary>Fires host-side after a successful grant (SFX / ""+N"" popup). (player, amount).</summary>
public static Action<GameObject, int> OnCollected {{ get; set; }}
private float _magnetSpeed;
private bool _collected;
protected override void OnFixedUpdate()
{{
if ( IsProxy ) return; // host owns the transform; it replicates to clients
if ( MagnetRadius <= 0f ) return; // magnet off
var player = FindNearestPlayer();
if ( player == null ) {{ _magnetSpeed = 0f; return; }}
_magnetSpeed = MathX.Clamp( _magnetSpeed + MagnetAccel * Time.Delta, 0f, MaxMagnetSpeed );
var dir = (player.WorldPosition - WorldPosition).Normal;
WorldPosition += dir * (_magnetSpeed * Time.Delta);
}}
// Nearest GameObject tagged PlayerTag within MagnetRadius, or null. Tag-based so it works
// with any player controller (no dependency on a specific Player type).
private GameObject FindNearestPlayer()
{{
GameObject best = null;
float bestDist = MagnetRadius * MagnetRadius;
foreach ( var collider in Scene.GetAllComponents<Collider>() )
{{
var go = collider?.GameObject;
if ( go == null || !go.Tags.Has( PlayerTag ) ) continue;
float d = go.WorldPosition.DistanceSquared( WorldPosition );
if ( d <= bestDist ) {{ bestDist = d; best = go; }}
}}
return best;
}}
public void OnTriggerEnter( Collider other )
{{
if ( _collected ) return;
var player = other?.GameObject;
if ( player == null || !player.Tags.Has( PlayerTag ) ) return;
// HOST-authoritative: on a proxy (a client copy of a host-spawned coin) do nothing.
// The host grants and the Destroy replicates to everyone.
if ( IsProxy ) return;
_collected = true;
GrantTo( player );
OnCollected?.Invoke( player, Value );
GameObject.Destroy(); // replicates network-wide from the host
}}
public void OnTriggerExit( Collider other ) {{ }}
// Deposit Value into the player's wallet. Reflection-free: prefer the wired Grant seam;
// otherwise locate the wallet by WalletComponentName just to point at the fix (never silent).
private void GrantTo( GameObject player )
{{
if ( Grant != null ) {{ Grant( player, Value ); return; }}
var wallet = player.Components.GetAll().FirstOrDefault( c => c.GetType().Name == WalletComponentName );
if ( wallet != null )
Log.Warning( $""[{className}] '{{WalletComponentName}}' is on {{player.Name}} but {className}.Grant is unwired -- set {className}.Grant = (p,amt) => p.Components.Get<{{WalletComponentName}}>()?.AddMoney(amt); to deposit {{Value}}."" );
else
Log.Warning( $""[{className}] No wallet '{{WalletComponentName}}' on {{player.Name}} and {className}.Grant is unwired -- {{Value}} not granted."" );
}}
}}
";
}
}
// -----------------------------------------------------------------------------
// create_offline_progress -- a sealed idle-game component. Persists LastSeenUtc
// (DateTime) via FileSystem.Data JSON on a dirty-flag autosave + OnDisabled. On
// enable it computes elapsed = now - LastSeenUtc, clamps to MaxOfflineHours, then
// replays it through SimulateOffline in fixed TickSeconds chunks so idle math stays
// deterministic. Guards clock rollback (negative elapsed -> 0). Fires the static
// OnOfflineProgressApplied(seconds). Copies create_save_system's persistence patterns.
// -----------------------------------------------------------------------------
public class CreateOfflineProgressHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
if ( !ScaffoldHelpers.PrepareCodeFile( p, "OfflineProgress", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
float maxHours = p.TryGetProperty( "maxOfflineHours", out var mv ) && mv.TryGetSingle( out var mf ) ? mf : 8f;
float tick = p.TryGetProperty( "tickSeconds", out var tv ) && tv.TryGetSingle( out var tf ) ? tf : 1f;
// Defensive clamps so the deterministic replay can't blow up.
if ( maxHours < 0f ) maxHours = 0f;
if ( tick < 0.1f ) tick = 0.1f; // floor keeps the chunk loop bounded
var code = BuildCode( className, maxHours, tick, ci );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = LootEconomyHelpers.PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
maxOfflineHours = maxHours,
tickSeconds = tick,
placedOn,
note,
nextSteps = new[]
{
$"trigger_hotload to compile {className} into the game assembly.",
placedOn != null
? $"{className} was attached to the target GameObject."
: $"Place it on your idle/save manager GameObject: add_component_to_new_object (component=\"{className}\") after the hotload, or re-run with targetId.",
$"Fill in the idle math: edit the SimulateOffline(double seconds) TODO in {className}.cs -- it's called in TickSeconds ({tick.ToString( ci )}s) chunks so accumulation is frame-rate independent, e.g. GetComponent<Wallet>()?.AddMoney( (int)(rate * seconds) ).",
$"React to the catch-up total: {className}.OnOfflineProgressApplied += seconds => ShowWelcomeBack( seconds );",
$"MaxOfflineHours is {maxHours.ToString( ci )} -- offline time is clamped to this window (and negative elapsed from a clock rollback is treated as 0). Tune MaxOfflineHours / TickSeconds / AutosaveSeconds with set_property.",
"LastSeenUtc persists to FileSystem.Data (FileName, default offlineprogress.json) on a dirty-flag autosave heartbeat and on OnDisabled, so a crash loses at most AutosaveSeconds of wall-clock."
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_offline_progress failed: {ex.Message}" } );
}
}
static string BuildCode( string className, float maxHours, float tick, System.Globalization.CultureInfo ci )
{
string mh = maxHours.ToString( ci ) + "f";
string tk = tick.ToString( ci ) + "f";
return $@"using Sandbox;
using System;
/// <summary>
/// {className} -- offline / idle progress. Persists LastSeenUtc (DateTime) to FileSystem.Data
/// JSON on a dirty-flag autosave heartbeat and on OnDisabled (copies create_save_system's
/// persistence patterns). On enable it computes elapsed = now - LastSeenUtc, guards a clock
/// rollback (negative -> 0), clamps to MaxOfflineHours, then replays that time through the
/// SimulateOffline(double seconds) hook in fixed TickSeconds chunks so idle accumulation is
/// deterministic (frame-rate independent). Fires the static OnOfflineProgressApplied(seconds).
///
/// Add your idle math to SimulateOffline. Owner/host-only (IsProxy guarded) so a client can't
/// author their own offline earnings.
///
/// Usage:
/// {className}.OnOfflineProgressApplied += seconds => ShowWelcomeBack( seconds );
/// // inside SimulateOffline: GetComponent<Wallet>()?.AddMoney( (int)(rate * seconds) );
/// </summary>
public sealed class {className} : Component
{{
/// FileSystem.Data path the last-seen timestamp is written to.
[Property] public string FileName {{ get; set; }} = ""offlineprogress.json"";
/// Offline time is clamped to this many hours (stops a week-away from paying out a week).
[Property] public float MaxOfflineHours {{ get; set; }} = {mh};
/// SimulateOffline chunk size in seconds -- smaller = finer-grained deterministic replay.
[Property] public float TickSeconds {{ get; set; }} = {tk};
/// Autosave heartbeat cadence (seconds) that keeps LastSeenUtc fresh. 0 disables the heartbeat.
[Property] public float AutosaveSeconds {{ get; set; }} = 30f;
/// The persisted payload. Add your own idle fields here if you want them saved alongside.
public class OfflineData
{{
public DateTime LastSeenUtc {{ get; set; }} = DateTime.UtcNow;
}}
public OfflineData Data {{ get; private set; }} = new OfflineData();
public bool IsDirty {{ get; private set; }}
/// Fires after offline progress is applied on enable, with the (clamped) elapsed seconds.
public static Action<double> OnOfflineProgressApplied {{ get; set; }}
private TimeUntil _nextAutosave;
protected override void OnEnabled()
{{
if ( IsProxy ) return; // only the owning machine tracks offline time
Load();
ApplyOfflineProgress();
_nextAutosave = AutosaveSeconds;
}}
protected override void OnUpdate()
{{
if ( IsProxy || AutosaveSeconds <= 0f ) return;
if ( _nextAutosave )
{{
_nextAutosave = AutosaveSeconds;
MarkDirty(); // heartbeat: keep LastSeenUtc current for next session
if ( IsDirty ) Save();
}}
}}
protected override void OnDisabled()
{{
if ( !IsProxy && IsDirty ) Save();
}}
/// Mark the timestamp changed so the next autosave tick (or OnDisabled) writes it.
public void MarkDirty() => IsDirty = true;
public void Load()
{{
var loaded = FileSystem.Data.ReadJsonOrDefault<OfflineData>( FileName, null );
Data = loaded ?? new OfflineData {{ LastSeenUtc = DateTime.UtcNow }};
}}
public void Save()
{{
Data.LastSeenUtc = DateTime.UtcNow;
FileSystem.Data.WriteJson( FileName, Data );
IsDirty = false;
}}
/// <summary>
/// Compute elapsed offline time, clamp it, and replay it deterministically in TickSeconds
/// chunks. Called on enable; also safe to call manually.
/// </summary>
public void ApplyOfflineProgress()
{{
double elapsed = (DateTime.UtcNow - Data.LastSeenUtc).TotalSeconds;
if ( elapsed < 0.0 ) elapsed = 0.0; // clock-rollback guard
double cap = MaxOfflineHours * 3600.0;
if ( cap > 0.0 && elapsed > cap ) elapsed = cap; // clamp to the offline window
double tick = TickSeconds > 0f ? TickSeconds : 1.0;
double remaining = elapsed;
while ( remaining > 0.0 )
{{
double chunk = Math.Min( tick, remaining );
SimulateOffline( chunk );
remaining -= chunk;
}}
OnOfflineProgressApplied?.Invoke( elapsed );
Data.LastSeenUtc = DateTime.UtcNow; // don't re-apply on the next enable
MarkDirty();
Save();
}}
/// <summary>
/// TODO: apply idle earnings for `seconds` of offline time. Called in fixed TickSeconds
/// chunks so the total is deterministic regardless of frame rate, e.g.:
/// GetComponent<Wallet>()?.AddMoney( (int)(EarnRatePerSecond * seconds) );
/// </summary>
private void SimulateOffline( double seconds )
{{
// Idle math goes here.
}}
}}
";
}
}
/// <summary>
/// Shared placement helper for the loot/economy handlers -- mirrors the standard scaffold
/// placement (create_weighted_loot_table / create_economy_wallet / create_event_director).
/// </summary>
internal static class LootEconomyHelpers
{
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; }
}
}