Editor/InputActionHandlers.cs

Editor handler that ensures a named input action exists in the current project's .sbproj JSON. It reads the project's .sbproj, seeds the engine default action set if none exist, appends or updates a named action (optionally changing its keyboard key), writes the .sbproj back, and returns a status object.

File Access
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.Text.Json.Nodes;
using System.Threading.Tasks;

// ═══════════════════════════════════════════════════════════════════════════
// Input Actions — ensure_input_action
//
// Registers a custom NAMED INPUT ACTION (a verb like "interact", "sprint",
// "drop") in the project so generated game code can call
// Input.Pressed("interact") / Input.Down(...) and have it actually fire in
// play mode. Without this, a scaffolded game's custom verbs are dead keys.
//
// WHERE s&box STORES INPUT ACTIONS (verified against a shipped game's .sbproj —
// dhi.garryware — and Sandbox.Engine.xml):
//   <project>.sbproj  →  Metadata.InputSettings.Actions[]
//   each entry: { "Name", "KeyboardCode", "GamepadCode"?, "GroupName" }
//
// IMPORTANT engine semantics (Sandbox.Engine.Input XML doc):
//   "Games that don't define any input actions will get a bunch of default
//    actions given to them."
// → The default Forward/Back/Left/Right/Jump/Use/etc. set is ONLY injected
//   when the game defines NONE. The moment Metadata.InputSettings.Actions
//   exists, IT is the authoritative full list. So when the block is absent we
//   must SEED THE FULL DEFAULT SET before appending, or we'd silently strip
//   movement/use out from under the scaffolded player controller.
//
// This handler is UNSANDBOXED editor code (System.* / System.Text.Json.Nodes
// are fine). It edits the .sbproj JSON directly — the same proven disk-edit
// idiom as SetProjectConfigHandler in MyEditorMenu.cs — which sidesteps any
// ambiguity about ProjectConfig.SetMeta disk persistence / editor reload.
//
// It lives in the SAME assembly as MyEditorMenu.cs, so it implements the shared
// IBridgeHandler contract and returns `new { error = ... }` on failure (the
// dispatch envelope reports success=false via TryGetHandlerError).
//
// Registration line + the _sceneMutatingCommands addition are listed in the
// implementation summary — MyEditorMenu.cs owns those.
// ═══════════════════════════════════════════════════════════════════════════

/// <summary>
/// Shared bits for the input-action handler. Internal to this file so it never
/// collides with helpers in MyEditorMenu.cs / ScaffoldHandlers.cs.
/// </summary>
internal static class InputActionHelpers
{
	// UTF-8 without BOM — the .sbproj is consumed by s&box's own JSON loader and
	// by tooling; matches the no-BOM rule used everywhere else in the bridge.
	public static readonly Encoding Utf8NoBom = new UTF8Encoding( false );

	/// <summary>
	/// The default action set s&box hands a project that defines none. We must
	/// re-create it verbatim before appending a custom action, otherwise writing
	/// an InputSettings block strips movement/use. Shape + codes match a shipped
	/// game's .sbproj (Name / KeyboardCode / GamepadCode / GroupName).
	/// </summary>
	public static JsonArray BuildDefaultActions()
	{
		// (Name, KeyboardCode, GamepadCode-or-null, GroupName)
		var defaults = new (string name, string kb, string pad, string group)[]
		{
			("Forward",    "W",     null,                    "Movement"),
			("Backward",   "S",     null,                    "Movement"),
			("Left",       "A",     null,                    "Movement"),
			("Right",      "D",     null,                    "Movement"),
			("Jump",       "space", "A",                     "Movement"),
			("Run",        "shift", "LeftJoystickButton",    "Movement"),
			("Walk",       "alt",   null,                    "Movement"),
			("Duck",       "ctrl",  "B",                     "Movement"),
			("attack1",    "mouse1","RightTrigger",          "Actions"),
			("attack2",    "mouse2","LeftTrigger",           "Actions"),
			("reload",     "r",     "X",                     "Actions"),
			("use",        "e",     "Y",                     "Actions"),
			("Voice",      "v",     "RightJoystickButton",   "Other"),
			("Drop",       "g",     "RightJoystickButton",   "Other"),
			("Flashlight", "f",     "DpadNorth",             "Other"),
			("Score",      "tab",   "SwitchLeftMenu",        "Other"),
			("Menu",       "Q",     "SwitchRightMenu",       "Other"),
			("Chat",       "enter", null,                    "Other"),
		};

		var arr = new JsonArray();
		foreach ( var d in defaults )
			arr.Add( MakeAction( d.name, d.kb, d.pad, d.group ) );
		return arr;
	}

	/// <summary>Build one action node in the on-disk shape (omits GamepadCode when null).</summary>
	public static JsonObject MakeAction( string name, string keyboardCode, string gamepadCode, string groupName )
	{
		var node = new JsonObject
		{
			["Name"] = name,
			["KeyboardCode"] = keyboardCode ?? "",
		};
		if ( !string.IsNullOrWhiteSpace( gamepadCode ) )
			node["GamepadCode"] = gamepadCode;
		node["GroupName"] = string.IsNullOrWhiteSpace( groupName ) ? "Actions" : groupName;
		return node;
	}
}

