Editor bridge handlers that generate several game-component C# source files for economy and save systems. Each handler reads parameters, builds a string of C# code for a scaffolded component or resource (currency account, idle economy, signed save, meta progression, Steam-stat-backed currency, loot table resource), writes it to disk, and optionally attaches a generated component to a scene GameObject.
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
// =============================================================================
// Economy & Save family (Track E) -- six Tier-2 scaffolds (code-gen; scene-mutating):
//
// create_currency_account audited host-authoritative ledger: [Sync(FromHost)]
// balance + Deposit/Withdraw/TryTransfer + fixed-size
// transaction ring buffer (Time.Now, reason, amount)
// create_idle_economy geometric bulk-buy: BaseCost * Growth^Owned, closed-form
// Buy 1 / Buy N / Buy Max, income tick auto-wired to a
// sibling wallet via TypeLibrary reflection
// create_signed_save tamper-evident save: FNV-1a signature over payload+salt,
// verify-on-load, clamp Sanitize() hook, forced reset on
// mismatch, versioned
// create_meta_progression between-runs roguelite meta: persistent meta-currency +
// unlock flags, Grant/TrySpend/Unlock/IsUnlocked,
// OnUnlocked static event, BankRun(int) run-end seam
// add_steam_stat_currency currency persisted over Sandbox.Services.Stats
// (SetValue/Flush; read-back via GetLocalPlayerStats)
// create_loot_table_resource GameResource-based loot tables ([AssetType], .loot files)
// with nested-table entries + depth-capped resolver component
//
// Compiles into the SAME editor assembly as MyEditorMenu.cs / ScaffoldHandlers.cs,
// so it reuses the shared statics on ClaudeBridge (TryResolveProjectPath,
// SanitizeIdentifier, SerializeGo) and ScaffoldHelpers (PrepareCodeFile /
// WriteCode / Utf8NoBom). Handler code here is UNSANDBOXED editor code (System.* fine).
//
// The C# *strings these handlers WRITE TO DISK* are SANDBOXED game code:
// - sealed Component classes, no virtual members.
// - [Sync(SyncFlags.FromHost)] for host-auth state (create_economy_wallet-verified);
// IsProxy guards on every mutation.
// - System.Math/MathF compile on this SDK; Array.Clone() is blocked (not used).
// - FileSystem.Data.ReadJsonOrDefault<T>/WriteJson + ReadAllText/WriteAllText/
// FileExists/DeleteFile all verified live via describe_type BaseFileSystem.
// - Sandbox.Json.Serialize(object)/Deserialize<T>(string) verified live.
// - Sandbox.Services.Stats: Increment(string,double), SetValue(string,double,string,object),
// Flush(), GetLocalPlayerStats(string packageIdent) -> Stats.PlayerStats (NESTED type;
// .Get(name) returns Stats.PlayerStat with .Value) -- all verified live. There is NO
// Stats.LocalPlayer on this SDK.
// - GameResourceAttribute is [Obsolete] on this SDK -- generated resources use
// [AssetType( Name=..., Extension=..., Category=... )] (the modern corpus pattern).
// - TypeLibrary wallet wiring copies the compile-verified create_idle_income shape:
// Game.TypeLibrary.GetType(comp.GetType()) -> Methods.FirstOrDefault(...) ->
// Invoke / InvokeWithReturn<bool> (both verified on MethodDescription);
// PropertyDescription.GetValue(object) verified live.
//
// Register(...) lines + the _sceneMutatingCommands additions live in MyEditorMenu.cs
// (orchestrator integration) to keep the files decoupled -- see the handoff summary.
// =============================================================================
// -----------------------------------------------------------------------------
// create_currency_account -- the audited sibling of create_economy_wallet.
// Wallet = simple money (AddMoney/TrySpend). Account = money + a fixed-size
// transaction ring buffer (timestamp, reason, amount, balance-after) with
// GetRecentTransactions() for ledger UIs / audit trails, plus TryTransfer
// between accounts. Folds the corpus asks create_economy_ledger / create_currency.
// -----------------------------------------------------------------------------
public class CreateCurrencyAccountHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "CurrencyAccount", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
long start = p.TryGetProperty( "startingBalance", out var sv ) && sv.TryGetInt64( out var sl ) ? sl : 0L;
int history = p.TryGetProperty( "historySize", out var hv ) && hv.TryGetInt32( out var hi ) ? hi : 32;
if ( history < 1 ) history = 1;
if ( history > 4096 ) history = 4096;
var code = BuildCode( className, start, history );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = EconomySaveHelpers.PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
startingBalance = start,
historySize = history,
placedOn,
note,
nextSteps = new[]
{
$"trigger_hotload to compile {className} into the game assembly.",
placedOn != null
? $"{className} was attached to the target GameObject."
: $"Place it on a per-player or bank GameObject: add_component_to_new_object (component=\"{className}\") after the hotload, or re-run with targetId.",
$"Move money host-side: GetComponent<{className}>()?.Deposit( 100, \"quest reward\" ); .Withdraw( 50, \"shop\" ); .TryTransfer( other, 25, \"trade\" );",
$"Read the ledger (host-side, newest first): foreach ( var t in GetComponent<{className}>().GetRecentTransactions() ) Log.Info( $\"{{t.Time}} {{t.Amount}} {{t.Reason}} -> {{t.BalanceAfter}}\" );",
$"Bind a HUD: GetComponent<{className}>().OnBalanceChanged = bal => {{ /* update label */ }}; Balance is [Sync(FromHost)] so clients can read it directly.",
$"History keeps the last {history} transactions (HistorySize, fixed once the first transaction is recorded); older entries are overwritten silently. The ledger itself is host-side only -- it does not replicate."
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_currency_account failed: {ex.Message}" } );
}
}
static string BuildCode( string className, long start, int history )
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
string st = start.ToString( ci );
string hs = history.ToString( ci );
return $@"using Sandbox;
using System;
using System.Collections.Generic;
/// <summary>
/// {className} -- a host-authoritative currency ACCOUNT: an audited ledger.
///
/// Use create_economy_wallet's Wallet when you just need money; use this when you need
/// money PLUS an audit trail. Balance is [Sync(SyncFlags.FromHost)] so only the host
/// writes it (clients can't author their own balance); every Deposit / Withdraw /
/// TryTransfer records a Transaction (Time.Now, reason, signed amount, balance-after)
/// into a fixed-size ring buffer, newest overwriting oldest past HistorySize.
///
/// The ledger is HOST-SIDE ONLY -- it does not replicate. Balance replicates; feed a
/// client-side ledger UI over an RPC if you need remote history. Single-player safe
/// (IsProxy is false with no networking active).
///
/// Usage (host-side):
/// GetComponent<{className}>()?.Deposit( 100, ""quest reward"" );
/// if ( GetComponent<{className}>().Withdraw( 50, ""shop"" ) ) {{ /* grant the item */ }}
/// GetComponent<{className}>().TryTransfer( otherAccount, 25, ""trade"" );
/// foreach ( var t in GetComponent<{className}>().GetRecentTransactions() ) {{ /* newest first */ }}
/// </summary>
public sealed class {className} : Component
{{
/// Balance the account opens with (host seeds it in OnStart).
[Property] public long StartingBalance {{ get; set; }} = {st}L;
/// Ring-buffer capacity. Fixed once the first transaction is recorded.
[Property] public int HistorySize {{ get; set; }} = {hs};
// Host-authoritative balance -- replicates to clients, only the host writes.
[Sync( SyncFlags.FromHost )] public long Balance {{ get; set; }}
/// One ledger line. Amount is signed: positive = deposit, negative = withdrawal.
public struct Transaction
{{
public float Time; // Time.Now when recorded
public long Amount; // signed delta
public string Reason; // free-form audit string
public long BalanceAfter; // balance after applying the delta
}}
/// Fired (on the writing machine) whenever the balance changes -- bind a HUD here.
public Action<long> OnBalanceChanged {{ get; set; }}
// Host-side ring buffer. _head = next write slot, _count = filled slots.
private Transaction[] _history;
private int _head;
private int _count;
protected override void OnStart()
{{
if ( IsProxy ) return; // only the authority seeds the balance
Balance = StartingBalance;
if ( StartingBalance != 0 ) Record( StartingBalance, ""opening balance"" );
OnBalanceChanged?.Invoke( Balance );
}}
public bool CanAfford( long amount ) => Balance >= amount;
/// <summary>Deposit (host-authoritative). Non-positive amounts are ignored.</summary>
public void Deposit( long amount, string reason = ""deposit"" )
{{
if ( IsProxy || amount <= 0 ) return;
Balance += amount;
Record( amount, reason );
OnBalanceChanged?.Invoke( Balance );
}}
/// <summary>Withdraw if affordable; returns false and changes nothing if not (host-authoritative).</summary>
public bool Withdraw( long amount, string reason = ""withdraw"" )
{{
if ( IsProxy || amount <= 0 ) return false;
if ( Balance < amount ) return false;
Balance -= amount;
Record( -amount, reason );
OnBalanceChanged?.Invoke( Balance );
return true;
}}
/// <summary>
/// Atomically move money into another account (host-authoritative). Both legs are
/// recorded in their respective ledgers. Returns false (nothing moves) when the
/// target is missing/self, the amount is non-positive, or funds are short.
/// </summary>
public bool TryTransfer( {className} to, long amount, string reason = ""transfer"" )
{{
if ( IsProxy || to == null || to == this || amount <= 0 ) return false;
if ( Balance < amount ) return false;
Balance -= amount;
Record( -amount, reason );
OnBalanceChanged?.Invoke( Balance );
to.ReceiveTransfer( amount, reason );
return true;
}}
// The receiving leg of TryTransfer -- runs on the host alongside the sending leg.
private void ReceiveTransfer( long amount, string reason )
{{
Balance += amount;
Record( amount, reason );
OnBalanceChanged?.Invoke( Balance );
}}
/// <summary>
/// The most recent transactions, NEWEST FIRST. max = 0 returns everything retained
/// (up to HistorySize). Host-side only -- proxies always get an empty list.
/// </summary>
public List<Transaction> GetRecentTransactions( int max = 0 )
{{
var list = new List<Transaction>();
if ( _history == null || _count == 0 ) return list;
int take = _count;
if ( max > 0 && max < take ) take = max;
for ( int i = 0; i < take; i++ )
{{
int idx = ( _head - 1 - i + _history.Length * 2 ) % _history.Length;
list.Add( _history[idx] );
}}
return list;
}}
private void Record( long amount, string reason )
{{
if ( _history == null )
_history = new Transaction[HistorySize < 1 ? 1 : HistorySize];
_history[_head] = new Transaction
{{
Time = Time.Now,
Amount = amount,
Reason = reason ?? """",
BalanceAfter = Balance
}};
_head = ( _head + 1 ) % _history.Length;
if ( _count < _history.Length ) _count++;
}}
}}
";
}
}
// -----------------------------------------------------------------------------
// create_idle_economy -- geometric bulk-buy purchasing. Generators follow the
// classic BaseCost * Growth^Owned curve; Buy 1 / Buy N / Buy Max use the
// closed-form geometric series (no loops). Income ticks grant into a sibling
// wallet's AddMoney via TypeLibrary reflection (the compile-verified
// create_idle_income pattern); purchases spend via the sibling's TrySpend and
// Buy Max reads its Money property the same way.
// -----------------------------------------------------------------------------
public class CreateIdleEconomyHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
if ( !ScaffoldHelpers.PrepareCodeFile( p, "IdleEconomy", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
float tick = p.TryGetProperty( "tickSeconds", out var tv ) && tv.TryGetSingle( out var tf ) ? tf : 1f;
if ( tick < 0.1f ) tick = 0.1f;
var gens = ParseGenerators( p );
var code = BuildCode( className, gens, tick, ci );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = EconomySaveHelpers.PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
generators = gens.Select( g => g.Name ).ToArray(),
tickSeconds = tick,
placedOn,
note,
nextSteps = new[]
{
$"trigger_hotload to compile {className} into the game assembly.",
placedOn != null
? $"{className} was attached to the target GameObject."
: $"Place it NEXT TO a wallet component (create_economy_wallet / create_currency_account) on the same GameObject: add_component_to_new_object (component=\"{className}\") after the hotload, or re-run with targetId.",
"It auto-wires the sibling wallet by reflection: income invokes AddMoney(long|int), purchases invoke TrySpend(long|int), Buy Max reads the Money property. No wallet sibling = purchases refused with a Log.Warning (never silent).",
$"Buy from game code: GetComponent<{className}>().TryBuy( 0, 1 ); .TryBuy( 0, 10 ); int n = GetComponent<{className}>().BuyMax( 0 );",
$"Show prices: double cost = GetComponent<{className}>().CostOf( 0, 10 ); int max = GetComponent<{className}>().MaxAffordable( 0 ); -- both closed-form geometric series, no loops.",
$"React to events: {className}.OnPurchased += ( index, count, cost ) => {{ }}; {className}.OnIncomeTick += ( amount, total ) => {{ }};",
"Tune GeneratorNames / BaseCosts / Growths / IncomesPerSecond (parallel lists) in the inspector or with set_property. Owned counts are host-side state (not replicated); pair with create_offline_progress for away-time earnings."
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_idle_economy failed: {ex.Message}" } );
}
}
internal struct GeneratorDef { public string Name; public float BaseCost; public float Growth; public float IncomePerSecond; }
static List<GeneratorDef> ParseGenerators( JsonElement p )
{
var result = new List<GeneratorDef>();
if ( p.TryGetProperty( "generators", out var gv ) && gv.ValueKind == JsonValueKind.Array )
{
foreach ( var item in gv.EnumerateArray() )
{
var g = new GeneratorDef
{
Name = item.TryGetProperty( "name", out var nv ) && !string.IsNullOrWhiteSpace( nv.GetString() ) ? nv.GetString() : "Generator",
BaseCost = item.TryGetProperty( "baseCost", out var bv ) && bv.TryGetSingle( out var bf ) ? bf : 15f,
Growth = item.TryGetProperty( "growth", out var grv ) && grv.TryGetSingle( out var grf ) ? grf : 1.15f,
IncomePerSecond = item.TryGetProperty( "incomePerSecond", out var iv ) && iv.TryGetSingle( out var inf ) ? inf : 0.5f
};
// Escape-strip: the name is baked into a generated string literal.
g.Name = ( g.Name ?? "Generator" ).Replace( "\\", "" ).Replace( "\"", "" );
if ( g.BaseCost <= 0f ) g.BaseCost = 1f;
if ( g.Growth < 1f ) g.Growth = 1f;
if ( g.IncomePerSecond < 0f ) g.IncomePerSecond = 0f;
result.Add( g );
}
}
if ( result.Count == 0 )
{
result.Add( new GeneratorDef { Name = "Cursor", BaseCost = 15f, Growth = 1.15f, IncomePerSecond = 0.5f } );
result.Add( new GeneratorDef { Name = "Farm", BaseCost = 200f, Growth = 1.15f, IncomePerSecond = 4f } );
result.Add( new GeneratorDef { Name = "Factory", BaseCost = 3000f, Growth = 1.12f, IncomePerSecond = 30f } );
}
return result;
}
static string BuildCode( string className, List<GeneratorDef> gens, float tick, System.Globalization.CultureInfo ci )
{
string nameLits = string.Join( ", ", gens.Select( g => $"\"{g.Name}\"" ) );
string costLits = string.Join( ", ", gens.Select( g => g.BaseCost.ToString( ci ) + "f" ) );
string growthLits = string.Join( ", ", gens.Select( g => g.Growth.ToString( ci ) + "f" ) );
string incomeLits = string.Join( ", ", gens.Select( g => g.IncomePerSecond.ToString( ci ) + "f" ) );
string tk = tick.ToString( ci ) + "f";
return $@"using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
/// <summary>
/// {className} -- a geometric idle economy: generators, bulk buying, passive income.
///
/// COST CURVE: buying copy k of generator i costs BaseCosts[i] * Growths[i]^k -- the
/// classic incremental-game curve. CostOf / MaxAffordable / TryBuy all use the CLOSED-FORM
/// geometric series (no per-copy loops), so Buy 1000 is the same math as Buy 1:
/// cost(n) = c0 * (g^n - 1) / (g - 1) where c0 = BaseCost * g^Owned
/// buyMax = floor( log_g( funds*(g-1)/c0 + 1 ) )
///
/// WALLET WIRING (TypeLibrary reflection -- no compile-time wallet dependency): income
/// invokes AddMoney(long|int) on the first sibling component that has one; purchases
/// invoke TrySpend(long|int); Buy Max reads the sibling's Money property. Works out of
/// the box next to a create_economy_wallet or create_currency_account scaffold. No wallet
/// sibling = purchases are REFUSED with a Log.Warning (never silent).
///
/// HOST-AUTHORITATIVE: all mutation is IsProxy-guarded; owned counts are host-side state
/// (not replicated -- replicate via your own [Sync]/RPC if clients need them). TotalEarned
/// is [Sync(FromHost)]. Single-player safe.
///
/// Usage:
/// GetComponent<{className}>().TryBuy( 0, 1 ); // Buy 1
/// GetComponent<{className}>().TryBuy( 0, 10 ); // Buy N
/// int bought = GetComponent<{className}>().BuyMax( 0 ); // Buy Max
/// {className}.OnPurchased += ( i, count, cost ) => {{ /* refresh shop UI */ }};
/// {className}.OnIncomeTick += ( amount, total ) => {{ /* +N popup */ }};
/// </summary>
public sealed class {className} : Component
{{
/// Generator display names -- parallel to BaseCosts / Growths / IncomesPerSecond.
[Property] public List<string> GeneratorNames {{ get; set; }} = new List<string> {{ {nameLits} }};
/// Cost of the FIRST copy of each generator (curve: BaseCost * Growth^Owned).
[Property] public List<float> BaseCosts {{ get; set; }} = new List<float> {{ {costLits} }};
/// Per-copy cost multiplier (1.15 = the classic curve). Values below 1 are treated as 1 (flat cost).
[Property] public List<float> Growths {{ get; set; }} = new List<float> {{ {growthLits} }};
/// Income each owned copy produces per second.
[Property] public List<float> IncomesPerSecond {{ get; set; }} = new List<float> {{ {incomeLits} }};
/// Seconds between income grants.
[Property] public float TickSeconds {{ get; set; }} = {tk};
/// Total income ever granted (host-authoritative, replicates to clients).
[Sync( SyncFlags.FromHost )] public float TotalEarned {{ get; set; }}
/// Fires host-side after a purchase: (generatorIndex, countBought, totalCost).
public static Action<int, int, double> OnPurchased {{ get; set; }}
/// Fires host-side after each income grant: (amount, newTotalEarned).
public static Action<float, float> OnIncomeTick {{ get; set; }}
// Host-side owned counts, parallel to the property lists.
private int[] _owned;
private TimeUntil _nextTick;
protected override void OnStart()
{{
_nextTick = TickSeconds;
}}
protected override void OnFixedUpdate()
{{
if ( IsProxy ) return;
if ( !_nextTick ) return;
_nextTick = TickSeconds;
EnsureOwned();
float amount = 0f;
for ( int i = 0; i < _owned.Length; i++ )
amount += _owned[i] * IncomeOf( i ) * TickSeconds;
if ( amount <= 0f ) return;
TotalEarned += amount;
GrantIncome( amount );
OnIncomeTick?.Invoke( amount, TotalEarned );
}}
/// <summary>Copies of a generator owned (host-side state; 0 on proxies).</summary>
public int GetOwned( int index )
{{
EnsureOwned();
return index >= 0 && index < _owned.Length ? _owned[index] : 0;
}}
/// <summary>
/// Closed-form cost of the next `count` copies of generator `index` from the current
/// owned count. 0 for an invalid index or non-positive count.
/// </summary>
public double CostOf( int index, int count )
{{
if ( count <= 0 || !ValidIndex( index ) ) return 0.0;
double g = GrowthOf( index );
double c0 = BaseCosts[index] * Math.Pow( g, GetOwned( index ) );
if ( Math.Abs( g - 1.0 ) < 0.0001 ) return c0 * count;
return c0 * ( Math.Pow( g, count ) - 1.0 ) / ( g - 1.0 );
}}
/// <summary>
/// Closed-form Buy-Max count against the sibling wallet's current Money.
/// 0 when nothing is affordable or no wallet sibling exposes a Money property.
/// </summary>
public int MaxAffordable( int index )
{{
if ( !ValidIndex( index ) ) return 0;
double funds = ReadWalletBalance();
if ( funds <= 0.0 ) return 0;
double g = GrowthOf( index );
double c0 = BaseCosts[index] * Math.Pow( g, GetOwned( index ) );
if ( c0 <= 0.0 ) return 0;
if ( Math.Abs( g - 1.0 ) < 0.0001 ) return (int) Math.Floor( funds / c0 );
return (int) Math.Floor( Math.Log( funds * ( g - 1.0 ) / c0 + 1.0 ) / Math.Log( g ) );
}}
/// <summary>
/// Buy `count` copies if the sibling wallet's TrySpend accepts the closed-form cost
/// (rounded up to whole currency). Host-only; false when unaffordable or no wallet.
/// </summary>
public bool TryBuy( int index, int count )
{{
if ( IsProxy || count <= 0 || !ValidIndex( index ) ) return false;
EnsureOwned();
double cost = CostOf( index, count );
if ( !SpendFromWallet( cost ) ) return false;
_owned[index] += count;
OnPurchased?.Invoke( index, count, cost );
return true;
}}
/// <summary>
/// Buy as many copies as the wallet can afford. Returns the count bought (0 = none).
/// Steps down once past a whole-currency rounding edge rather than failing.
/// </summary>
public int BuyMax( int index )
{{
int n = MaxAffordable( index );
while ( n > 0 )
{{
if ( TryBuy( index, n ) ) return n;
n--; // ceil-rounding edge: the closed form said n, the wallet said no -- step down
}}
return 0;
}}
private bool ValidIndex( int index )
=> BaseCosts != null && index >= 0 && index < BaseCosts.Count;
private double GrowthOf( int index )
{{
float g = Growths != null && index < Growths.Count ? Growths[index] : 1.15f;
return g < 1f ? 1.0 : g;
}}
private float IncomeOf( int index )
=> IncomesPerSecond != null && index < IncomesPerSecond.Count && index >= 0 ? IncomesPerSecond[index] : 0f;
private void EnsureOwned()
{{
int size = BaseCosts?.Count ?? 0;
int names = GeneratorNames?.Count ?? 0;
if ( names > size ) size = names;
if ( size < 1 ) size = 1;
if ( _owned == null )
{{
_owned = new int[size];
}}
else if ( _owned.Length < size )
{{
var grown = new int[size];
for ( int i = 0; i < _owned.Length; i++ ) grown[i] = _owned[i];
_owned = grown;
}}
}}
// ---- sibling-wallet wiring (TypeLibrary reflection; no hard wallet dependency) ----
// Deliver income: AddMoney(long|int) on the first sibling that has one.
private void GrantIncome( float amount )
{{
foreach ( var comp in Components.GetAll() )
{{
if ( comp == this || comp is null ) continue;
var type = Game.TypeLibrary?.GetType( comp.GetType() );
var method = type?.Methods?.FirstOrDefault( m => m.Name == ""AddMoney"" );
if ( method == null ) continue;
try {{ method.Invoke( comp, new object[] {{ (long) amount }} ); return; }}
catch {{ }}
try {{ method.Invoke( comp, new object[] {{ (int) amount }} ); return; }}
catch {{ /* wrong signature -- keep looking */ }}
}}
// No wallet sibling -- TotalEarned still accumulates; read it directly.
}}
// Spend: TrySpend(long|int) on the first sibling that has one. Never silent on failure.
private bool SpendFromWallet( double cost )
{{
if ( cost <= 0.0 ) return false;
long rounded = (long) Math.Ceiling( cost );
foreach ( var comp in Components.GetAll() )
{{
if ( comp == this || comp is null ) continue;
var type = Game.TypeLibrary?.GetType( comp.GetType() );
var method = type?.Methods?.FirstOrDefault( m => m.Name == ""TrySpend"" );
if ( method == null ) continue;
try {{ return method.InvokeWithReturn<bool>( comp, new object[] {{ rounded }} ); }}
catch {{ }}
try {{ return method.InvokeWithReturn<bool>( comp, new object[] {{ (int) rounded }} ); }}
catch {{ /* wrong signature -- keep looking */ }}
}}
Log.Warning( $""[{className}] No sibling wallet with TrySpend found -- add a create_economy_wallet / create_currency_account component next to it. Purchase refused."" );
return false;
}}
// Read funds for Buy Max: the first sibling exposing a numeric Money property.
private double ReadWalletBalance()
{{
foreach ( var comp in Components.GetAll() )
{{
if ( comp == this || comp is null ) continue;
var type = Game.TypeLibrary?.GetType( comp.GetType() );
var prop = type?.Properties?.FirstOrDefault( pp => pp.Name == ""Money"" || pp.Name == ""Balance"" );
if ( prop == null ) continue;
try
{{
object v = prop.GetValue( comp );
if ( v is long l ) return l;
if ( v is int i ) return i;
if ( v is float f ) return f;
if ( v is double d ) return d;
}}
catch {{ /* unreadable -- keep looking */ }}
}}
return 0.0;
}}
}}
";
}
}
// -----------------------------------------------------------------------------
// create_signed_save -- tamper-evident save file. The payload POCO is serialized
// to JSON (Sandbox.Json), FNV-1a-64 hashed together with a salt + version, and
// written inside a signed envelope via FileSystem.Data. Load verifies the
// signature; a mismatch = forced reset (delete + defaults) + OnTampered event.
// Clamp-on-load Sanitize() hook + versioning copy create_save_system's shape.
// -----------------------------------------------------------------------------
public class CreateSignedSaveHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "SignedSave", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
var ci = System.Globalization.CultureInfo.InvariantCulture;
string fileName = p.TryGetProperty( "fileName", out var fn ) && !string.IsNullOrWhiteSpace( fn.GetString() ) ? fn.GetString() : "save_signed.json";
int version = p.TryGetProperty( "version", out var vv ) && vv.TryGetInt32( out var vi ) ? vi : 1;
float autosave = p.TryGetProperty( "autosaveSeconds", out var av ) && av.TryGetSingle( out var af ) ? af : 10f;
string salt = p.TryGetProperty( "salt", out var sv ) && !string.IsNullOrWhiteSpace( sv.GetString() )
? sv.GetString()
: Guid.NewGuid().ToString( "N" ); // unique per generated file by default
// These are baked into generated string literals -- strip escape characters.
fileName = fileName.Replace( "\\", "" ).Replace( "\"", "" );
salt = salt.Replace( "\\", "" ).Replace( "\"", "" );
var code = BuildCode( className, fileName, version.ToString( ci ), autosave.ToString( ci ) + "f", salt );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = EconomySaveHelpers.PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
fileName,
version,
autosaveSeconds = autosave,
placedOn,
note,
nextSteps = new[]
{
$"trigger_hotload to compile {className} into the game assembly.",
placedOn != null
? $"{className} was attached to the target GameObject."
: $"Place it on your save-manager GameObject: add_component_to_new_object (component=\"{className}\") after the hotload, or re-run with targetId.",
$"Add your game fields to the SaveData inner class in {className}.cs, extend Sanitize() to clamp them, and bump Version when the shape changes.",
$"Use it: GetComponent<{className}>().Data.Money += 100; GetComponent<{className}>().MarkDirty(); -- the dirty-flag autosave (or OnDestroy) writes and re-signs.",
$"React: {className}.OnLoaded += d => {{ }}; {className}.OnSaved += d => {{ }}; {className}.OnTampered += reason => {{ /* tell the player their save was reset */ }};",
"TAMPER = FORCED RESET: an edited payload fails the FNV-1a signature check on load, the file is DELETED and defaults are used (OnTampered fires with the reason). This is tamper-EVIDENT, not cryptographically secure -- the salt ships in the game code, so a determined user can re-sign; it stops casual notepad edits, not reverse engineers."
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_signed_save failed: {ex.Message}" } );
}
}
static string BuildCode( string className, string fileName, string version, string autosave, string salt )
{
return $@"using Sandbox;
using System;
/// <summary>
/// {className} -- a tamper-evident, versioned save system.
///
/// The SaveData payload is serialized to JSON, hashed with FNV-1a-64 over
/// payload + version + salt, and written inside a signed envelope to
/// FileSystem.Data. Load re-computes the signature: a mismatch (hand-edited or
/// corrupt file) triggers a FORCED RESET -- the file is deleted, defaults are
/// used, and the static OnTampered event fires. A version mismatch starts fresh
/// (add migrations in Load if you need them). Loaded values pass through the
/// Sanitize() clamp hook so even a re-signed save can't smuggle absurd values.
///
/// NOT cryptography: the salt ships inside the game assembly, so this is
/// tamper-EVIDENT (stops notepad edits), not tamper-PROOF.
///
/// Host/owner-only (IsProxy-guarded). Dirty-flag autosave every AutosaveSeconds
/// plus a final save in OnDestroy.
///
/// Usage:
/// var save = GetComponent<{className}>();
/// save.Data.Money += 100; save.MarkDirty();
/// {className}.OnTampered += reason => Log.Warning( $""save reset: {{reason}}"" );
/// </summary>
public sealed class {className} : Component
{{
/// FileSystem.Data path the signed envelope is written to.
[Property] public string FileName {{ get; set; }} = ""{fileName}"";
/// Autosave cadence in seconds. 0 disables the heartbeat (OnDestroy still saves).
[Property] public float AutosaveSeconds {{ get; set; }} = {autosave};
/// Save-shape version -- bump when SaveData changes so old files start fresh.
public const int Version = {version};
// Baked-in signing salt (unique to this generated file). Changing it invalidates existing saves.
private const string Salt = ""{salt}"";
/// The save payload. Add your own fields here; clamp them in Sanitize().
public class SaveData
{{
public int Money {{ get; set; }}
public int Day {{ get; set; }} = 1;
// Add game fields here.
}}
/// The envelope actually written to disk: version + raw payload JSON + signature.
public class SaveEnvelope
{{
public int Version {{ get; set; }}
public string Payload {{ get; set; }}
public ulong Signature {{ get; set; }}
}}
public SaveData Data {{ get; private set; }} = new SaveData();
public bool IsDirty {{ get; private set; }}
/// Fires after a successful Load() with the loaded (sanitized) data.
public static Action<SaveData> OnLoaded {{ get; set; }}
/// Fires after every Save().
public static Action<SaveData> OnSaved {{ get; set; }}
/// Fires when the signature check fails and the save is force-reset. Arg = reason.
public static Action<string> OnTampered {{ get; set; }}
private TimeUntil _nextAutosave;
protected override void OnStart()
{{
if ( IsProxy ) return; // only the owning machine loads
Load();
_nextAutosave = AutosaveSeconds;
}}
protected override void OnUpdate()
{{
if ( IsProxy || AutosaveSeconds <= 0f ) return;
if ( _nextAutosave )
{{
_nextAutosave = AutosaveSeconds;
if ( IsDirty ) Save();
}}
}}
protected override void OnDestroy()
{{
if ( !IsProxy && IsDirty ) Save();
}}
/// Mark the data changed so the next autosave tick (or OnDestroy) writes + re-signs it.
public void MarkDirty() => IsDirty = true;
public void Load()
{{
var envelope = FileSystem.Data.ReadJsonOrDefault<SaveEnvelope>( FileName, null );
if ( envelope == null )
{{
// Missing or unreadable envelope: start fresh (not treated as tampering).
Data = new SaveData();
IsDirty = true;
}}
else if ( envelope.Version != Version )
{{
// Old save shape: start fresh (add migrations here later).
Data = new SaveData();
IsDirty = true;
}}
else if ( envelope.Payload == null || ComputeSignature( envelope.Payload ) != envelope.Signature )
{{
ForceReset( ""signature mismatch -- save file was modified outside the game"" );
return; // ForceReset already fired OnLoaded
}}
else
{{
SaveData loaded = null;
try {{ loaded = Json.Deserialize<SaveData>( envelope.Payload ); }}
catch {{ }}
if ( loaded == null )
{{
ForceReset( ""payload failed to parse despite a valid signature"" );
return;
}}
Data = Sanitize( loaded );
IsDirty = false;
}}
OnLoaded?.Invoke( Data );
}}
public void Save()
{{
var payload = Json.Serialize( Data );
var envelope = new SaveEnvelope
{{
Version = Version,
Payload = payload,
Signature = ComputeSignature( payload )
}};
FileSystem.Data.WriteJson( FileName, envelope );
IsDirty = false;
OnSaved?.Invoke( Data );
}}
/// <summary>Delete the save file and reset to defaults. Fires OnTampered then OnLoaded.</summary>
public void ForceReset( string reason )
{{
try
{{
if ( FileSystem.Data.FileExists( FileName ) )
FileSystem.Data.DeleteFile( FileName );
}}
catch {{ }}
Data = new SaveData();
IsDirty = true;
OnTampered?.Invoke( reason ?? ""forced reset"" );
OnLoaded?.Invoke( Data );
}}
/// Clamp-on-load: keep loaded values inside sane ranges so even a re-signed
/// save can't smuggle absurd values. Extend per field you add.
private SaveData Sanitize( SaveData d )
{{
if ( d.Money < 0 ) d.Money = 0;
if ( d.Day < 1 ) d.Day = 1;
return d;
}}
// FNV-1a 64-bit over payload + version + salt. Deterministic, allocation-light.
private static ulong ComputeSignature( string payload )
{{
const ulong offsetBasis = 14695981039346656037UL;
const ulong prime = 1099511628211UL;
ulong hash = offsetBasis;
string material = payload + ""|"" + Version + ""|"" + Salt;
for ( int i = 0; i < material.Length; i++ )
{{
hash ^= material[i];
hash *= prime;
}}
return hash;
}}
}}
";
}
}
// -----------------------------------------------------------------------------
// create_meta_progression -- the between-runs roguelite meta layer: persistent
// meta-currency + unlock-flag dictionary saved to FileSystem.Data JSON.
// Grant/TrySpend/Unlock/IsUnlocked + a BankRun(int) run-end seam + a static
// OnUnlocked event. Persistence copies create_save_system's dirty-flag shape.
// -----------------------------------------------------------------------------
public class CreateMetaProgressionHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "MetaProgression", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
var ci = System.Globalization.CultureInfo.InvariantCulture;
string fileName = p.TryGetProperty( "fileName", out var fn ) && !string.IsNullOrWhiteSpace( fn.GetString() ) ? fn.GetString() : "meta.json";
int version = p.TryGetProperty( "version", out var vv ) && vv.TryGetInt32( out var vi ) ? vi : 1;
float autosave = p.TryGetProperty( "autosaveSeconds", out var av ) && av.TryGetSingle( out var af ) ? af : 10f;
fileName = fileName.Replace( "\\", "" ).Replace( "\"", "" );
var code = BuildCode( className, fileName, version.ToString( ci ), autosave.ToString( ci ) + "f" );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = EconomySaveHelpers.PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
fileName,
version,
placedOn,
note,
nextSteps = new[]
{
$"trigger_hotload to compile {className} into the game assembly.",
placedOn != null
? $"{className} was attached to the target GameObject."
: $"Place it on a persistent manager GameObject (one that exists in your hub/menu scene): add_component_to_new_object (component=\"{className}\") after the hotload, or re-run with targetId.",
$"At run end, bank the earnings: GetComponent<{className}>().BankRun( runCurrencyEarned ); -- it grants and saves immediately.",
$"Gate content: if ( GetComponent<{className}>().TrySpend( 50 ) ) GetComponent<{className}>().Unlock( \"double_jump\" ); then check IsUnlocked( \"double_jump\" ) when building the player.",
$"React to unlocks anywhere: {className}.OnUnlocked += key => {{ /* flash the new item in the meta shop */ }};",
"MetaCurrency and the unlock flags persist to FileSystem.Data across sessions (dirty-flag autosave + OnDestroy). IsProxy-guarded: in multiplayer each machine banks only its own meta file."
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_meta_progression failed: {ex.Message}" } );
}
}
static string BuildCode( string className, string fileName, string version, string autosave )
{
return $@"using Sandbox;
using System;
using System.Collections.Generic;
/// <summary>
/// {className} -- the between-runs roguelite meta layer.
///
/// Persists a meta-currency plus an unlock-flag dictionary to FileSystem.Data JSON
/// (dirty-flag autosave + OnDestroy, create_save_system's shape). During a run you earn
/// normal run-currency; at run end call BankRun(earned) to convert it into persistent
/// meta-currency. Spend meta-currency on permanent Unlock() flags and gate content with
/// IsUnlocked(). The static OnUnlocked event fires on every new unlock.
///
/// Owner-only (IsProxy-guarded): each machine banks only its own meta file.
///
/// Usage:
/// GetComponent<{className}>().BankRun( 120 ); // run over
/// if ( GetComponent<{className}>().TrySpend( 50 ) )
/// GetComponent<{className}>().Unlock( ""double_jump"" );
/// if ( GetComponent<{className}>().IsUnlocked( ""double_jump"" ) ) {{ /* enable it */ }}
/// {className}.OnUnlocked += key => {{ /* celebrate */ }};
/// </summary>
public sealed class {className} : Component
{{
/// FileSystem.Data path the meta state is written to.
[Property] public string FileName {{ get; set; }} = ""{fileName}"";
/// Autosave cadence in seconds. 0 disables the heartbeat (OnDestroy still saves).
[Property] public float AutosaveSeconds {{ get; set; }} = {autosave};
/// The persisted payload. Bump Version when the shape changes so old files start fresh.
public class MetaData
{{
public int Version {{ get; set; }} = {version};
public long MetaCurrency {{ get; set; }}
public int RunsBanked {{ get; set; }}
public Dictionary<string, bool> Unlocks {{ get; set; }} = new Dictionary<string, bool>();
}}
public MetaData Data {{ get; private set; }} = new MetaData();
public bool IsDirty {{ get; private set; }}
/// Fires (on the owning machine) when a key is unlocked for the FIRST time.
public static Action<string> OnUnlocked {{ get; set; }}
/// Fires whenever MetaCurrency changes -- bind the meta-shop balance label here.
public Action<long> OnCurrencyChanged {{ get; set; }}
private TimeUntil _nextAutosave;
protected override void OnStart()
{{
if ( IsProxy ) return; // only the owning machine loads
Load();
_nextAutosave = AutosaveSeconds;
}}
protected override void OnUpdate()
{{
if ( IsProxy || AutosaveSeconds <= 0f ) return;
if ( _nextAutosave )
{{
_nextAutosave = AutosaveSeconds;
if ( IsDirty ) Save();
}}
}}
protected override void OnDestroy()
{{
if ( !IsProxy && IsDirty ) Save();
}}
/// <summary>Add meta-currency. Non-positive amounts are ignored.</summary>
public void Grant( long amount )
{{
if ( IsProxy || amount <= 0 ) return;
Data.MetaCurrency += amount;
IsDirty = true;
OnCurrencyChanged?.Invoke( Data.MetaCurrency );
}}
/// <summary>Spend meta-currency if affordable; false and no change otherwise.</summary>
public bool TrySpend( long amount )
{{
if ( IsProxy || amount <= 0 ) return false;
if ( Data.MetaCurrency < amount ) return false;
Data.MetaCurrency -= amount;
IsDirty = true;
OnCurrencyChanged?.Invoke( Data.MetaCurrency );
return true;
}}
/// <summary>Set a permanent unlock flag. Idempotent; OnUnlocked fires only the first time. Saves immediately.</summary>
public void Unlock( string key )
{{
if ( IsProxy || string.IsNullOrEmpty( key ) ) return;
if ( Data.Unlocks.TryGetValue( key, out var already ) && already ) return;
Data.Unlocks[key] = true;
Save(); // unlocks are precious -- write through immediately
OnUnlocked?.Invoke( key );
}}
/// <summary>True when a key has been permanently unlocked.</summary>
public bool IsUnlocked( string key )
=> !string.IsNullOrEmpty( key ) && Data.Unlocks.TryGetValue( key, out var v ) && v;
/// <summary>
/// Run-end seam: convert this run's earnings into persistent meta-currency and
/// save immediately. Call it from your round machine's end-of-run transition.
/// </summary>
public void BankRun( int earned )
{{
if ( IsProxy ) return;
if ( earned > 0 ) Data.MetaCurrency += earned;
Data.RunsBanked += 1;
Save();
OnCurrencyChanged?.Invoke( Data.MetaCurrency );
}}
/// Mark the data changed so the next autosave tick (or OnDestroy) writes it.
public void MarkDirty() => IsDirty = true;
public void Load()
{{
var loaded = FileSystem.Data.ReadJsonOrDefault<MetaData>( FileName, null );
if ( loaded == null || loaded.Version != {version} )
{{
Data = new MetaData();
IsDirty = true;
}}
else
{{
if ( loaded.MetaCurrency < 0 ) loaded.MetaCurrency = 0;
if ( loaded.RunsBanked < 0 ) loaded.RunsBanked = 0;
if ( loaded.Unlocks == null ) loaded.Unlocks = new Dictionary<string, bool>();
Data = loaded;
IsDirty = false;
}}
OnCurrencyChanged?.Invoke( Data.MetaCurrency );
}}
public void Save()
{{
FileSystem.Data.WriteJson( FileName, Data );
IsDirty = false;
}}
}}
";
}
}
// -----------------------------------------------------------------------------
// add_steam_stat_currency -- currency persisted over Sandbox.Services.Stats.
// Verified live on this SDK: static Stats.Increment(string,double),
// Stats.SetValue(string,double,string,object), Stats.Flush(), and
// Stats.GetLocalPlayerStats(string packageIdent) returning the NESTED
// Stats.PlayerStats (Get(name) -> Stats.PlayerStat with .Value). There is
// NO Stats.LocalPlayer property on this SDK.
// -----------------------------------------------------------------------------
public class AddSteamStatCurrencyHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "SteamStatCurrency", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
string statName = p.TryGetProperty( "statName", out var sv ) && !string.IsNullOrWhiteSpace( sv.GetString() ) ? sv.GetString() : "currency";
string packageIdent = p.TryGetProperty( "packageIdent", out var pv ) && !string.IsNullOrWhiteSpace( pv.GetString() ) ? pv.GetString() : "";
bool flushEveryChange = p.TryGetProperty( "flushEveryChange", out var fv ) && fv.ValueKind == JsonValueKind.True;
// Baked into generated string literals -- strip escape characters.
statName = statName.Replace( "\\", "" ).Replace( "\"", "" );
packageIdent = packageIdent.Replace( "\\", "" ).Replace( "\"", "" );
var code = BuildCode( className, statName, packageIdent, flushEveryChange );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = EconomySaveHelpers.PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
statName,
packageIdent = string.IsNullOrEmpty( packageIdent ) ? "(Game.Ident -- the running package)" : packageIdent,
flushEveryChange,
placedOn,
note,
nextSteps = new[]
{
$"trigger_hotload to compile {className} into the game assembly.",
placedOn != null
? $"{className} was attached to the target GameObject."
: $"Place it on the LOCAL player's GameObject (each player writes only their own Steam stat): add_component_to_new_object (component=\"{className}\") after the hotload, or re-run with targetId.",
$"Use it: GetComponent<{className}>().Add( 25 ); if ( GetComponent<{className}>().TrySpend( 10 ) ) {{ }} -- Balance is the in-session truth; every change pushes Stats.SetValue.",
$"React: {className}.OnBalanceLoaded += bal => {{ }}; and instance OnBalanceChanged for HUD labels. Wait for IsLoaded before showing the balance -- the read-back is async.",
"CLOUD SEMANTICS: stats writes are buffered by the backend (Flush() pushes; the component flushes on destroy) and only apply to the LOCAL Steam user -- calling it for another player silently does nothing. Read-back is eventually consistent and can lag minutes; the in-session Balance property is authoritative while playing.",
"Stats persist per Steam account per package ident -- dev sessions without a real published ident may read back nothing (you'll get balance 0 + a log line). This is Steam-cloud persistence, not a local save file; pair with create_signed_save if you need offline saves."
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"add_steam_stat_currency failed: {ex.Message}" } );
}
}
static string BuildCode( string className, string statName, string packageIdent, bool flushEveryChange )
{
string flushLit = flushEveryChange ? "true" : "false";
return $@"using Sandbox;
using Sandbox.Services;
using System;
/// <summary>
/// {className} -- a currency persisted over Sandbox.Services.Stats (Steam cloud).
///
/// The stat named StatName stores the ABSOLUTE balance (Stats.SetValue on every change);
/// on start the component reads it back asynchronously via
/// Stats.GetLocalPlayerStats(ident).Refresh() -> Get(StatName).Value and fires
/// OnBalanceLoaded. While playing, the in-session Balance property is the authoritative
/// value -- the cloud read-back is eventually consistent and can lag behind writes.
///
/// SCOPE: stats writes apply only to the LOCAL Steam user (writes for other players
/// silently no-op) and persist per package ident. Attach this to the local player's
/// GameObject; IsProxy guards keep remote copies inert. Dev sessions without a real
/// published ident may read back nothing (balance starts at 0).
///
/// Usage:
/// GetComponent<{className}>().Add( 25 );
/// if ( GetComponent<{className}>().TrySpend( 10 ) ) {{ /* grant the thing */ }}
/// {className}.OnBalanceLoaded += bal => {{ /* show the wallet */ }};
/// </summary>
public sealed class {className} : Component
{{
/// The Sandbox.Services stat that stores the balance.
[Property] public string StatName {{ get; set; }} = ""{statName}"";
/// Package ident to read stats from. Empty = the running package (Game.Ident).
[Property] public string PackageIdent {{ get; set; }} = ""{packageIdent}"";
/// Push Stats.Flush() after every change (rate-limited by the backend) instead of
/// relying on the buffered flush + the OnDestroy flush.
[Property] public bool FlushEveryChange {{ get; set; }} = {flushLit};
/// In-session balance -- authoritative while playing. Cloud value catches up on flush.
public double Balance {{ get; private set; }}
/// True once the async cloud read-back has completed (successfully or not).
public bool IsLoaded {{ get; private set; }}
/// Fires once after the cloud read-back completes, with the loaded balance.
public static Action<double> OnBalanceLoaded {{ get; set; }}
/// Fires on every balance change (including the initial load) -- bind a HUD here.
public Action<double> OnBalanceChanged {{ get; set; }}
protected override void OnStart()
{{
if ( IsProxy ) return; // only the local player's machine touches their stats
_ = LoadAsync();
}}
protected override void OnDestroy()
{{
if ( !IsProxy && IsLoaded ) Stats.Flush();
}}
/// <summary>Re-read the balance from the stats backend (async; also runs on start).</summary>
public async System.Threading.Tasks.Task LoadAsync()
{{
double loaded = 0.0;
try
{{
string ident = string.IsNullOrWhiteSpace( PackageIdent ) ? Game.Ident : PackageIdent;
var stats = Stats.GetLocalPlayerStats( ident );
await stats.Refresh();
loaded = stats.Get( StatName ).Value;
}}
catch ( Exception ex )
{{
Log.Warning( $""[{className}] Stat read-back failed ({{ex.Message}}) -- starting at 0. Stats need a valid package ident + Steam session."" );
}}
Balance = loaded;
IsLoaded = true;
OnBalanceLoaded?.Invoke( Balance );
OnBalanceChanged?.Invoke( Balance );
}}
public bool CanAfford( double amount ) => Balance >= amount;
/// <summary>Add currency and push the new balance to the stats backend. Non-positive ignored.</summary>
public void Add( double amount )
{{
if ( IsProxy || amount <= 0.0 ) return;
Balance += amount;
Push();
}}
/// <summary>Spend if affordable; returns false and changes nothing if not.</summary>
public bool TrySpend( double amount )
{{
if ( IsProxy || amount <= 0.0 ) return false;
if ( Balance < amount ) return false;
Balance -= amount;
Push();
return true;
}}
/// <summary>Force-push buffered stat writes to the backend now (rate-limited upstream).</summary>
public void Flush() => Stats.Flush();
// Write the absolute balance to the stat and notify listeners.
private void Push()
{{
Stats.SetValue( StatName, Balance, null, null );
if ( FlushEveryChange ) Stats.Flush();
OnBalanceChanged?.Invoke( Balance );
}}
}}
";
}
}
// -----------------------------------------------------------------------------
// create_loot_table_resource -- the data-asset sibling of create_weighted_loot_table.
// Generates ONE .cs containing: an entry POCO (name, weight, optional nested table
// reference), a GameResource loot-table asset type ([AssetType] -- the modern
// attribute; GameResourceAttribute is [Obsolete] on this SDK), and a resolver
// Component that rolls a table by cumulative weight with a resolve depth cap.
// Designers author .loot files in the asset browser; code rolls them.
// -----------------------------------------------------------------------------
public class CreateLootTableResourceHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !ScaffoldHelpers.PrepareCodeFile( p, "LootTableResource", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
string extension = p.TryGetProperty( "extension", out var ev ) && !string.IsNullOrWhiteSpace( ev.GetString() ) ? ev.GetString() : "loot";
string title = p.TryGetProperty( "title", out var tv ) && !string.IsNullOrWhiteSpace( tv.GetString() ) ? tv.GetString() : "Loot Table";
int maxDepth = p.TryGetProperty( "maxDepth", out var mv ) && mv.TryGetInt32( out var mi ) ? mi : 4;
if ( maxDepth < 0 ) maxDepth = 0;
if ( maxDepth > 16 ) maxDepth = 16;
// Extension: lowercase alphanumerics only.
var extChars = new StringBuilder();
foreach ( var c in extension.ToLowerInvariant() )
if ( ( c >= 'a' && c <= 'z' ) || ( c >= '0' && c <= '9' ) ) extChars.Append( c );
extension = extChars.Length > 0 ? extChars.ToString() : "loot";
// Title is baked into an attribute string literal.
title = title.Replace( "\\", "" ).Replace( "\"", "" );
var resolverClass = className + "Resolver";
var code = BuildCode( className, resolverClass, extension, title, maxDepth );
ScaffoldHelpers.WriteCode( fullPath, code );
// Placement attaches the RESOLVER component (the resource itself is an asset type, not a component).
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = EconomySaveHelpers.PlaceOnTarget( tid.GetString(), resolverClass, out note );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
resolverClass,
extension,
maxDepth,
placedOn,
note,
nextSteps = new[]
{
$"trigger_hotload to compile {className} + {resolverClass} into the game assembly -- the '.{extension}' asset type registers on compile.",
$"Author tables as ASSETS: in the editor asset browser, New > {title} creates a .{extension} file; fill Entries (Name, Weight, optional NestedTable reference to another .{extension}) in the inspector.",
placedOn != null
? $"{resolverClass} was attached to the target GameObject -- assign its Table property to a .{extension} asset (set_property with the asset path)."
: $"Attach the resolver: add_component_to_new_object (component=\"{resolverClass}\") after the hotload, then set its Table property to a .{extension} asset path.",
$"Roll from game code (host-side): string drop = GetComponent<{resolverClass}>().Roll(); {resolverClass}.OnLoot += ( go, item ) => {{ }};",
$"Nested tables: an entry with a NestedTable rolls INTO that table instead of dropping its Name -- capped at MaxDepth ({maxDepth}) with a self-reference guard, so cycles terminate.",
"Use create_weighted_loot_table instead when you want a single inline component with no asset files; use create_gacha_drop_table for pity + duplicate mechanics. Pick an extension that is NOT a suffix of a built-in one (e.g. avoid 'cfg') or ResourceLibrary.GetAll will pick up engine files as phantom instances."
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_loot_table_resource failed: {ex.Message}" } );
}
}
static string BuildCode( string className, string resolverClass, string extension, string title, int maxDepth )
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
string md = maxDepth.ToString( ci );
return $@"using Sandbox;
using System;
using System.Collections.Generic;
/// <summary>
/// One row of a {className} asset. Amount is picked by cumulative weight; when
/// NestedTable is set the roll continues INTO that table instead of dropping Name.
/// </summary>
public sealed class {className}Entry
{{
/// What drops when this entry wins (ignored when NestedTable is set).
[Property] public string Name {{ get; set; }} = """";
/// Relative chance. Bigger = more likely. Entries with weight <= 0 never win.
[Property] public float Weight {{ get; set; }} = 1f;
/// Optional: roll this table instead of dropping Name (depth-capped on resolve).
[Property] public {className} NestedTable {{ get; set; }}
}}
/// <summary>
/// {className} -- a designer-authored loot table ASSET (.{extension} files).
///
/// Each .{extension} file holds weighted entries; entries may reference other
/// .{extension} assets as nested tables (rarity tiers, per-biome sub-tables).
/// Resolve() rolls by cumulative weight and follows nested references up to a
/// depth cap, so cyclic references terminate. Author the files in the editor
/// asset browser; roll them with {resolverClass} or call Resolve() directly.
/// </summary>
[AssetType( Name = ""{title}"", Extension = ""{extension}"", Category = ""Game"" )]
public sealed class {className} : GameResource
{{
/// The weighted rows of this table.
[Property] public List<{className}Entry> Entries {{ get; set; }} = new List<{className}Entry>();
/// <summary>
/// Roll once: pick an entry by cumulative weight; if it references a nested table,
/// keep rolling into it until a plain entry wins or maxDepth is exhausted (then the
/// deepest entry's Name is returned). Null when the table is empty. HOST-authoritative:
/// roll on the host and replicate the result -- clients rolling their own loot is the
/// classic economy exploit.
/// </summary>
public string Resolve( int maxDepth = {md} )
{{
var entry = RollEntry();
if ( entry == null ) return null;
if ( entry.NestedTable != null && entry.NestedTable != this && maxDepth > 0 )
return entry.NestedTable.Resolve( maxDepth - 1 );
return entry.Name;
}}
// Cumulative-weight pick over Entries. Null when empty; first entry when all weights are zero.
private {className}Entry RollEntry()
{{
if ( Entries == null || Entries.Count == 0 ) return null;
float total = 0f;
foreach ( var e in Entries )
if ( e != null && e.Weight > 0f ) total += e.Weight;
if ( total <= 0f ) return Entries[0];
float roll = Game.Random.Float( 0f, total );
float cumulative = 0f;
{className}Entry winner = null;
foreach ( var e in Entries )
{{
if ( e == null || e.Weight <= 0f ) continue;
winner = e;
cumulative += e.Weight;
if ( roll < cumulative ) break;
}}
return winner;
}}
}}
/// <summary>
/// {resolverClass} -- rolls a {className} asset from the scene.
///
/// Assign Table to a .{extension} asset in the inspector (or via set_property with the
/// asset path). Roll() resolves through nested tables up to MaxDepth and fires the
/// static OnLoot event with the winning item name. Call it host-side and replicate
/// the result yourself ([Sync] or an [Rpc.Broadcast]).
///
/// Usage:
/// string drop = GetComponent<{resolverClass}>().Roll();
/// {resolverClass}.OnLoot += ( go, item ) => Log.Info( $""{{go.Name}} got {{item}}"" );
/// </summary>
public sealed class {resolverClass} : Component
{{
/// The loot table asset this resolver rolls.
[Property] public {className} Table {{ get; set; }}
/// How deep nested-table references may chain before the roll settles.
[Property] public int MaxDepth {{ get; set; }} = {md};
/// Fires (on the rolling machine) when Roll() picks a winner: (roller, itemName).
public static Action<GameObject, string> OnLoot {{ get; set; }}
/// <summary>
/// Roll the assigned table once. Null (with a warning) when no Table is assigned or
/// the table is empty. HOST-authoritative by convention -- see the class summary.
/// </summary>
public string Roll()
{{
if ( Table == null )
{{
Log.Warning( $""[{resolverClass}] No Table assigned on {{GameObject.Name}} -- assign a .{extension} asset."" );
return null;
}}
var drop = Table.Resolve( MaxDepth );
if ( drop != null ) OnLoot?.Invoke( GameObject, drop );
return drop;
}}
}}
";
}
}
/// <summary>
/// Shared placement helper for the economy/save handlers -- mirrors the standard scaffold
/// placement (create_economy_wallet / create_weighted_loot_table / LootEconomyHelpers).
/// </summary>
internal static class EconomySaveHelpers
{
public static object PlaceOnTarget( string targetId, string className, out string note )
{
note = null;
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null ) { note = "No active scene to place into."; return null; }
if ( !Guid.TryParse( targetId, out var guid ) ) { note = "Invalid targetId GUID."; return null; }
var go = scene.Directory.FindByGuid( guid );
if ( go == null ) { note = $"Target GameObject not found: {targetId}"; return null; }
var typeDesc = Game.TypeLibrary.GetType( className );
if ( typeDesc == null )
{
note = $"Generated {className}.cs but it is not in the TypeLibrary yet -- trigger_hotload, then add it with add_component_with_properties.";
return null;
}
try { go.Components.Create( typeDesc ); return ClaudeBridge.SerializeGo( go ); }
catch ( Exception ex ) { note = $"Placement failed ({ex.Message})."; return null; }
}
}