Editor-side playtest harness and bridge handlers. PlaytestRunner runs scripted gameplay steps (move, look, action, jump, set, wait, capture, assert) on a resolved player controller inside the editor frame loop, records a transcript and pass/fail summary. Handlers parse JSON step lists, start a Job, report live status, and abort running jobs.
using Editor;
using Sandbox;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
// ═══════════════════════════════════════════════════════════════════════════
// PLAYTEST HARNESS — playtest / playtest_status (the gameplay-verification frontier)
//
// Same assembly as MyEditorMenu.cs (reuses IBridgeHandler + ClaudeBridge helpers).
// Unsandboxed editor code → System.Math / System.Reflection are fine here.
//
// WHY AN IN-ADDON RUNNER (not TS round-trips):
// Verifying a gameplay LOOP needs input + state-reads + assertions that time-align
// with the game's frames. Two facts (proven live on the Gravehold player) force this:
// 1. The facepunch PlayerController reads Input.AnalogMove each frame and OVERWRITES
// a WishVelocity you set — UNLESS you set `UseInputControls=false` first. With it
// off, setting WishVelocity moved the player 0→526u. So a move step must flip that
// toggle, drive WishVelocity per frame, and ZERO it after (it persists otherwise).
// 2. Transient state (a jump's z-velocity) is gone by the time a SEPARATE bridge call
// lands — so assertions must be evaluated IN-FRAME, inside the editor frame loop.
// => one async job, ticked by [EditorEvent.Frame], runs a step list and records a
// pass/fail transcript. TS only starts it (playtest) and polls it (playtest_status).
//
// Step verbs: move · look · lookDelta · action · jump · set · wait · capture · assert
// { "move": {"x":1}, "frames":60 } analog move (auto UseInputControls=false)
// { "look": {"pitch":0,"yaw":90,"roll":0} } set EyeAngles
// { "lookDelta": {"yaw":2}, "frames":30 } sweep EyeAngles
// { "action": "use", "frames":20 } hold a named input action (rising-edge safe)
// { "jump": "0,0,400" } invoke the controller's Jump(velocity)
// { "set": {"component":"PlayerController","property":"UseInputControls","to":"false"} }
// { "wait": 10 } advance N frames
// { "capture": "after-jump" } screenshot the live player POV → path in transcript
// { "assert": {"read":"Displacement","op":">","value":50,"desc":"moved >50u from start"} }
//
// assert.read = "WorldPosition[.x|.y|.z]" (the controller's GameObject), "Displacement"
// (scalar distance moved from job start — the facing-independent movement proof), OR
// "<Component>.<Property>[.x|.y|.z|.Count]" (a component on the player).
// assert.op = > < >= <= == != changed (changed = differs from the value at job start)
// ═══════════════════════════════════════════════════════════════════════════
internal static class PlaytestRunner
{
internal class StepSpec
{
public string Kind;
public int Frames = 1;
public Vector2 Move;
public Angles Look; public bool HasLook;
public Angles LookDelta;
public string Action;
public Vector3 JumpVel;
public string SetComponent, SetProperty, SetValue;
public string AssertRead, AssertOp, AssertValue, AssertDesc;
public string CaptureLabel;
public float MoveSpeed = 160f;
}
internal class Job
{
public Guid TargetId;
public string ComponentType;
public Component Controller; // resolved once
public GameObject Anchor; // controller.GameObject — the player object
public Vector3 StartPos; // Anchor.WorldPosition at job start (for the "Displacement" read)
public List<StepSpec> Steps;
public int Index;
public int FrameInStep;
public List<object> Transcript = new();
public int Passed, Failed;
public bool DisabledInput; // we flipped UseInputControls=false → restore at teardown
public string HeldAction; // currently-held action (release at step exit / teardown)
public Dictionary<string, string> Baselines = new(); // read-key → value at job start (for "changed")
public bool Done;
public string EndReason;
public bool Started;
}
private static Job _job;
private static readonly object _lock = new();
private static object _lastSummary;
internal static void Start( Job job ) { lock ( _lock ) { _job = job; _lastSummary = null; } }
internal static object ConsumeSummary() { lock ( _lock ) { return _lastSummary; } }
internal static bool IsActive() { lock ( _lock ) { return _job != null; } }
/// <summary>Stop the running job NOW: teardown (restore input state) + summarize as aborted.</summary>
internal static object Abort()
{
lock ( _lock )
{
if ( _job == null )
return new { aborted = false, note = "No playtest job is running. playtest_status shows the last summary." };
var j = _job;
Teardown( j );
_lastSummary = Summarize( j, "aborted via playtest_abort" );
_job = null;
return new
{
aborted = true,
stepsRun = j.Index,
passed = j.Passed,
failed = j.Failed,
note = "Job stopped, input state restored. The partial transcript is available via playtest_status."
};
}
}
internal static object LiveSnapshot()
{
lock ( _lock )
{
if ( _job == null ) return null;
return new { active = true, step = _job.Index, totalSteps = _job.Steps.Count, passed = _job.Passed, failed = _job.Failed };
}
}
[EditorEvent.Frame]
public static void OnFrame()
{
Job j;
lock ( _lock ) { j = _job; }
if ( j == null ) return;
if ( !Game.IsPlaying )
{
Teardown( j );
lock ( _lock ) { _lastSummary = Summarize( j, "play mode ended before completion" ); _job = null; }
return;
}
try
{
// Resolve the controller + anchor once.
if ( !j.Started )
{
ResolveAnchor( j );
CaptureBaselines( j );
j.Started = true;
}
if ( j.Index >= j.Steps.Count )
{
Teardown( j );
lock ( _lock ) { _lastSummary = Summarize( j, "completed" ); _job = null; }
return;
}
var step = j.Steps[j.Index];
if ( j.FrameInStep == 0 ) StepEnter( j, step );
StepTick( j, step );
j.FrameInStep++;
if ( j.FrameInStep >= System.Math.Max( 1, step.Frames ) )
{
StepExit( j, step );
j.Index++;
j.FrameInStep = 0;
}
}
catch ( Exception ex )
{
// Never let the ticker throw (it'd spam every frame). Record + stop.
j.Transcript.Add( new { step = j.Index, kind = j.Index < j.Steps.Count ? j.Steps[j.Index].Kind : "?", error = ex.Message } );
Teardown( j );
lock ( _lock ) { _lastSummary = Summarize( j, $"runner error: {ex.Message}" ); _job = null; }
}
}
// ── Step lifecycle ─────────────────────────────────────────────────────────
static void StepEnter( Job j, StepSpec s )
{
switch ( s.Kind )
{
case "move":
EnsureInputDisabled( j ); // so WishVelocity isn't overwritten by the controller
break;
case "jump":
DoJump( j, s );
break;
case "set":
DoSet( j, s );
break;
case "assert":
DoAssert( j, s );
break;
case "capture":
DoCapture( j, s );
break;
}
}
static void StepTick( Job j, StepSpec s )
{
switch ( s.Kind )
{
case "move":
{
if ( j.Controller == null ) return;
var td = Game.TypeLibrary.GetType( j.Controller.GetType() );
var yaw = ( ReadAngles( j.Controller, td, "EyeAngles" ) ?? j.Controller.WorldRotation.Angles() ).yaw;
var rot = Rotation.From( 0f, yaw, 0f );
var wish = rot.Forward * s.Move.x + rot.Left * s.Move.y;
if ( wish.Length > 1f ) wish = wish.Normal;
wish *= s.MoveSpeed;
TrySetVector3( j.Controller, td, "WishVelocity", wish );
break;
}
case "look":
{
if ( j.Controller == null ) return;
var td = Game.TypeLibrary.GetType( j.Controller.GetType() );
var a = s.Look; a.pitch = System.Math.Clamp( a.pitch, -89f, 89f );
TrySetAngles( j.Controller, td, "EyeAngles", a );
break;
}
case "lookDelta":
{
if ( j.Controller == null ) return;
var td = Game.TypeLibrary.GetType( j.Controller.GetType() );
var cur = ReadAngles( j.Controller, td, "EyeAngles" ) ?? new Angles();
cur.pitch = System.Math.Clamp( cur.pitch + s.LookDelta.pitch, -89f, 89f );
cur.yaw += s.LookDelta.yaw;
cur.roll += s.LookDelta.roll;
TrySetAngles( j.Controller, td, "EyeAngles", cur );
break;
}
case "action":
try { Sandbox.Input.SetAction( s.Action, true ); } catch { }
j.HeldAction = s.Action;
break;
}
}
static void StepExit( Job j, StepSpec s )
{
switch ( s.Kind )
{
case "move":
if ( j.Controller != null )
{
var td = Game.TypeLibrary.GetType( j.Controller.GetType() );
TrySetVector3( j.Controller, td, "WishVelocity", Vector3.Zero ); // stop — WishVelocity persists otherwise
}
j.Transcript.Add( new { step = j.Index, kind = "move", frames = s.Frames, move = $"{s.Move.x},{s.Move.y}" } );
break;
case "action":
try { Sandbox.Input.SetAction( s.Action, false ); } catch { }
j.HeldAction = null;
j.Transcript.Add( new { step = j.Index, kind = "action", action = s.Action, frames = s.Frames } );
break;
case "look":
j.Transcript.Add( new { step = j.Index, kind = "look", look = $"{s.Look.pitch},{s.Look.yaw},{s.Look.roll}" } );
break;
case "lookDelta":
j.Transcript.Add( new { step = j.Index, kind = "lookDelta", frames = s.Frames } );
break;
case "wait":
j.Transcript.Add( new { step = j.Index, kind = "wait", frames = s.Frames } );
break;
// jump/set/assert already recorded their result in StepEnter.
}
}
// ── Actions ────────────────────────────────────────────────────────────────
static void DoJump( Job j, StepSpec s )
{
if ( j.Controller == null )
{
j.Transcript.Add( new { step = j.Index, kind = "jump", ok = false, error = "no controller" } );
j.Failed++;
return;
}
try
{
var m = j.Controller.GetType().GetMethod( "Jump", new[] { typeof( Vector3 ) } );
if ( m == null )
{
j.Transcript.Add( new { step = j.Index, kind = "jump", ok = false, error = "controller has no Jump(Vector3)" } );
j.Failed++;
return;
}
m.Invoke( j.Controller, new object[] { s.JumpVel } );
j.Transcript.Add( new { step = j.Index, kind = "jump", ok = true, velocity = $"{s.JumpVel.x},{s.JumpVel.y},{s.JumpVel.z}" } );
}
catch ( Exception ex )
{
j.Transcript.Add( new { step = j.Index, kind = "jump", ok = false, error = ex.Message } );
j.Failed++;
}
}
static void DoSet( Job j, StepSpec s )
{
try
{
var comp = FindComponent( j, s.SetComponent );
if ( comp == null )
{
j.Transcript.Add( new { step = j.Index, kind = "set", ok = false, error = $"component '{s.SetComponent}' not found" } );
j.Failed++; return;
}
var td = Game.TypeLibrary.GetType( comp.GetType() );
var pd = td?.Properties.FirstOrDefault( pp => pp.Name == s.SetProperty );
if ( pd == null )
{
j.Transcript.Add( new { step = j.Index, kind = "set", ok = false, error = $"property '{s.SetProperty}' not found" } );
j.Failed++; return;
}
object typed = CoerceTo( pd.PropertyType, s.SetValue );
pd.SetValue( comp, typed );
j.Transcript.Add( new { step = j.Index, kind = "set", ok = true, target = $"{s.SetComponent}.{s.SetProperty}", to = s.SetValue } );
}
catch ( Exception ex )
{
j.Transcript.Add( new { step = j.Index, kind = "set", ok = false, error = ex.Message } );
j.Failed++;
}
}
static void DoAssert( Job j, StepSpec s )
{
string actual = null;
bool ok = false;
string err = null;
try
{
object val = ResolveRead( j, s.AssertRead, out err );
if ( err == null )
{
actual = ValueToString( val );
ok = Compare( j, s.AssertRead, val, s.AssertOp, s.AssertValue, out err );
}
}
catch ( Exception ex ) { err = ex.Message; }
if ( ok ) j.Passed++; else j.Failed++;
j.Transcript.Add( new
{
step = j.Index,
kind = "assert",
ok,
desc = s.AssertDesc,
read = s.AssertRead,
op = s.AssertOp,
expected = s.AssertValue,
actual,
error = err,
} );
}
// ── Capture: screenshot the live player-POV camera (diagnostic, never pass/fail) ──
static void DoCapture( Job j, StepSpec s )
{
try
{
var scene = Game.ActiveScene;
var cam = scene != null ? VisualHelpers.FindMainCamera( scene ) : null;
if ( cam == null )
{
j.Transcript.Add( new { step = j.Index, kind = "capture", ok = false, label = s.CaptureLabel, error = "no main camera in the running scene" } );
return;
}
var bmp = new Bitmap( 1280, 720 );
cam.RenderToBitmap( bmp, true ); // renderUI=true → the running game incl. HUD
string path = System.IO.Path.Combine( System.IO.Path.GetTempPath(), $"bridge_playtest_{System.Guid.NewGuid():N}.png" );
System.IO.File.WriteAllBytes( path, bmp.ToPng() );
j.Transcript.Add( new { step = j.Index, kind = "capture", ok = true, label = s.CaptureLabel, path } );
}
catch ( Exception ex )
{
j.Transcript.Add( new { step = j.Index, kind = "capture", ok = false, label = s.CaptureLabel, error = ex.Message } );
}
}
// ── Read resolution: "WorldPosition.x" | "<Component>.<Prop>[.sub]" ──────────
static object ResolveRead( Job j, string read, out string err )
{
err = null;
if ( string.IsNullOrEmpty( read ) ) { err = "empty read"; return null; }
var parts = read.Split( '.' );
object cur;
int sub;
var head = parts[0];
if ( head == "Displacement" )
{
if ( j.Anchor == null ) { err = "no player object resolved"; return null; }
return (object) ( j.Anchor.WorldPosition - j.StartPos ).Length; // scalar — facing-independent movement proof
}
if ( head == "WorldPosition" || head == "LocalPosition" || head == "WorldRotation" || head == "WorldScale" )
{
if ( j.Anchor == null ) { err = "no player object resolved"; return null; }
cur = head switch
{
"WorldPosition" => (object) j.Anchor.WorldPosition,
"LocalPosition" => j.Anchor.LocalPosition,
"WorldRotation" => j.Anchor.WorldRotation.Angles(),
"WorldScale" => j.Anchor.WorldScale,
_ => null,
};
sub = 1;
}
else
{
if ( parts.Length < 2 ) { err = $"read '{read}' needs <Component>.<Property>"; return null; }
var comp = FindComponent( j, head );
if ( comp == null ) { err = $"component '{head}' not found on player"; return null; }
var td = Game.TypeLibrary.GetType( comp.GetType() );
var pd = td?.Properties.FirstOrDefault( pp => pp.Name == parts[1] );
if ( pd == null ) { err = $"property '{head}.{parts[1]}' not found"; return null; }
cur = pd.GetValue( comp );
sub = 2;
}
for ( int i = sub; i < parts.Length && cur != null; i++ )
cur = SubAccess( cur, parts[i] );
return cur;
}
static object SubAccess( object v, string sub )
{
if ( v is Vector3 v3 ) return sub switch { "x" => v3.x, "y" => v3.y, "z" => v3.z, _ => null };
if ( v is Vector2 v2 ) return sub switch { "x" => v2.x, "y" => v2.y, _ => null };
if ( v is Angles an ) return sub switch { "pitch" => an.pitch, "yaw" => an.yaw, "roll" => an.roll, _ => null };
if ( sub == "Count" )
{
if ( v is ICollection col ) return col.Count;
if ( v is IEnumerable en ) return en.Cast<object>().Count();
}
// generic property fallback
try { return v.GetType().GetProperty( sub )?.GetValue( v ); } catch { return null; }
}
static bool Compare( Job j, string readKey, object actual, string op, string expected, out string err )
{
err = null;
if ( op == "changed" )
return j.Baselines.TryGetValue( readKey, out var b ) ? ValueToString( actual ) != b : true;
// numeric comparison when both sides are numbers
if ( TryNum( actual, out var an ) && float.TryParse( expected, NumberStyles.Float, CultureInfo.InvariantCulture, out var en ) )
{
return op switch
{
">" => an > en, "<" => an < en, ">=" => an >= en, "<=" => an <= en,
"==" => System.Math.Abs( an - en ) < 0.0001f, "!=" => System.Math.Abs( an - en ) >= 0.0001f,
_ => SetErr( out err, $"bad numeric op '{op}'" ),
};
}
// bool / string equality
var astr = ValueToString( actual );
return op switch
{
"==" => string.Equals( astr, expected, StringComparison.OrdinalIgnoreCase ),
"!=" => !string.Equals( astr, expected, StringComparison.OrdinalIgnoreCase ),
_ => SetErr( out err, $"op '{op}' needs numeric operands (got '{astr}' vs '{expected}')" ),
};
}
static bool SetErr( out string err, string msg ) { err = msg; return false; }
static bool TryNum( object v, out float f )
{
f = 0f;
switch ( v )
{
case float ff: f = ff; return true;
case double dd: f = (float) dd; return true;
case int ii: f = ii; return true;
case long ll: f = ll; return true;
case short ss: f = ss; return true;
case byte bb: f = bb; return true;
default: return false;
}
}
static string ValueToString( object v )
{
if ( v == null ) return "null";
if ( v is bool b ) return b ? "True" : "False";
if ( v is Vector3 v3 ) return $"{v3.x},{v3.y},{v3.z}";
if ( v is float f ) return f.ToString( CultureInfo.InvariantCulture );
return v.ToString();
}
// ── Setup / teardown ────────────────────────────────────────────────────────
static void ResolveAnchor( Job j )
{
var scene = Game.ActiveScene;
if ( scene == null ) return;
Component c = null;
if ( j.TargetId != Guid.Empty )
{
var go = ClaudeBridge.ResolveGameObject( scene, j.TargetId.ToString() );
if ( go != null ) c = FindControllerOn( go, j.ComponentType );
}
if ( c == null )
{
foreach ( var obj in scene.GetAllObjects( true ) )
{
c = FindControllerOn( obj, j.ComponentType );
if ( c != null ) break;
}
}
j.Controller = c;
j.Anchor = c?.GameObject;
}
static void CaptureBaselines( Job j )
{
// Anchor position at job start — the origin for the "Displacement" read.
if ( j.Anchor != null ) j.StartPos = j.Anchor.WorldPosition;
// Record the initial value of every "changed" read so we can diff later.
foreach ( var s in j.Steps.Where( x => x.Kind == "assert" && x.AssertOp == "changed" ) )
{
var v = ResolveRead( j, s.AssertRead, out var e );
if ( e == null ) j.Baselines[s.AssertRead] = ValueToString( v );
}
}
static void EnsureInputDisabled( Job j )
{
if ( j.DisabledInput || j.Controller == null ) return;
var td = Game.TypeLibrary.GetType( j.Controller.GetType() );
var pd = td?.Properties.FirstOrDefault( pp => pp.Name == "UseInputControls" );
if ( pd != null && pd.PropertyType == typeof( bool ) )
{
pd.SetValue( j.Controller, false );
j.DisabledInput = true;
}
}
static void Teardown( Job j )
{
try
{
if ( !string.IsNullOrEmpty( j.HeldAction ) )
try { Sandbox.Input.SetAction( j.HeldAction, false ); } catch { }
if ( j.Controller != null && j.Controller.IsValid() )
{
var td = Game.TypeLibrary.GetType( j.Controller.GetType() );
TrySetVector3( j.Controller, td, "WishVelocity", Vector3.Zero );
if ( j.DisabledInput )
{
var pd = td?.Properties.FirstOrDefault( pp => pp.Name == "UseInputControls" );
pd?.SetValue( j.Controller, true );
}
}
}
catch { }
}
static object Summarize( Job j, string reason )
{
return new
{
finished = true,
reason,
verdict = j.Failed == 0 ? "PASS" : "FAIL",
passed = j.Passed,
failed = j.Failed,
stepsRun = j.Index,
totalSteps = j.Steps.Count,
controller = j.Controller?.GetType().Name,
controllerResolved = j.Controller != null,
transcript = j.Transcript,
};
}
// ── Reflection helpers (self-contained; mirror PlayInputDriver's idiom) ──────
internal static Component FindControllerOn( GameObject go, string componentType )
{
if ( go == null ) return null;
var all = go.Components.GetAll().ToList();
if ( !string.IsNullOrEmpty( componentType ) )
return all.FirstOrDefault( c => c.GetType().Name.Equals( componentType, StringComparison.OrdinalIgnoreCase ) );
var exact = all.FirstOrDefault( c => c.GetType().Name == "PlayerController" );
if ( exact != null ) return exact;
return all.FirstOrDefault( c =>
{
var n = c.GetType().Name;
if ( !n.EndsWith( "Controller", StringComparison.OrdinalIgnoreCase ) ) return false;
var td = Game.TypeLibrary.GetType( c.GetType() );
return td != null && td.Properties.Any( pp => pp.Name == "EyeAngles" || pp.Name == "WishVelocity" );
} );
}
static Component FindComponent( Job j, string typeName )
{
if ( j.Anchor == null || string.IsNullOrEmpty( typeName ) ) return null;
return j.Anchor.Components.GetAll().FirstOrDefault( c => c.GetType().Name.Equals( typeName, StringComparison.OrdinalIgnoreCase ) );
}
static Angles? ReadAngles( Component c, TypeDescription td, string member )
{
try
{
var pd = td?.Properties.FirstOrDefault( pp => pp.Name == member );
if ( pd == null ) return null;
var v = pd.GetValue( c );
if ( v is Angles a ) return a;
if ( v is Rotation r ) return r.Angles();
}
catch { }
return null;
}
static bool TrySetAngles( Component c, TypeDescription td, string member, Angles value )
{
try
{
var pd = td?.Properties.FirstOrDefault( pp => pp.Name == member );
if ( pd == null ) return false;
if ( pd.PropertyType == typeof( Angles ) ) { pd.SetValue( c, value ); return true; }
if ( pd.PropertyType == typeof( Rotation ) ) { pd.SetValue( c, Rotation.From( value ) ); return true; }
}
catch { }
return false;
}
static bool TrySetVector3( Component c, TypeDescription td, string member, Vector3 value )
{
try
{
var pd = td?.Properties.FirstOrDefault( pp => pp.Name == member );
if ( pd == null || pd.PropertyType != typeof( Vector3 ) ) return false;
pd.SetValue( c, value );
return true;
}
catch { return false; }
}
static object CoerceTo( Type t, string raw )
{
if ( t == typeof( bool ) ) return raw == "true" || raw == "True" || raw == "1";
if ( t == typeof( float ) ) return float.Parse( raw, NumberStyles.Float, CultureInfo.InvariantCulture );
if ( t == typeof( int ) ) return (int) float.Parse( raw, NumberStyles.Float, CultureInfo.InvariantCulture );
if ( t == typeof( Vector3 ) ) return ClaudeBridge.ParseVector3Flexible( ParseElement( raw ) );
return raw;
}
static JsonElement ParseElement( string raw )
{
// Wrap a bare "x,y,z" or scalar as a JSON string element for ParseVector3Flexible.
using var doc = JsonDocument.Parse( JsonSerializer.Serialize( raw ) );
return doc.RootElement.Clone();
}
}
/// <summary>
/// playtest — run a scripted gameplay-verification sequence in play mode (async, in the
/// editor frame loop) and record a pass/fail transcript. Requires start_play first.
/// </summary>
public class PlaytestHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
if ( !Game.IsPlaying )
return Task.FromResult<object>( new { error = "playtest requires play mode — call start_play first" } );
if ( PlaytestRunner.IsActive() )
return Task.FromResult<object>( new { error = "a playtest is already running — poll playtest_status until it finishes" } );
if ( !p.TryGetProperty( "steps", out var stepsEl ) || stepsEl.ValueKind != JsonValueKind.Array )
return Task.FromResult<object>( new { error = "steps (an array of step objects) is required" } );
try
{
var job = new PlaytestRunner.Job { Steps = new List<PlaytestRunner.StepSpec>() };
if ( p.TryGetProperty( "id", out var idEl ) && idEl.ValueKind == JsonValueKind.String
&& Guid.TryParse( idEl.GetString(), out var gid ) )
job.TargetId = gid;
if ( p.TryGetProperty( "component", out var compEl ) && compEl.ValueKind == JsonValueKind.String )
job.ComponentType = compEl.GetString();
int idx = 0;
foreach ( var stepEl in stepsEl.EnumerateArray() )
{
var spec = ParseStep( stepEl, idx, out var perr );
if ( spec == null )
return Task.FromResult<object>( new { error = $"step {idx}: {perr}" } );
job.Steps.Add( spec );
idx++;
}
if ( job.Steps.Count == 0 )
return Task.FromResult<object>( new { error = "steps is empty" } );
PlaytestRunner.Start( job );
return Task.FromResult<object>( new
{
started = true,
steps = job.Steps.Count,
note = "Playtest running ASYNC in the editor frame loop. Poll playtest_status until finished:true, then read the transcript (pass/fail per step).",
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"playtest failed: {ex.Message}" } );
}
}
static PlaytestRunner.StepSpec ParseStep( JsonElement e, int idx, out string err )
{
err = null;
if ( e.ValueKind != JsonValueKind.Object ) { err = "not an object"; return null; }
var s = new PlaytestRunner.StepSpec();
int? framesOverride = ( e.TryGetProperty( "frames", out var fEl ) && fEl.TryGetInt32( out var fi ) )
? System.Math.Clamp( fi, 1, 1800 ) : (int?) null;
if ( e.TryGetProperty( "moveSpeed", out var msEl ) && msEl.TryGetSingle( out var ms ) ) s.MoveSpeed = ms;
if ( e.TryGetProperty( "move", out var mEl ) )
{
s.Kind = "move"; s.Move = ParseMove( mEl ); s.Frames = framesOverride ?? 30;
}
else if ( e.TryGetProperty( "look", out var lEl ) )
{
s.Kind = "look"; s.Look = ParseAngles( lEl ); s.HasLook = true; s.Frames = framesOverride ?? 1;
}
else if ( e.TryGetProperty( "lookDelta", out var ldEl ) )
{
s.Kind = "lookDelta"; s.LookDelta = ParseAngles( ldEl ); s.Frames = framesOverride ?? 30;
}
else if ( e.TryGetProperty( "action", out var aEl ) && aEl.ValueKind == JsonValueKind.String )
{
s.Kind = "action"; s.Action = aEl.GetString(); s.Frames = framesOverride ?? 20;
}
else if ( e.TryGetProperty( "jump", out var jEl ) )
{
s.Kind = "jump"; s.JumpVel = ClaudeBridge.ParseVector3Flexible( jEl ); s.Frames = 1;
}
else if ( e.TryGetProperty( "set", out var setEl ) && setEl.ValueKind == JsonValueKind.Object )
{
s.Kind = "set"; s.Frames = 1;
s.SetComponent = GetStr( setEl, "component" );
s.SetProperty = GetStr( setEl, "property" );
s.SetValue = GetStr( setEl, "to" ) ?? GetStr( setEl, "value" );
if ( s.SetComponent == null || s.SetProperty == null ) { err = "set needs {component, property, to}"; return null; }
}
else if ( e.TryGetProperty( "wait", out var wEl ) && wEl.TryGetInt32( out var wf ) )
{
s.Kind = "wait"; s.Frames = System.Math.Clamp( wf, 1, 1800 );
}
else if ( e.TryGetProperty( "capture", out var capEl ) )
{
s.Kind = "capture"; s.Frames = 1;
s.CaptureLabel = capEl.ValueKind == JsonValueKind.String ? capEl.GetString() : null;
}
else if ( e.TryGetProperty( "assert", out var asEl ) && asEl.ValueKind == JsonValueKind.Object )
{
s.Kind = "assert"; s.Frames = 1;
s.AssertRead = GetStr( asEl, "read" );
s.AssertOp = GetStr( asEl, "op" ) ?? "==";
s.AssertDesc = GetStr( asEl, "desc" );
if ( asEl.TryGetProperty( "value", out var vEl ) )
s.AssertValue = vEl.ValueKind == JsonValueKind.String ? vEl.GetString() : vEl.GetRawText();
if ( s.AssertRead == null ) { err = "assert needs {read, op, value}"; return null; }
}
else
{
err = "unknown step (expected one of: move, look, lookDelta, action, jump, set, wait, capture, assert)";
return null;
}
return s;
}
static string GetStr( JsonElement o, string key )
=> o.TryGetProperty( key, out var v ) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
static Vector2 ParseMove( JsonElement el )
{
float x = 0f, y = 0f;
if ( el.ValueKind == JsonValueKind.Object )
{
if ( el.TryGetProperty( "x", out var xp ) && xp.TryGetSingle( out var xf ) ) x = xf;
if ( el.TryGetProperty( "y", out var yp ) && yp.TryGetSingle( out var yf ) ) y = yf;
}
else if ( el.ValueKind == JsonValueKind.String )
{
var pr = ( el.GetString() ?? "" ).Split( ',' );
if ( pr.Length > 0 ) float.TryParse( pr[0], NumberStyles.Float, CultureInfo.InvariantCulture, out x );
if ( pr.Length > 1 ) float.TryParse( pr[1], NumberStyles.Float, CultureInfo.InvariantCulture, out y );
}
var v = new Vector2( x, y );
if ( v.Length > 1f ) v = v.Normal;
return v;
}
static Angles ParseAngles( JsonElement el )
{
float pitch = 0f, yaw = 0f, roll = 0f;
if ( el.ValueKind == JsonValueKind.Object )
{
if ( el.TryGetProperty( "pitch", out var pp ) && pp.TryGetSingle( out var pf ) ) pitch = pf;
if ( el.TryGetProperty( "yaw", out var yp ) && yp.TryGetSingle( out var yf ) ) yaw = yf;
if ( el.TryGetProperty( "roll", out var rp ) && rp.TryGetSingle( out var rf ) ) roll = rf;
}
else if ( el.ValueKind == JsonValueKind.String )
{
var pr = ( el.GetString() ?? "" ).Split( ',' );
if ( pr.Length > 0 ) float.TryParse( pr[0], NumberStyles.Float, CultureInfo.InvariantCulture, out pitch );
if ( pr.Length > 1 ) float.TryParse( pr[1], NumberStyles.Float, CultureInfo.InvariantCulture, out yaw );
if ( pr.Length > 2 ) float.TryParse( pr[2], NumberStyles.Float, CultureInfo.InvariantCulture, out roll );
}
return new Angles( pitch, yaw, roll );
}
}
/// <summary>
/// playtest_status — poll the running/finished playtest: live progress while running,
/// or the full pass/fail transcript once finished.
/// </summary>
public class PlaytestStatusHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var summary = PlaytestRunner.ConsumeSummary();
if ( summary != null )
return Task.FromResult<object>( summary );
var live = PlaytestRunner.LiveSnapshot();
if ( live != null )
return Task.FromResult<object>( live );
return Task.FromResult<object>( new { active = false, finished = false, note = "No playtest has run yet." } );
}
}
/// <summary>
/// playtest_abort — stop the running playtest immediately, restoring input state.
/// The partial transcript stays available via playtest_status.
/// </summary>
public class PlaytestAbortHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
=> Task.FromResult<object>( PlaytestRunner.Abort() );
}