// ═══════════════════════════════════════════════════════════════════════════
// ensure_input_action — add a named input action to the project if missing.
//   params: { name (required), keyboardKey?, group? }
//   • idempotent: if an action with that Name already exists, report exists=true
//     and (optionally) update its key if `keyboardKey` differs and update=true.
//   • seeds the full default action set if the project has none, so movement/use
//     survive (engine only auto-injects defaults when NO actions are defined).
// ═══════════════════════════════════════════════════════════════════════════
public class EnsureInputActionHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			// ── name (required) ──────────────────────────────────────────
			var name = p.TryGetProperty( "name", out var n ) ? n.GetString() : null;
			if ( string.IsNullOrWhiteSpace( name ) )
				return Task.FromResult<object>( new { error = "name is required — the action verb game code will call, e.g. \"interact\" (Input.Pressed(\"interact\"))." } );
			name = name.Trim();

			var keyboardKey = p.TryGetProperty( "keyboardKey", out var kk ) && !string.IsNullOrWhiteSpace( kk.GetString() )
				? kk.GetString().Trim()
				: null;
			var group = p.TryGetProperty( "group", out var g ) && !string.IsNullOrWhiteSpace( g.GetString() )
				? g.GetString().Trim()
				: "Actions";
			bool update = p.TryGetProperty( "update", out var up ) && up.ValueKind == JsonValueKind.True;

			// ── locate the .sbproj ───────────────────────────────────────
			var rootPath = Project.Current?.GetRootPath();
			if ( string.IsNullOrEmpty( rootPath ) )
				return Task.FromResult<object>( new { error = "No current project (Project.Current is null)." } );

			var sbproj = Directory.GetFiles( rootPath, "*.sbproj", SearchOption.TopDirectoryOnly ).FirstOrDefault();
			if ( sbproj == null )
				return Task.FromResult<object>( new { error = ".sbproj file not found in project root" } );

			// ── parse ────────────────────────────────────────────────────
			var raw = File.ReadAllText( sbproj );
			JsonObject root;
			try { root = JsonNode.Parse( raw ) as JsonObject; }
			catch ( Exception ex ) { return Task.FromResult<object>( new { error = $"Could not parse .sbproj as JSON: {ex.Message}" } ); }
			if ( root == null )
				return Task.FromResult<object>( new { error = ".sbproj root is not a JSON object" } );

			// Metadata { ... }
			if ( root["Metadata"] is not JsonObject metadata )
			{
				metadata = new JsonObject();
				root["Metadata"] = metadata;
			}

			// Metadata.InputSettings { Actions: [...] }
			bool seededDefaults = false;
			if ( metadata["InputSettings"] is not JsonObject inputSettings )
			{
				inputSettings = new JsonObject();
				metadata["InputSettings"] = inputSettings;
			}

			if ( inputSettings["Actions"] is not JsonArray actions )
			{
				// No actions defined → the engine was injecting defaults. Re-create
				// the default set so we don't strip movement/use by writing a block.
				actions = InputActionHelpers.BuildDefaultActions();
				inputSettings["Actions"] = actions;
				seededDefaults = true;
			}

			// ── already present? (case-insensitive on Name) ──────────────
			JsonObject existing = actions
				.OfType<JsonObject>()
				.FirstOrDefault( a => string.Equals( a["Name"]?.GetValue<string>(), name, StringComparison.OrdinalIgnoreCase ) );

			if ( existing != null )
			{
				bool changed = seededDefaults; // seeding defaults is itself a disk change worth saving
				string priorKey = existing["KeyboardCode"]?.GetValue<string>();

				if ( update && keyboardKey != null && !string.Equals( priorKey, keyboardKey, StringComparison.Ordinal ) )
				{
					existing["KeyboardCode"] = keyboardKey;
					changed = true;
				}

				if ( changed )
					File.WriteAllText( sbproj, Serialize( root ), InputActionHelpers.Utf8NoBom );

				return Task.FromResult<object>( new
				{
					ensured = true,
					exists = true,
					updated = update && changed && keyboardKey != null,
					seededDefaults,
					name,
					keyboardKey = existing["KeyboardCode"]?.GetValue<string>(),
					group = existing["GroupName"]?.GetValue<string>(),
					actionCount = actions.Count,
					note = "Action already defined. " + RestartNote()
				} );
			}

			// ── append the new action ────────────────────────────────────
			actions.Add( InputActionHelpers.MakeAction( name, keyboardKey, null, group ) );
			File.WriteAllText( sbproj, Serialize( root ), InputActionHelpers.Utf8NoBom );

			return Task.FromResult<object>( new
			{
				ensured = true,
				exists = false,
				added = true,
				seededDefaults,
				name,
				keyboardKey = keyboardKey ?? "",
				group,
				actionCount = actions.Count,
				note = (keyboardKey == null
					? $"Added input action '{name}' with no key bound — set a default key by passing keyboardKey, or let the player bind it. "
					: $"Added input action '{name}' bound to '{keyboardKey}'. ")
					+ "Call it from game code with Input.Pressed(\"" + name + "\") / Input.Down(...). "
					+ RestartNote()
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"ensure_input_action failed: {ex.Message}" } );
		}
	}

	// .sbproj is written with two-space indentation (matches the engine's own
	// serializer) so diffs stay clean.
	static string Serialize( JsonObject root )
		=> root.ToJsonString( new JsonSerializerOptions { WriteIndented = true } );

	static string RestartNote()
		=> "Input config is read at project load — restart the editor (restart_editor) or reload the project for a new/changed action to take effect in play mode.";
}