Editor/Mcp/BridgePlaytestTools.cs

Editor-side MCP wrapper for playtest tools. It exposes methods to drive the player controller across frames, run scripted playtest step lists, query status, abort runs, and simulate input by forwarding calls to McpGate.

Networking
// AUTO-GENERATED by scripts/emit-mcp-wrappers.mjs — DO NOT EDIT.
// Regenerate: node scripts/extract-manifest.mjs && node scripts/emit-mcp-wrappers.mjs
// Source of truth: sbox-mcp-server/src/tools/ (zod schemas) → scripts/tools-manifest.json

using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Editor.Mcp;

/// <summary>
/// Scripted gameplay verification in play mode: run step lists with in-frame assertions (playtest),
/// drive the player controller, and simulate input actions.
/// </summary>
[McpToolset( "bridge_playtest", "Scripted gameplay verification in play mode: run step lists with in-frame assertions (playtest), drive the player controller, and simulate input actions." )]
public static class BridgePlaytestTools
{
	/// <summary>
	/// EXPERIMENTAL — Drive the active PlayerController DURING PLAY MODE across multiple frames:
	/// synthesize sustained look (EyeAngles), analog movement (wish velocity), and/or hold a named
	/// action down long enough that Input.Pressed fires its rising edge. This is the reliable
	/// alternative to simulate_input, which only sets an action for ONE frame and so MISSES
	/// edge-triggered controls (Input.Pressed) and cannot inject analog move/look at all. Requires
	/// start_play first. Runs ASYNC across editor frames and returns immediately — the job keeps
	/// applying for `frames` (or `durationMs`) frames. After it runs, confirm with drive_player_status,
	/// then verify the actual effect with capture_view / get_runtime_property. Provide at least one of
	/// look / lookDelta / move / action.
	/// </summary>
	/// <param name="id">GUID of the GameObject holding the controller. Omit to auto-resolve the first PlayerController (or *Controller with EyeAngles/WishVelocity) in the play scene.</param>
	/// <param name="component">Controller component type name to target (e.g. 'PlayerController'). Omit to auto-detect.</param>
	/// <param name="frames">How many editor frames to drive for (1–1800, ~60/sec). Default 30 (~0.5s). Takes precedence over durationMs.</param>
	/// <param name="durationMs">Duration in ms, converted at ~60fps (ignored if `frames` is given). Default ~500ms.</param>
	/// <param name="look">Absolute EyeAngles target {pitch,yaw,roll} held for the whole duration (aim the camera/body). Object, [pitch,yaw,roll], or 'pitch,yaw,roll'. pitch is clamped to ±89. As "pitch,yaw,roll" degrees.</param>
	/// <param name="lookDelta">Per-frame EyeAngles delta added each frame (turn/pan over time, e.g. {yaw:2} to sweep right). Combine with `frames` to control total rotation. As "pitch,yaw,roll" degrees.</param>
	/// <param name="move">Analog movement in the controller's facing frame: x = forward(+)/back(-), y = left(+)/right(-). Magnitude clamped to 1. e.g. {x:1} walks forward for the whole duration. JSON value.</param>
	/// <param name="moveSpeed">Units/sec used when synthesizing WishVelocity from `move` (default 160). Ignored if the controller exposes its own AnalogMove field.</param>
	/// <param name="action">A named input action ('jump','use','attack1',…) HELD DOWN every frame for the whole duration, so Input.Pressed catches the edge single-frame simulate_input misses. Auto-released on the final frame.</param>
	[McpTool( "drive_player" )]
	public static Task<object> DrivePlayer( string id = null, string component = null, int? frames = null, int? durationMs = null, string look = null, string lookDelta = null, JsonNode move = null, double? moveSpeed = null, string action = null )
		=> McpGate.Run( "drive_player", McpGate.Args( ( "id", id ), ( "component", component ), ( "frames", frames ), ( "durationMs", durationMs ), ( "look", look ), ( "lookDelta", lookDelta ), ( "move", move ), ( "moveSpeed", moveSpeed ), ( "action", action ) ) );

	/// <summary>
	/// EXPERIMENTAL — Read the result of the most recently FINISHED drive_player job: which controller
	/// members were actually written (EyeAngles / WishVelocity / AnalogMove…), how many frames applied,
	/// and why it ended. Because drive_player runs across frames and returns immediately, this is how
	/// you confirm it took effect. Returns lastResult=null if no job has finished yet.
	/// </summary>
	[McpTool.ReadOnly( "drive_player_status" )]
	public static Task<object> DrivePlayerStatus()
		=> McpGate.Run( "drive_player_status", McpGate.Args() );

	/// <summary>
	/// Run a scripted gameplay-verification sequence in PLAY MODE and get a pass/fail transcript
	/// (start_play first; poll playtest_status until finished). Each step is an object with ONE verb: •
	/// { "move": {"x":1,"y":0}, "frames":60 } — analog move in the controller frame (x=fwd/back,
	/// y=left/right); auto-sets UseInputControls=false and zeroes WishVelocity after. Movement is
	/// controller-specific/best-effort. • { "look": {"pitch":0,"yaw":90,"roll":0} } / { "lookDelta":
	/// {"yaw":2}, "frames":30 } — set/sweep EyeAngles. • { "action": "use", "frames":20 } — hold a
	/// named input action down (rising-edge safe; for use/dig/attack handled by gameplay components). •
	/// { "jump": "0,0,400" } — invoke the controller's Jump(velocity). • { "set":
	/// {"component":"PlayerController","property":"WorldPosition","to":"100,0,0"} } — set a runtime
	/// property (toggles, or teleport via WorldPosition — the robust positioning fallback). • { "wait":
	/// 10 } — advance N frames. • { "capture": "after-jump" } — screenshot the live player-POV camera
	/// at this frame; the PNG path is recorded in the transcript (diagnostic, never pass/fail). Pass
	/// true for no label. • { "assert": {"read":"Displacement","op":"&gt;","value":50,"desc":"moved
	/// &gt;50u"} } — read a value and compare IN-FRAME. read = "Displacement" (scalar distance moved
	/// from job start — the clean facing-independent movement proof), "WorldPosition[.x|.y|.z]", or
	/// "&lt;Component&gt;.&lt;Property&gt;[.x|.y|.z|.Count]". op = &gt; &lt; &gt;= &lt;= == != changed.
	/// Records pass/fail. Tip: prove movement with read:'Displacement' op:'&gt;' (facing-independent,
	/// unambiguous), and catch transient state (IsAirborne) right after the action.
	/// </summary>
	/// <param name="steps">Ordered step objects (see verbs above). Runs top-to-bottom in the frame loop. JSON array.</param>
	/// <param name="id">GUID of the player/controller GameObject. Omit to auto-resolve the first PlayerController.</param>
	/// <param name="component">Controller component type to target (e.g. 'PlayerController'). Omit to auto-detect.</param>
	[McpTool( "playtest" )]
	public static Task<object> Playtest( JsonNode steps, string id = null, string component = null )
		=> McpGate.Run( "playtest", McpGate.Args( ( "steps", steps ), ( "id", id ), ( "component", component ) ) );

	/// <summary>
	/// Stop the RUNNING playtest immediately: releases held input actions, restores UseInputControls,
	/// and finalizes the partial transcript (reason 'aborted'). Returns { aborted, stepsRun, passed,
	/// failed } — or aborted:false if no job is running. The partial transcript stays readable via
	/// playtest_status. Use when a step list is clearly stuck or targeting the wrong object.
	/// </summary>
	[McpTool( "playtest_abort" )]
	public static Task<object> PlaytestAbort()
		=> McpGate.Run( "playtest_abort", McpGate.Args() );

	/// <summary>
	/// Poll the playtest started by the playtest tool. While running: { active:true, step, totalSteps,
	/// passed, failed }. When done: { finished:true, reason, verdict:'PASS'|'FAIL', passed, failed,
	/// stepsRun, totalSteps, controller, controllerResolved, transcript:[...] } — the full per-step
	/// pass/fail record, including any capture PNG paths; the finished summary stays readable until the
	/// next playtest starts. If none has run yet: { active:false, finished:false }. Stop a stuck/wrong
	/// run early with playtest_abort.
	/// </summary>
	[McpTool.ReadOnly( "playtest_status" )]
	public static Task<object> PlaytestStatus()
		=> McpGate.Run( "playtest_status", McpGate.Args() );

	/// <summary>
	/// Press or release a named input action during PLAY mode (via Sandbox.Input.SetAction) so
	/// input-driven behavior — jump, attack1, use, reload, IPressable, weapon fire — can be verified
	/// without a human at the keyboard. REQUIRES play mode and a named `action`; returns {action,
	/// state: 'down'|'up', note}. Caveats: SetAction applies to the current input frame ('press' and
	/// 'hold' both set the action down; 'release' clears it), and analogMove/analogLook/durationMs are
	/// accepted but IGNORED — Sandbox.Input has no analog injection API, so for movement use
	/// drive_player or the playtest harness instead.
	/// </summary>
	/// <param name="action">Named input action to drive (must exist in Input.config), e.g. 'jump', 'attack1', 'use'.</param>
	/// <param name="state">press = one tick (Pressed); hold = held for durationMs; release = clear a held action. One of: press | hold | release. Default: "press".</param>
	/// <param name="analogMove">AnalogMove vector to apply (forward/left) -- object {x,y,z} or comma string "x,y,z". As "x,y,z" (or JSON {x,y,z}).</param>
	/// <param name="analogLook">AnalogLook angles delta to apply. As "pitch,yaw,roll" degrees.</param>
	/// <param name="durationMs">How long to hold the action / apply the analog values.</param>
	[McpTool( "simulate_input" )]
	public static Task<object> SimulateInput( string action = null, string state = "press", string analogMove = null, string analogLook = null, int durationMs = 100 )
		=> McpGate.Run( "simulate_input", McpGate.Args( ( "action", action ), ( "state", state ), ( "analogMove", analogMove ), ( "analogLook", analogLook ), ( "durationMs", durationMs ) ) );
}