🔍 s&box Package Code Search

Search C# source code, UI razor templates, shaders, and configs across s&box packages.

Showing code results for query: * (48 total matches found)
fieldguide.tips / Code/Demo/TipsDemoBootstrap.cs
Game library
using System;
using System.Linq;
using Sandbox;

namespace FieldGuide.Tips;

/// <summary>
/// Wires the demo scene in one component so the whole kit runs from a single press of play: it points the
/// <see cref="TipsWorld"/> seams at the demo pawn, creates the coach and its display with
/// <see cref="TipsCoach.Ensure"/>, and pushes a context every frame carrying the demo flag the demo tips
/// gate on. The tips themselves are authored assets (<c>Assets/demo/*.tip</c> plus
/// <c>Assets/starter.tip</c>), not code, so the scene also shows the no-code authoring path.
///
/// THE DEMO FLAG. Every demo tip's Relevance is <c>Flag("fg_tips_demo")</c>, and only this component sets
/// that flag. A game that vendors the kit and never runs this scene therefore never sees a demo tip, even
/// if it forgets to delete the assets: the tips merge into the catalog but stay irrelevant forever. Delete
/// them anyway.
///
/// THE ENDING. The walkthrough hands the player to the authoring tool: the last tip coaches <c>T</c>, and
/// opening the Tips Studio is what retires it. Two pieces do that, both of them demo wiring rather than
/// kit runtime. This component builds the Studio's host object at boot (the same one line a game writes
/// from the README), and it raises the <see cref="StudioOpenedSignal"/> string signal while the Studio is
/// open, which is the completion <c>demo_wrap.tip</c> waits on.
///
/// Not part of the kit's runtime surface: delete <c>Code/Demo</c> and <c>Assets/demo</c> when you drop the
/// kit into your own project, and write your own bootstrap from the README instead.
/// </summary>
[Title( "Tips Demo Bootstrap" )]
[Category( "Field Guide Tips" )]
[Icon( "auto_awesome" )]
public sealed class TipsDemoBootstrap : Component
{
	/// <summary>The context flag every demo tip's Relevance reads, so demo content is inert anywhere this
	/// component is not running.</summary>
	public const string DemoFlag = "fg_tips_demo";

	/// <summary>Progress file the demo writes, kept apart from the kit default so replaying the demo never
	/// touches the progress your own game saves.</summary>
	public const string DemoSaveFile = "fieldguide_tips_demo.json";

	/// <summary>The string signal this component latches while the Tips Studio is open, and the completion
	/// <c>demo_wrap.tip</c> waits on. A demo-side name: the kit knows nothing about it, which is the point.
	/// Any game can retire a tip on its own UI the same way, with one <see cref="TipsCoach.Signal(string)"/>
	/// call and a Signal trigger on the tip.</summary>
	public const string StudioOpenedSignal = "fg_tips_studio_opened";

	/// <summary>Raw key that resets progress and replays the sequence from the top.</summary>
	[Property] public string ReplayKey { get; set; } = "r";

	private TipsCoach _coach;
	private TipsDemoPawn _pawn;
	private string _previousSaveFile;
	private Func<bool> _previousHasPlayer;
	private Func<Vector3> _previousPlayerPosition;
	private Func<Ray> _previousAimRay;

	protected override void OnStart()
	{
		_pawn = Scene.GetAllComponents<TipsDemoPawn>().FirstOrDefault();

		// Set the save file BEFORE Ensure: the first load reads whatever name is set then.
		_previousSaveFile = TipsCoach.SaveFileName;
		TipsCoach.SaveFileName = DemoSaveFile;

		// Always start the demo from the top, the way a first-time player would see it.
		TipsCoach.ResetProgress();

		// World seams. A library cannot reach into your player, so the demo hands the kit its pawn. The
		// marker zone's PlayerEntered trigger reads LocalPlayerPosition; nothing here uses LookedAt, so
		// AimRay stays null and those triggers stay inert. They are statics, so the old values are kept
		// and handed back in OnDestroy: a demo pawn that no longer exists must not answer for a game
		// scene opened later in the same session.
		_previousHasPlayer = TipsWorld.HasLocalPlayer;
		_previousPlayerPosition = TipsWorld.LocalPlayerPosition;
		_previousAimRay = TipsWorld.AimRay;

		TipsWorld.HasLocalPlayer = () => _pawn.IsValid();
		TipsWorld.LocalPlayerPosition = () => _pawn.IsValid() ? _pawn.WorldPosition : Vector3.Zero;
		TipsWorld.AimRay = null;

		_coach = TipsCoach.Ensure( Scene );
		EnsureStudioHost();

		Log.Info( "[tips] demo ready: move with W A S D or the left stick, jump with Space or A, " +
			"switch the marker on inside its ring, then press T for the Tips Studio. " +
			$"Press {ReplayKey.ToUpperInvariant()} to replay." );
	}

	protected override void OnUpdate()
	{
		if ( _coach is null )
			return;

		// Ignored while the Studio is open. The key is read raw, so without this an "r" typed into one of
		// the Studio's text boxes would restart the walkthrough under the panel, and the walkthrough now
		// ENDS in that panel. Closed, the last beat is there to walk again.
		if ( !TipsStudio.Open && Input.Keyboard.Pressed( ReplayKey ) )
			Replay();

		// The last beat. Latched while the Studio is OPEN rather than on the frame it opens: a latch is
		// idempotent, so pushing it every frame costs nothing, and it leaves no edge state to go stale
		// when the walkthrough is reset out from under it. Raised before the tick below, because the tick
		// is what reads it.
		if ( TipsStudio.Open )
			_coach.Signal( StudioOpenedSignal );

		// The context path: one small neutral struct per frame. The demo only needs two things in it, a
		// player and the demo flag; a real game fills the fields its own tips read.
		var ctx = new TipContext { HasPlayer = _pawn.IsValid() };
		ctx.SetFlag( DemoFlag, true );
		_coach.Tick( ctx );
	}

	protected override void OnDestroy()
	{
		// Hand the statics back so a game scene opened later in the same editor session sees the kit
		// exactly as it was before the demo ran.
		if ( !string.IsNullOrEmpty( _previousSaveFile ) )
			TipsCoach.SaveFileName = _previousSaveFile;

		if ( _previousHasPlayer is not null )
			TipsWorld.HasLocalPlayer = _previousHasPlayer;
		else
			TipsWorld.HasLocalPlayer = static () => true;

		TipsWorld.LocalPlayerPosition = _previousPlayerPosition;
		TipsWorld.AimRay = _previousAimRay;
	}

	/// <summary>
	/// Build the Tips Studio's host: one <see cref="ScreenPanel"/> carrying the panel, created in code so
	/// the demo scene file holds no razor reference (the same idiom the other kits' demos use). It sits
	/// above the coach's own panel, since the Studio is a modal over the card it is editing. The Studio
	/// ships CLOSED and opens on its own OpenKey, which is T; nothing here opens it.
	///
	/// Idempotent: a scene that already carries a Studio panel of its own keeps it.
	/// </summary>
	private void EnsureStudioHost()
	{
		if ( Scene.GetAllComponents<TipsStudioPanel>().Any() )
			return;

		var go = Scene.CreateObject();
		go.Name = "UI.TipsStudio";

		var screen = go.Components.Create<ScreenPanel>();
		screen.ZIndex = 60; // above the coach's card at 50

		go.Components.Create<TipsStudioPanel>();
	}

	private void Replay()
	{
		TipsCoach.ResetProgress();

		foreach ( var marker in Scene.GetAllComponents<TipsDemoMarker>() )
			marker.ResetMarker();

		_pawn?.ResetPawn();
		Log.Info( "[tips] demo replayed from the first tip." );
	}
}
fieldguide.tips / Demo/TipsDemoMarker.cs
Game library
using System.Linq;
using Sandbox;

namespace FieldGuide.Tips;

/// <summary>
/// The demo scene's one interactable: a marker the citizen walks up to and switches on. It owns its own
/// state (off / on) and tells the kit about the use through <see cref="TipTriggerObject.NotifyInteracted"/>,
/// which is exactly the one line a real game writes from wherever it already handles "the player used
/// this object". The proximity half of the beat is a second <see cref="TipTriggerObject"/> on the sibling
/// zone object, in <see cref="TipTriggerObject.Mode.PlayerEntered"/>, so no code is involved there at all.
///
/// Note the ordering: the marker gates on ITS OWN rule (the pawn is inside the ring and the marker is
/// still off), never on which tip is showing. Game logic drives tips, not the other way round.
///
/// Not part of the kit's runtime surface: delete <c>Code/Demo</c> and <c>Assets/demo</c> when you drop
/// the kit into your own project.
/// </summary>
[Title( "Tips Demo Marker" )]
[Category( "Field Guide Tips" )]
[Icon( "emoji_objects" )]
public sealed class TipsDemoMarker : Component
{
	/// <summary>How close the pawn has to be, in world units, before the marker accepts the switch.</summary>
	[Property] public float Radius { get; set; } = 110f;

	/// <summary>The Input.config action that switches the marker on. The demo reuses Jump so the prompt
	/// reads Space on a keyboard and A on a pad with no extra binding.</summary>
	[Property] public string SwitchAction { get; set; } = "Jump";

	[Property] public Color OffTint { get; set; } = new Color( 1f, 0.62f, 0.18f );
	[Property] public Color OnTint { get; set; } = new Color( 0.35f, 0.95f, 0.5f );

	private TipsDemoPawn _pawn;
	private ModelRenderer _renderer;
	private bool _on;

	protected override void OnStart()
	{
		_renderer = Components.Get<ModelRenderer>();
		_pawn = Scene.GetAllComponents<TipsDemoPawn>().FirstOrDefault();
		Paint();
	}

	protected override void OnUpdate()
	{
		if ( _on || !PawnInside() )
			return;
		if ( !Input.Pressed( SwitchAction ) )
			return;

		_on = true;
		Paint();

		// The world-trigger seam: the game says "this object was used" and the TipTriggerObject on it
		// retires the tip it is bound to. A game that vendors fieldguide.interaction wires the same call
		// to its InteractionPerformed event instead.
		TipTriggerObject.NotifyInteracted( GameObject );
	}

	/// <summary>True while the demo pawn stands inside the marker ring (flat distance, height ignored so a
	/// hop does not drop the player out of the ring).</summary>
	public bool PawnInside()
		=> _pawn.IsValid() && WorldPosition.WithZ( 0f ).Distance( _pawn.WorldPosition.WithZ( 0f ) ) <= Radius;

	/// <summary>Switch the marker back off (the demo's replay key).</summary>
	public void ResetMarker()
	{
		_on = false;
		Paint();
	}

	private void Paint()
	{
		if ( _renderer.IsValid() )
			_renderer.Tint = _on ? OnTint : OffTint;
	}
}
fieldguide.tips / Demo/TipsDemoPawn.cs
Game library
using System.Linq;
using Sandbox;

namespace FieldGuide.Tips;

/// <summary>
/// The demo scene's stand-in player: a citizen that slides along the ground on the movement stick (or
/// W/A/S/D) and hops on the jump action. Deliberately the simplest thing that can be coached: plain
/// transform movement, one hand-integrated hop, no rigidbody, no collider, no controller. It exists so
/// the tips in <c>Assets/demo/</c> have real actions to retire on.
///
/// THE LOOK. The scene authors this object with a plain box renderer. At boot the pawn switches that off
/// and builds a dressed stock citizen in its place (<see cref="TipsDemoCitizen"/>), so the scene file
/// stays as authored and the editor viewport still shows the simple block when nothing is playing. The
/// movement, the play radius and the hop are untouched by the swap: only the visual changed.
///
/// Not part of the kit's runtime surface: delete <c>Code/Demo</c> and <c>Assets/demo</c> when you drop
/// the kit into your own project, and coach your own player instead.
/// </summary>
[Title( "Tips Demo Pawn" )]
[Category( "Field Guide Tips" )]
[Icon( "smart_toy" )]
public sealed class TipsDemoPawn : Component
{
	/// <summary>Ground speed in world units per second.</summary>
	[Property] public float MoveSpeed { get; set; } = 220f;

	/// <summary>Upward speed of one hop, in world units per second.</summary>
	[Property] public float JumpSpeed { get; set; } = 260f;

	/// <summary>Downward acceleration applied to a hop, in world units per second squared.</summary>
	[Property] public float Gravity { get; set; } = 900f;

	/// <summary>How far from its start the citizen may wander. The demo camera is fixed, so this is what
	/// keeps the citizen in frame, and the scene's camera is framed to contain exactly this disc. The
	/// marker sits 205 units from the start, so 220 lets the citizen walk onto it and a little past
	/// without opening up a corner of the yard that the camera would then have to cover for nothing.</summary>
	[Property] public float PlayRadius { get; set; } = 220f;

	/// <summary>The Input.config action that hops. Bound to Space on a keyboard and A on a pad in the
	/// s&amp;box default config, which is what the demo tips prompt.</summary>
	[Property] public string JumpAction { get; set; } = "Jump";

	/// <summary>Yaw the demo camera looks along, so pushing forward moves the citizen away from the camera
	/// instead of sideways. Change it with the camera.</summary>
	[Property] public float CameraYaw { get; set; } = 45f;

	/// <summary>Local Z of the citizen visual. The pawn object sits half a block above the ground because
	/// the authored box is centred on it, and the citizen's origin is at its feet, so the visual drops by
	/// that half height to stand on the floor instead of hovering.</summary>
	[Property] public float VisualZOffset { get; set; } = -25f;

	/// <summary>How briskly the citizen turns to face where it is going, in turns per second-ish. High
	/// enough to read as responsive, low enough that a flick of the stick does not snap it.</summary>
	[Property] public float TurnSpeed { get; set; } = 12f;

	private Vector3 _start;
	private float _height;
	private float _riseSpeed;
	private SkinnedModelRenderer _visual;
	private Rotation _facing;

	protected override void OnStart()
	{
		_start = WorldPosition;

		// Resting yaw looks back down the camera's line, so the citizen greets the player instead of
		// showing its back on the first frame.
		_facing = Rotation.FromYaw( CameraYaw + 180f );

		HideAuthoredBlock();

		_visual = TipsDemoCitizen.Build( GameObject, VisualZOffset );
		if ( _visual.IsValid() )
		{
			_visual.WorldRotation = _facing;
			Log.Info( "[tips] demo pawn: dressed citizen built in code, authored block renderer switched off." );
		}
	}

	protected override void OnUpdate()
	{
		var facing = Rotation.FromYaw( CameraYaw );
		var move = ReadMove();
		var dir = facing.Forward * move.x + facing.Left * move.y;
		if ( dir.Length > 1f )
			dir = dir.Normal;

		var flat = ( WorldPosition + dir * MoveSpeed * Time.Delta - _start ).WithZ( 0f );
		if ( flat.Length > PlayRadius )
			flat = flat.Normal * PlayRadius;

		var hopped = false;
		if ( _height <= 0f && _riseSpeed <= 0f && Input.Pressed( JumpAction ) )
		{
			_riseSpeed = JumpSpeed;
			hopped = true;
		}

		if ( _height > 0f || _riseSpeed > 0f )
		{
			_riseSpeed -= Gravity * Time.Delta;
			_height += _riseSpeed * Time.Delta;
			if ( _height <= 0f )
			{
				_height = 0f;
				_riseSpeed = 0f;
			}
		}

		var previous = WorldPosition;
		WorldPosition = _start + flat + Vector3.Up * _height;

		DriveVisual( previous, dir, hopped );
	}

	/// <summary>
	/// Movement as a forward/left pair. <c>Input.AnalogMove</c> carries the movement stick and, in a
	/// project whose Input.config binds the standard movement actions, the keyboard too. The raw W/A/S/D
	/// fallback keeps the demo drivable in a project that binds movement under other names, which is the
	/// same reason the movement tip completes on either the stick or those keys.
	/// </summary>
	private static Vector3 ReadMove()
	{
		var move = Input.AnalogMove;
		if ( move.Length > 0.01f )
			return move;

		var forward = ( Input.Keyboard.Down( "w" ) ? 1f : 0f ) - ( Input.Keyboard.Down( "s" ) ? 1f : 0f );
		var left = ( Input.Keyboard.Down( "a" ) ? 1f : 0f ) - ( Input.Keyboard.Down( "d" ) ? 1f : 0f );
		return new Vector3( forward, left, 0f );
	}

	/// <summary>
	/// Turn the citizen to face its travel and hand the animgraph a frame of locomotion. The legs read the
	/// distance actually covered rather than the stick, so at the play radius the clamp reads as standing
	/// still instead of running on the spot.
	/// </summary>
	private void DriveVisual( Vector3 previous, Vector3 wishDirection, bool hopped )
	{
		if ( !_visual.IsValid() )
			return;

		var travelled = ( WorldPosition - previous ).WithZ( 0f );
		var velocity = ( Time.Delta > 0f ? travelled / Time.Delta : Vector3.Zero ).WithZ( _riseSpeed );

		if ( travelled.Length > 0.01f )
		{
			var target = Rotation.LookAt( travelled.Normal, Vector3.Up );
			_facing = Rotation.Slerp( _facing, target, ( Time.Delta * TurnSpeed ).Clamp( 0f, 1f ) );
		}

		_visual.WorldRotation = _facing;

		var grounded = _height <= 0f && _riseSpeed <= 0f;
		TipsDemoCitizen.Drive( _visual, velocity, wishDirection * MoveSpeed, grounded );

		if ( hopped )
			TipsDemoCitizen.TriggerJump( _visual );
	}

	/// <summary>
	/// Switch off the box renderer the scene authors on this object, so the citizen stands alone. Disabling
	/// the component at runtime leaves the scene file untouched: reopen it in the editor and the simple
	/// authored block is still what you see. The skinned renderer is skipped by type because it derives
	/// from <c>ModelRenderer</c> too.
	/// </summary>
	private void HideAuthoredBlock()
	{
		var authored = Components.GetAll<ModelRenderer>( FindMode.EverythingInSelf )
			.Where( r => r is not SkinnedModelRenderer )
			.ToArray();

		foreach ( var renderer in authored )
			renderer.Enabled = false;
	}

	/// <summary>Put the citizen back where it started (the demo's replay key).</summary>
	public void ResetPawn()
	{
		_height = 0f;
		_riseSpeed = 0f;
		WorldPosition = _start;

		_facing = Rotation.FromYaw( CameraYaw + 180f );
		if ( _visual.IsValid() )
			_visual.WorldRotation = _facing;
	}
}
fieldguide.tips / TipCatalogMerge.cs
Game library
using System.Collections.Generic;

namespace FieldGuide.Tips;

/// <summary>
/// The merged catalog as one value: the ordered tips and the label saying where each id came from, built
/// together so a reader can never pair a list from one build with labels from another.
/// </summary>
public sealed class TipCatalogView
{
	/// <summary>The deduped, precedence-ordered tips.</summary>
	public IReadOnlyList<TipDefinition> Tips { get; init; } = new List<TipDefinition>();

	/// <summary>Tip id to source label ("code" / "asset" / "draft" / "example").</summary>
	public IReadOnlyDictionary<string, string> SourceById { get; init; } = new Dictionary<string, string>();
}

/// <summary>
/// The pure merge rule behind <see cref="TipsCatalog.Active"/>: which source wins an id collision, what order
/// the survivors come out in, and when the shipped example stands in. Lifted out of the catalog so it has no
/// <c>Sandbox</c> reference and the harness can assert precedence and dedupe without a running engine, which
/// is the half of the catalog that a typo actually breaks.
/// </summary>
public static class TipCatalogMerge
{
	/// <summary>
	/// Merge the three sources into one view, highest precedence first: CODE, then ASSETS, then DRAFTS. The
	/// first tip seen for an id wins and later ones are dropped, so a draft never shadows the real tip it is
	/// a draft of. When all three come back empty, <paramref name="fallback"/> is used and labelled
	/// "example". Null sources are treated as empty; a null tip, or one with a blank id, is skipped.
	/// </summary>
	public static TipCatalogView Merge(
		IEnumerable<TipDefinition> code,
		IEnumerable<TipDefinition> assets,
		IEnumerable<TipDefinition> drafts,
		IEnumerable<TipDefinition> fallback )
	{
		var order = new List<TipDefinition>();
		var sources = new Dictionary<string, string>( System.StringComparer.Ordinal );

		Add( code, "code", order, sources );
		Add( assets, "asset", order, sources );
		Add( drafts, "draft", order, sources );

		if ( order.Count == 0 )
			Add( fallback, "example", order, sources );

		return new TipCatalogView { Tips = order, SourceById = sources };
	}

	// The source map IS the dedupe guard, and it is filled in the same pass as the list it guards. Guarding
	// inserts to one collection by querying a different, longer-lived one is how these two drift apart.
	private static void Add( IEnumerable<TipDefinition> source, string label,
		List<TipDefinition> order, Dictionary<string, string> sources )
	{
		if ( source is null )
			return;

		foreach ( var def in source )
		{
			if ( def is null || string.IsNullOrEmpty( def.Id ) )
				continue;
			if ( sources.ContainsKey( def.Id ) )
				continue; // a higher-precedence source already claimed this id
			order.Add( def );
			sources[def.Id] = label;
		}
	}
}

/// <summary>
/// What a built catalog view was built FROM: the code list it saw, and the draft / asset revision numbers at
/// the time. A read compares the stamp against the live sources; anything that moved means the view is stale
/// and gets rebuilt. Comparing is three field reads with no allocation, which is what lets the coach ask for
/// the catalog several times a frame without a rescan.
///
/// The code source is compared BY REFERENCE, not by content: registering is a whole-list swap, so a new list
/// is a new catalog, and a caller mutating a list it already registered is expected to say so
/// (<see cref="TipsCatalog.Rebuild"/>) the same way it always was.
/// </summary>
public readonly struct TipCatalogStamp
{
	/// <summary>The code catalog this view merged.</summary>
	public object Code { get; }

	/// <summary>The draft revision this view merged.</summary>
	public int DraftRevision { get; }

	/// <summary>The asset revision this view merged.</summary>
	public int AssetRevision { get; }

	public TipCatalogStamp( object code, int draftRevision, int assetRevision )
	{
		Code = code;
		DraftRevision = draftRevision;
		AssetRevision = assetRevision;
	}

	/// <summary>True when nothing has moved since this view was built.</summary>
	public bool Matches( object code, int draftRevision, int assetRevision )
		=> ReferenceEquals( Code, code ) && DraftRevision == draftRevision && AssetRevision == assetRevision;
}
fieldguide.tips / Code/Studio/TipsStudio.cs
Game library
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace FieldGuide.Tips;

/// <summary>
/// The Tips Studio's state and every action its panel takes. The panel
/// (<see cref="TipsStudioPanel"/>) is markup over this; everything that decides something lives here, so the
/// razor stays readable and this stays testable by eye.
///
/// WHAT IT IS. An authoring surface for tips that runs inside your game: list the merged catalog, open any
/// tip in an editor, watch the real card change as you type, fire the tip and its completion for real, and
/// bake the result out as a <c>.tip</c> file. Nothing here is part of a shipped game's runtime: the panel
/// only exists if you add it, it starts closed, and <c>fg_tips_studio</c> is off by default.
///
/// TWO WAYS A DRAFT REACHES THE COACH, and they are deliberately different:
/// <list type="bullet">
/// <item>PREVIEW registers the draft under <see cref="PreviewId"/>, an id nothing else uses, and force-shows
/// it. It is a picture of the card. It cannot be shadowed by a real tip with the same id, which is what would
/// happen if it registered under the draft's own id (drafts are the lowest-precedence source), and it cannot
/// mark anything complete.</item>
/// <item>TEST FIRE registers the draft under its OWN id and shows it, so completing it retires the real tip
/// and the chain advances the way it will in the game. If a code or asset tip already owns that id, that one
/// wins, which is correct: you are testing the chain, not the draft.</item>
/// </list>
///
/// CLEAN-UP. Everything it touches is static and would otherwise outlive the scene: the preview draft, the
/// test-fire draft, and the pinned preview device. <see cref="Shutdown"/> hands all of it back, and the panel
/// calls it from OnDestroy.
/// </summary>
public static class TipsStudio
{
	/// <summary>The id the live preview registers under. Long and namespaced on purpose: it must never
	/// collide with a real tip, because a collision would silently show the real tip instead of the draft.</summary>
	public const string PreviewId = "fg_tips_studio_preview";

	/// <summary>Folder under <c>FileSystem.Data</c> the Studio stages baked tips in, for the editor menu
	/// action to pick up. Staging through a file rather than a live static is what lets you bake in play and
	/// write the asset after you have stopped playing.</summary>
	public const string StageFolder = "fieldguide_tips_studio";

	// ------------------------------------------------------------------
	// Open / close
	// ------------------------------------------------------------------

	private static bool _open;

	/// <summary>Open or close the Tips Studio. Off by default, and the panel forces it off at boot: s&amp;box
	/// persists convars between sessions, so without that a value set weeks ago would open an authoring panel
	/// over someone's game.</summary>
	[ConVar( "fg_tips_studio", Help = "Open or close the Tips Studio authoring panel (dev tool, off by default)" )]
	public static bool Open
	{
		get => _open;
		set => _open = value;
	}

	/// <summary>Which tab is showing.</summary>
	public static StudioTab Tab { get; set; } = StudioTab.Tips;

	/// <summary>The Studio's three tabs.</summary>
	public enum StudioTab
	{
		/// <summary>The merged catalog, with source labels.</summary>
		Tips,

		/// <summary>The draft editor: wording, order, triggers, preview and test fire.</summary>
		Draft,

		/// <summary>The bake-out surface: the .tip JSON, Copy, and staging for the editor.</summary>
		Bake,
	}

	// ------------------------------------------------------------------
	// The draft
	// ------------------------------------------------------------------

	private static TipStudioDraft _draft = new();

	/// <summary>The tip being authored. Never null.</summary>
	public static TipStudioDraft Draft
	{
		get => _draft ??= new TipStudioDraft();
		set => _draft = value ?? new TipStudioDraft();
	}

	/// <summary>The catalog id the draft was opened from, or null for a new tip. Shown so it is obvious
	/// whether you are editing something that already exists.</summary>
	public static string OpenedFrom { get; private set; }

	/// <summary>Start a new, empty tip.</summary>
	public static void NewDraft()
	{
		Draft = new TipStudioDraft { Priority = 100 };
		OpenedFrom = null;
	}

	/// <summary>Open a catalog tip in the editor. The two code-only predicates have no authored form and are
	/// dropped; <see cref="DroppedPredicates"/> says so on screen.</summary>
	public static void OpenTip( string id )
	{
		var def = TipsCatalog.Active.FirstOrDefault( t => t.Id == id );
		if ( def is null )
			return;

		Draft = TipStudioDraft.FromDefinition( def );
		OpenedFrom = id;
		DroppedPredicates = HasCodePredicates( def );
		Tab = StudioTab.Draft;
	}

	/// <summary>True when the tip currently open was carrying a <c>Trigger</c> or <c>CompleteWhen</c>
	/// predicate, which a <c>.tip</c> file cannot hold. Baking it out keeps the declarative triggers and
	/// loses the predicate, so the panel warns before you do.</summary>
	public static bool DroppedPredicates { get; private set; }

	private static bool HasCodePredicates( TipDefinition def )
	{
		// A tip that never set them carries the record's defaults. Comparing against a fresh default is the
		// only way to tell "the author wrote a predicate" from "the record filled one in".
		var plain = new TipDefinition { Id = "probe", Text = "" };
		return def.Trigger != plain.Trigger || def.CompleteWhen != plain.CompleteWhen;
	}

	/// <summary>The authoring notes for the current draft (grey-block run lengths, triggers that can never
	/// fire). Recomputed on read; the panel refreshes them when you press Enter in a box or click anything,
	/// because rebuilding the panel while you type would take the cursor out of the box.</summary>
	public static IReadOnlyList<string> Notes => TipStudioText.Warnings( Draft );

	/// <summary>The draft as <c>.tip</c> JSON: what Copy puts on the clipboard and what a bake writes.</summary>
	public static string Json => TipStudioJson.Write( Draft );

	/// <summary>True when the draft's id already names a tip from a HIGHER-precedence source, so a bake would
	/// be shadowed until that source lets go. Worth saying out loud before someone wonders why their new file
	/// does nothing.</summary>
	public static string ShadowedBy
	{
		get
		{
			if ( string.IsNullOrWhiteSpace( Draft.Id ) )
				return null;

			var source = TipsCatalog.SourceOf( Draft.Id );
			return source == "code" ? "code" : null;
		}
	}

	// ------------------------------------------------------------------
	// Live preview
	// ------------------------------------------------------------------

	/// <summary>Whether the real card is mirroring the draft right now.</summary>
	public static bool PreviewOn { get; private set; }

	/// <summary>Push the draft onto the real card, or refresh what is already there. Registers under
	/// <see cref="PreviewId"/> so a draft of an existing tip is not shadowed by the tip it copies.</summary>
	public static void PushPreview( Scene scene )
	{
		var def = Draft.ToDefinition();

		// The preview stands in for the draft even before it has an id, so an author sees the card from the
		// first character typed rather than after they remember to name it.
		var preview = new TipDefinition
		{
			Id = PreviewId,
			Text = Draft.Text ?? "",
			TextPad = string.IsNullOrEmpty( Draft.TextPad ) ? null : Draft.TextPad,
			Icon = Draft.Icon ?? "",
			Priority = def?.Priority ?? 0,
		};

		TipsCatalog.RegisterRuntime( preview );
		PreviewOn = true;

		var coach = LiveCoach( scene );
		coach?.ForceShow( PreviewId );
	}

	/// <summary>Take the preview off the card and out of the catalog.</summary>
	public static void StopPreview( Scene scene )
	{
		PreviewOn = false;
		TipsCatalog.UnregisterRuntime( PreviewId );
		TipsCoach.PreviewDevice = null;

		// The card may still be showing a tip that no longer exists. Drop it rather than dismiss it: dismissing
		// would write the fake preview id into the player's saved progress and leave it there for good.
		if ( TipsCoach.ActiveTip?.Id == PreviewId )
			LiveCoach( scene )?.DropActive();
	}

	/// <summary>Which device the preview card is pinned to, or null for whatever the player last used.</summary>
	public static TipDevice? PinnedDevice
	{
		get => TipsCoach.PreviewDevice;
		set => TipsCoach.PreviewDevice = value;
	}

	// ------------------------------------------------------------------
	// Test fire
	// ------------------------------------------------------------------

	/// <summary>
	/// Make the draft the live tip UNDER ITS OWN ID and show it now. From here its completion is the real
	/// thing: fire the trigger in the game, or press Complete, and the tip retires and the chain moves on.
	/// Returns false when the draft has no id yet.
	/// </summary>
	public static bool TestFire( Scene scene )
	{
		var def = Draft.ToDefinition();
		if ( def is null )
			return false;

		// A test fire of a tip already marked complete would retire the moment it appeared.
		TipsCoach.Uncomplete( def.Id );
		TipsCatalog.UnregisterRuntime( PreviewId );
		PreviewOn = false;
		TipsCatalog.RegisterRuntime( def );

		var coach = LiveCoach( scene );
		if ( coach is null )
		{
			Log.Warning( "fg_tips: no TipsCoach in the scene, so there is nothing to show the tip on." );
			return false;
		}

		return coach.ForceShow( def.Id );
	}

	/// <summary>Fire the draft's completion by hand, the same path a world trigger uses. The tip retires and
	/// whatever waits on it becomes eligible.</summary>
	public static void CompleteNow()
	{
		if ( string.IsNullOrWhiteSpace( Draft.Id ) )
			return;

		TipsCoach.Complete( Draft.Id );
	}

	private static TipsCoach LiveCoach( Scene scene )
		=> scene?.GetAllComponents<TipsCoach>().FirstOrDefault();

	// ------------------------------------------------------------------
	// Input actions (the action picker)
	// ------------------------------------------------------------------

	/// <summary>
	/// The project's real input actions, for the InputAction picker: <c>Input.ActionNames</c>, which is the
	/// engine's list from the current game's input settings, the same list the <c>[InputAction]</c> inspector
	/// dropdown draws from. Sorted, and empty rather than throwing outside a running game.
	/// </summary>
	public static IReadOnlyList<string> ActionNames
	{
		get
		{
			try
			{
				var names = Input.ActionNames?.Where( n => !string.IsNullOrWhiteSpace( n ) ).ToList();
				if ( names is null || names.Count == 0 )
					return Array.Empty<string>();

				names.Sort( StringComparer.OrdinalIgnoreCase );
				return names;
			}
			catch ( Exception )
			{
				return Array.Empty<string>();
			}
		}
	}

	// ------------------------------------------------------------------
	// Bake out
	// ------------------------------------------------------------------

	/// <summary>Copy the draft's <c>.tip</c> JSON to the system clipboard, from in game. Paste it into a new
	/// file under your project's <c>Assets/</c> and the editor picks it up as a tip.</summary>
	public static void CopyJson()
	{
		Sandbox.UI.Clipboard.SetText( Json );
	}

	/// <summary>
	/// Write the draft into <see cref="StageFolder"/> under <c>FileSystem.Data</c>, where the editor menu
	/// action "Field Guide / Write staged tips" picks it up and writes the real asset. Two steps because game
	/// code cannot write into a project's <c>Assets/</c> folder, and because staging survives the end of the
	/// play session, so you can author in play and land the file afterwards.
	/// </summary>
	/// <returns>A line for the panel saying what happened.</returns>
	public static string Stage()
	{
		var file = TipStudioJson.FileNameFor( Draft.Id );
		if ( file is null )
			return "Give the tip an id first.";

		try
		{
			FileSystem.Data.CreateDirectory( StageFolder );
			var path = $"{StageFolder}/{file}";
			FileSystem.Data.WriteAllText( path, Json );
			Log.Info( $"fg_tips: staged {file}. In the editor, run Field Guide / Write staged tips to Assets/tips." );

			// Short on purpose: this lands in a one-line status slot in the panel, and the console line
			// above already carries the full instruction.
			return $"staged {file}";
		}
		catch ( Exception e )
		{
			Log.Warning( $"fg_tips: could not stage {file} ({e.Message})." );
			return $"Could not stage {file}: {e.Message}";
		}
	}

	/// <summary>How many tips are waiting in the staging folder, so the panel can say whether there is
	/// anything for the editor action to do.</summary>
	public static int StagedCount
	{
		get
		{
			try
			{
				return FileSystem.Data.DirectoryExists( StageFolder )
					? FileSystem.Data.FindFile( StageFolder, "*.tip", false ).Count()
					: 0;
			}
			catch ( Exception )
			{
				return 0;
			}
		}
	}

	/// <summary>Empty the staging folder, for when a bake was a mistake or the files have landed.</summary>
	public static string ClearStaged()
	{
		try
		{
			if ( !FileSystem.Data.DirectoryExists( StageFolder ) )
				return "Nothing staged.";

			var cleared = 0;
			foreach ( var file in FileSystem.Data.FindFile( StageFolder, "*.tip", false ).ToList() )
			{
				FileSystem.Data.DeleteFile( $"{StageFolder}/{file}" );
				cleared++;
			}

			return cleared == 0 ? "Nothing staged." : $"Cleared {cleared} staged tip(s).";
		}
		catch ( Exception e )
		{
			return $"Could not clear the staging folder: {e.Message}";
		}
	}

	// ------------------------------------------------------------------
	// Shutdown
	// ------------------------------------------------------------------

	/// <summary>
	/// Hand back everything the Studio pinned: the preview and test-fire drafts, and the pinned preview
	/// device. Called from the panel's OnDestroy, because all of it is static and would otherwise follow the
	/// developer into the next scene, exactly the trap a scene-registered code catalog falls into.
	/// </summary>
	public static void Shutdown()
	{
		PreviewOn = false;
		TipsCoach.PreviewDevice = null;
		TipsCatalog.ClearRuntime();
		Open = false;
	}
}
fieldguide.tips / Code/Studio/TipStudioJson.cs
Game library
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace FieldGuide.Tips;

/// <summary>
/// Writes and reads the <c>.tip</c> file format: the exact JSON the s&amp;box editor puts on disk for a
/// <see cref="TipResource"/>. What the Tips Studio's Copy button puts on the clipboard, and what its Editor
/// menu action writes into <c>Assets/tips/</c>, is a file the editor will happily open, edit and re-save.
///
/// THE SHAPE, derived from the shipped assets under <c>Assets/</c> and from <see cref="TipResource"/> itself:
/// every <c>[Property]</c> in declaration order, then the two editor bookkeeping keys. Every trigger field is
/// written whether or not its kind reads it, because that is what a GameResource does (it serializes the
/// object, not the interesting parts of it), and a hand-written file missing those keys would gain them the
/// first time the editor saved it, producing a spurious diff.
///
/// <code>
/// {
///   "Id": "jump",
///   "Text": "Press *Space* to jump.",
///   "TextPad": "",
///   "Icon": "",
///   "Priority": 100,
///   "PrerequisiteTipIds": [],
///   "Completion": { "Kind": "InputAction", "Action": "Jump", ... },
///   "MaxShowSeconds": 0,
///   "Relevance": { "Kind": "Always", ... },
///   "__references": [],
///   "__version": 0
/// }
/// </code>
///
/// Enums go out as their names through hand-written maps rather than a converter, so the on-disk vocabulary
/// is a stated format instead of a by-product of how the enum happens to be spelled today. The round trip
/// (write then read) is asserted headlessly in <c>tools/tips_harness</c>.
/// </summary>
public static class TipStudioJson
{
	/// <summary>Two-space indentation, matching what the editor writes, so a Studio-authored file and an
	/// editor-saved one diff cleanly against each other.</summary>
	private static readonly JsonSerializerOptions WriteOptions = new()
	{
		WriteIndented = true,
	};

	private static readonly JsonSerializerOptions ReadOptions = new()
	{
		PropertyNameCaseInsensitive = false,
	};

	/// <summary>The file name a draft bakes out to, <c>&lt;id&gt;.tip</c>, with anything awkward for a file
	/// name folded to an underscore. A blank id yields null: an unnamed tip has nowhere to land.</summary>
	public static string FileNameFor( string id )
	{
		if ( string.IsNullOrWhiteSpace( id ) )
			return null;

		var chars = id.Trim().ToCharArray();
		for ( var i = 0; i < chars.Length; i++ )
		{
			var c = chars[i];
			var ok = ( c >= 'a' && c <= 'z' ) || ( c >= 'A' && c <= 'Z' ) || ( c >= '0' && c <= '9' ) || c == '_' || c == '-';
			if ( !ok )
				chars[i] = '_';
		}

		return new string( chars ) + ".tip";
	}

	/// <summary>Serialize a draft as <c>.tip</c> JSON. Never throws: a null draft writes an empty tip.</summary>
	public static string Write( TipStudioDraft draft )
		=> RelaxEscapes( JsonSerializer.Serialize( ToWire( draft ), WriteOptions ) );

	/// <summary>
	/// Put back the characters the default JSON writer escapes but the editor does not. An icon of "✅" is
	/// written to a <c>.tip</c> by the editor as that character; the default encoder emits <c>✅</c>,
	/// which parses to the same string but is not the same FILE, so the first inspector save of a
	/// Studio-written tip would show a diff on a line nobody touched. The same goes for the apostrophes and
	/// angle brackets the default encoder escapes out of HTML caution, which a tip line is full of.
	///
	/// Done as a pass over the finished text rather than by swapping in a relaxed encoder, so the writer
	/// needs no API beyond the JSON serializer a sibling kit already ships. Escapes JSON actually requires
	/// (the quote, the backslash, and the control characters below 0x20) are left exactly as they are, and an
	/// escaped backslash is stepped over as a pair so <c>\\u0041</c> stays literal.
	/// </summary>
	private static string RelaxEscapes( string json )
	{
		if ( string.IsNullOrEmpty( json ) || json.IndexOf( '\\' ) < 0 )
			return json;

		var sb = new System.Text.StringBuilder( json.Length );
		var i = 0;

		while ( i < json.Length )
		{
			var c = json[i];
			if ( c != '\\' || i + 1 >= json.Length )
			{
				sb.Append( c );
				i++;
				continue;
			}

			if ( json[i + 1] == 'u' && i + 5 < json.Length && TryHex( json, i + 2, out var code )
				&& code >= 0x20 && code != '"' && code != '\\' )
			{
				sb.Append( (char)code );
				i += 6;
				continue;
			}

			// Any other escape (including \\) is copied as a PAIR, so its second character can never be read
			// as the start of a new escape.
			sb.Append( c );
			sb.Append( json[i + 1] );
			i += 2;
		}

		return sb.ToString();
	}

	private static bool TryHex( string text, int start, out int value )
	{
		value = 0;
		for ( var i = start; i < start + 4; i++ )
		{
			var c = text[i];
			int digit;
			if ( c >= '0' && c <= '9' )
				digit = c - '0';
			else if ( c >= 'a' && c <= 'f' )
				digit = c - 'a' + 10;
			else if ( c >= 'A' && c <= 'F' )
				digit = c - 'A' + 10;
			else
				return false;

			value = ( value << 4 ) | digit;
		}

		return true;
	}

	/// <summary>Parse <c>.tip</c> JSON back into a draft. Returns null on malformed input rather than
	/// throwing, so a hand-edited file with a stray comma reports a problem instead of taking the panel
	/// down with it.</summary>
	public static TipStudioDraft Read( string json )
	{
		if ( string.IsNullOrWhiteSpace( json ) )
			return null;

		try
		{
			var wire = JsonSerializer.Deserialize<TipWire>( json, ReadOptions );
			return wire is null ? null : FromWire( wire );
		}
		catch ( Exception )
		{
			return null;
		}
	}

	// ------------------------------------------------------------------
	// Wire types. Property ORDER here is the on-disk field order.
	// ------------------------------------------------------------------

	private sealed class TipWire
	{
		public string Id { get; set; } = "";
		public string Text { get; set; } = "";
		public string TextPad { get; set; } = "";
		public string Icon { get; set; } = "";
		public int Priority { get; set; }
		public List<string> PrerequisiteTipIds { get; set; } = new();
		public TriggerWire Completion { get; set; } = new();
		public float MaxShowSeconds { get; set; }
		public TriggerWire Relevance { get; set; } = new();

		[JsonPropertyName( "__references" )]
		public List<string> References { get; set; } = new();

		[JsonPropertyName( "__version" )]
		public int Version { get; set; }
	}

	private sealed class TriggerWire
	{
		public string Kind { get; set; } = "Always";
		public string Action { get; set; } = "";
		public string Key { get; set; } = "";
		public string Name { get; set; } = "";
		public float Threshold { get; set; }
		public float Seconds { get; set; }
		public string AnalogSource { get; set; } = "AnalogMove";
		public float Magnitude { get; set; }
		public List<TriggerWire> Children { get; set; } = new();
	}

	private static TipWire ToWire( TipStudioDraft draft )
	{
		draft ??= new TipStudioDraft();

		return new TipWire
		{
			Id = draft.Id ?? "",
			Text = draft.Text ?? "",
			TextPad = draft.TextPad ?? "",
			Icon = draft.Icon ?? "",
			Priority = draft.Priority,
			PrerequisiteTipIds = draft.PrerequisiteTipIds is null
				? new List<string>()
				: new List<string>( draft.PrerequisiteTipIds ),
			Completion = ToWire( draft.Completion ),
			MaxShowSeconds = draft.MaxShowSeconds,
			Relevance = ToWire( draft.Relevance ),
		};
	}

	private static TriggerWire ToWire( TipStudioTrigger spec )
	{
		spec ??= new TipStudioTrigger();

		var wire = new TriggerWire
		{
			Kind = TipStudioTrigger.KindName( spec.Kind ),
			Action = spec.Action ?? "",
			Key = spec.Key ?? "",
			Name = spec.Name ?? "",
			Threshold = spec.Threshold,
			Seconds = spec.Seconds,
			AnalogSource = TipStudioTrigger.SourceName( spec.AnalogSource ),
			Magnitude = spec.Magnitude,
		};

		if ( spec.Children is not null )
			foreach ( var child in spec.Children )
				wire.Children.Add( ToWire( child ) );

		return wire;
	}

	private static TipStudioDraft FromWire( TipWire wire ) => new()
	{
		Id = wire.Id ?? "",
		Text = wire.Text ?? "",
		TextPad = wire.TextPad ?? "",
		Icon = wire.Icon ?? "",
		Priority = wire.Priority,
		PrerequisiteTipIds = wire.PrerequisiteTipIds is null
			? new List<string>()
			: new List<string>( wire.PrerequisiteTipIds ),
		Completion = FromWire( wire.Completion ),
		MaxShowSeconds = wire.MaxShowSeconds,
		Relevance = FromWire( wire.Relevance ),
	};

	private static TipStudioTrigger FromWire( TriggerWire wire )
	{
		if ( wire is null )
			return new TipStudioTrigger();

		var spec = new TipStudioTrigger
		{
			Kind = TipStudioTrigger.ParseKind( wire.Kind ),
			Action = wire.Action ?? "",
			Key = wire.Key ?? "",
			Name = wire.Name ?? "",
			Threshold = wire.Threshold,
			Seconds = wire.Seconds,
			AnalogSource = TipStudioTrigger.ParseSource( wire.AnalogSource ),
			Magnitude = wire.Magnitude,
		};

		if ( wire.Children is not null )
			foreach ( var child in wire.Children )
				spec.Children.Add( FromWire( child ) );

		return spec;
	}
}
fieldguide.tips / Code/TipDevice.cs
Game library
using System;

namespace FieldGuide.Tips;

/// <summary>
/// Which input device the player last used. Backed by <c>Input.UsingController</c>, which the engine
/// flaps to the last-used device (there is no event; it is polled per frame). The coach exposes the
/// live value as <see cref="TipsCoach.ActiveDevice"/> and the display folds it into its BuildHash so a
/// device flip re-renders the active tip with the right wording and chips immediately.
/// </summary>
public enum TipDevice
{
	/// <summary>The player is on keyboard and mouse (the default when no controller has been used).</summary>
	KeyboardMouse,

	/// <summary>The player is on a game controller (the last button pressed was a pad button).</summary>
	Gamepad,
}

/// <summary>
/// Pure, engine-free render-time helpers for device-aware tip text: which wording a tip shows for a
/// device, and how a keycap label remaps when the player is on a pad. Kept free of any <c>Sandbox</c>
/// reference so the exact rules a game sees on screen can be exercised headlessly (the display calls
/// these; a harness calls them with fakes and asserts the truth table).
/// </summary>
public static class TipDeviceText
{
	/// <summary>
	/// The pad-mode wording for a tip: its <paramref name="textPad"/> when authored, else its
	/// <paramref name="text"/>. This is the single-text fallback (build plan point 3): a tip that leaves
	/// TextPad unset reads identically from either device, so the "either" idiom
	/// (<c>"Press *W* or `RT`"</c>) keeps rendering both chips with no auto-stripping.
	/// </summary>
	public static string PadTextOr( string text, string textPad )
		=> string.IsNullOrEmpty( textPad ) ? ( text ?? "" ) : textPad;

	/// <summary>
	/// The wording a tip shows for <paramref name="device"/>: <paramref name="text"/> on keyboard/mouse,
	/// and <see cref="PadTextOr(string, string)"/> on a pad. A single seam so the device-to-text choice is
	/// identical in the display and the harness.
	/// </summary>
	public static string ForDevice( string text, string textPad, TipDevice device )
		=> device == TipDevice.Gamepad ? PadTextOr( text, textPad ) : ( text ?? "" );

	/// <summary>
	/// Remap a keyboard keycap label for pad mode (build plan point 4), generalizing World Builder's
	/// <c>Cap()</c>. Applied to keycap chips at render time only when the player is on a pad:
	/// <list type="bullet">
	/// <item><paramref name="padLabelFor"/> null: no map is set, the label passes through unchanged (the
	/// single-text behaviour, so a game that never sets a map is unaffected).</item>
	/// <item>the map returns the same or a different non-empty string: that label is shown (an unmapped
	/// label the game's map passes through stays unchanged; a mapped one, e.g. "RMB" to "LT", swaps).</item>
	/// <item>the map returns null or empty: the chip is unpressable on a pad and is skipped (the caller
	/// renders nothing for it), matching WB's "skip the unpressable chip" behaviour.</item>
	/// </list>
	/// Returns the label to render, or null to skip the chip.
	/// </summary>
	public static string PadCap( string keyLabel, Func<string, string> padLabelFor )
	{
		if ( padLabelFor is null )
			return keyLabel; // no map set: passthrough

		var mapped = padLabelFor( keyLabel );
		return string.IsNullOrEmpty( mapped ) ? null : mapped; // null/empty = unpressable on pad, skip
	}
}
fieldguide.tips / TipDevice.cs
Game library
using System;

namespace FieldGuide.Tips;

/// <summary>
/// Which input device the player last used. Backed by <c>Input.UsingController</c>, which the engine
/// flaps to the last-used device (there is no event; it is polled per frame). The coach exposes the
/// live value as <see cref="TipsCoach.ActiveDevice"/> and the display folds it into its BuildHash so a
/// device flip re-renders the active tip with the right wording and chips immediately.
/// </summary>
public enum TipDevice
{
	/// <summary>The player is on keyboard and mouse (the default when no controller has been used).</summary>
	KeyboardMouse,

	/// <summary>The player is on a game controller (the last button pressed was a pad button).</summary>
	Gamepad,
}

/// <summary>
/// Pure, engine-free render-time helpers for device-aware tip text: which wording a tip shows for a
/// device, and how a keycap label remaps when the player is on a pad. Kept free of any <c>Sandbox</c>
/// reference so the exact rules a game sees on screen can be exercised headlessly (the display calls
/// these; a harness calls them with fakes and asserts the truth table).
/// </summary>
public static class TipDeviceText
{
	/// <summary>
	/// The pad-mode wording for a tip: its <paramref name="textPad"/> when authored, else its
	/// <paramref name="text"/>. This is the single-text fallback (build plan point 3): a tip that leaves
	/// TextPad unset reads identically from either device, so the "either" idiom
	/// (<c>"Press *W* or `RT`"</c>) keeps rendering both chips with no auto-stripping.
	/// </summary>
	public static string PadTextOr( string text, string textPad )
		=> string.IsNullOrEmpty( textPad ) ? ( text ?? "" ) : textPad;

	/// <summary>
	/// The wording a tip shows for <paramref name="device"/>: <paramref name="text"/> on keyboard/mouse,
	/// and <see cref="PadTextOr(string, string)"/> on a pad. A single seam so the device-to-text choice is
	/// identical in the display and the harness.
	/// </summary>
	public static string ForDevice( string text, string textPad, TipDevice device )
		=> device == TipDevice.Gamepad ? PadTextOr( text, textPad ) : ( text ?? "" );

	/// <summary>
	/// Remap a keyboard keycap label for pad mode (build plan point 4), generalizing World Builder's
	/// <c>Cap()</c>. Applied to keycap chips at render time only when the player is on a pad:
	/// <list type="bullet">
	/// <item><paramref name="padLabelFor"/> null: no map is set, the label passes through unchanged (the
	/// single-text behaviour, so a game that never sets a map is unaffected).</item>
	/// <item>the map returns the same or a different non-empty string: that label is shown (an unmapped
	/// label the game's map passes through stays unchanged; a mapped one, e.g. "RMB" to "LT", swaps).</item>
	/// <item>the map returns null or empty: the chip is unpressable on a pad and is skipped (the caller
	/// renders nothing for it), matching WB's "skip the unpressable chip" behaviour.</item>
	/// </list>
	/// Returns the label to render, or null to skip the chip.
	/// </summary>
	public static string PadCap( string keyLabel, Func<string, string> padLabelFor )
	{
		if ( padLabelFor is null )
			return keyLabel; // no map set: passthrough

		var mapped = padLabelFor( keyLabel );
		return string.IsNullOrEmpty( mapped ) ? null : mapped; // null/empty = unpressable on pad, skip
	}
}
fieldguide.tips / TipSegment.cs
Game library
using System.Collections.Generic;

namespace FieldGuide.Tips;

/// <summary>
/// What a run of tip text renders as: plain prose, a keyboard keycap, or a gamepad button chip.
/// </summary>
public enum TipSegmentKind
{
	/// <summary>Ordinary text, rendered as-is.</summary>
	Plain,

	/// <summary>A keyboard key or mouse-button name, rendered as a square keycap. Markup: <c>*W*</c>.</summary>
	Key,

	/// <summary>A gamepad button or stick, rendered as a rounded controller chip. Markup: <c>`A`</c> (backticks).</summary>
	GamepadButton,
}

/// <summary>
/// One run of tip text: either plain prose, a keyboard key that renders as a square keycap, or a
/// gamepad button that renders as a rounded controller chip. <see cref="TipsDisplay"/> walks a tip's
/// segments and styles each kind, so tip authors write a single string with markup around input names
/// instead of embedding UI.
///
/// Markup:
/// <list type="bullet">
/// <item><c>*W*</c> (asterisks) is a keyboard / mouse keycap, e.g. <c>"Hold *B* to block."</c></item>
/// <item><c>`A`</c> (backticks) is a gamepad button, e.g. <c>"Press `A` to jump."</c></item>
/// </list>
/// The two are independent, so one line can carry both for a keyboard-and-gamepad prompt:
/// <c>"Attack with *LMB* / `RT`."</c>. Everything outside the markers is plain.
/// </summary>
public readonly record struct TipSegment( string Text, TipSegmentKind Kind )
{
	/// <summary>True for a keyboard keycap run (<see cref="TipSegmentKind.Key"/>). Kept for readers that
	/// only distinguish keyboard chips from plain text.</summary>
	public bool IsKey => Kind == TipSegmentKind.Key;

	/// <summary>True for a gamepad button run (<see cref="TipSegmentKind.GamepadButton"/>).</summary>
	public bool IsGamepadButton => Kind == TipSegmentKind.GamepadButton;

	/// <summary>
	/// Split a tip line into alternating plain / key / gamepad runs. A matched pair of <c>*</c> marks a
	/// keyboard keycap; a matched pair of <c>`</c> marks a gamepad button; everything else is plain. An
	/// unmatched trailing marker degrades gracefully: its run stays plain.
	/// </summary>
	public static IReadOnlyList<TipSegment> Parse( string text )
	{
		var segments = new List<TipSegment>();
		if ( string.IsNullOrEmpty( text ) )
			return segments;

		var i = 0;
		while ( i < text.Length )
		{
			var c = text[i];

			// A marker only opens a chip if it has a matching partner later in the line; otherwise it is
			// literal text (so a lone `*` or backtick reads plainly instead of eating the rest of the line).
			if ( c == '*' || c == '`' )
			{
				var close = text.IndexOf( c, i + 1 );
				if ( close > i )
				{
					if ( close > i + 1 ) // non-empty run between the markers
					{
						var kind = c == '*' ? TipSegmentKind.Key : TipSegmentKind.GamepadButton;
						segments.Add( new TipSegment( text.Substring( i + 1, close - i - 1 ), kind ) );
					}
					i = close + 1;
					continue;
				}
			}

			// Accumulate a plain run up to the next marker (or end of line).
			var start = i;
			while ( i < text.Length && text[i] != '*' && text[i] != '`' )
				i++;

			// A marker with no partner ahead is literal: fold it into the plain run and keep going.
			if ( i < text.Length && text.IndexOf( text[i], i + 1 ) < 0 )
				i++;

			if ( i > start )
				segments.Add( new TipSegment( text.Substring( start, i - start ), TipSegmentKind.Plain ) );
		}

		return segments;
	}
}
fieldguide.tips / TipTriggerEval.cs
Game library
using System;

namespace FieldGuide.Tips;

/// <summary>
/// The environment a <see cref="TipTrigger"/> is evaluated against: the four world reads plus the active
/// tip's elapsed visible time. <see cref="TipsCoach"/> fills these from <see cref="Sandbox.Input"/>, the
/// pushed <see cref="TipContext"/> and its own timer; a headless harness fills them with fakes. Keeping
/// the reads behind delegates is what lets the trigger truth table be unit-tested without the engine.
/// </summary>
public sealed class TipTriggerEnv
{
	/// <summary>Was the named Input.config action pressed this frame? (Input.Pressed)</summary>
	public Func<string, bool> ActionPressed { get; init; } = static _ => false;

	/// <summary>Was the named raw key / mouse button pressed this frame? (Input.Keyboard.Pressed)</summary>
	public Func<string, bool> KeyPressed { get; init; } = static _ => false;

	/// <summary>Has the named string signal been latched this session? (Signal / Ever kinds)</summary>
	public Func<string, bool> SignalLatched { get; init; } = static _ => false;

	/// <summary>Is the named custom context flag true this frame? (TipContext.Flag)</summary>
	public Func<string, bool> Flag { get; init; } = static _ => false;

	/// <summary>The named custom context number this frame. (TipContext.Number)</summary>
	public Func<string, float> Number { get; init; } = static _ => 0f;

	/// <summary>Seconds the active tip has been visible, for the Timer kind.</summary>
	public float Elapsed { get; init; }

	/// <summary>The current magnitude (0..1-ish) of the given analog stick, for the AnalogAxis kind. (Input.AnalogMove / Input.AnalogLook length.)</summary>
	public Func<TipTriggerAnalogSource, float> AnalogMagnitude { get; init; } = static _ => 0f;
}

/// <summary>
/// The pure, engine-free evaluation core for a declarative <see cref="TipTrigger"/>: the per-kind rule and
/// the AnyOf / AllOf recursion, with every world read behind a <see cref="TipTriggerEnv"/> delegate. This
/// holds the whole completion/relevance truth table, so it can be exercised headlessly (the coach passes
/// real Input / context reads; a harness passes fakes) and stays identical between the two.
/// </summary>
public static class TipTriggerEval
{
	/// <summary>Evaluate a trigger against the given environment. Null trigger evaluates false.</summary>
	public static bool Evaluate( TipTrigger t, TipTriggerEnv env )
	{
		if ( t is null || env is null )
			return false;

		switch ( t.Kind )
		{
			case TipTriggerKind.Always:
				return true;

			case TipTriggerKind.InputAction:
				foreach ( var a in t.Actions )
					if ( !string.IsNullOrEmpty( a ) && env.ActionPressed( a ) )
						return true;
				return false;

			case TipTriggerKind.Key:
				foreach ( var k in t.Keys )
					if ( !string.IsNullOrEmpty( k ) && env.KeyPressed( k ) )
						return true;
				return false;

			case TipTriggerKind.Signal:
			case TipTriggerKind.Ever:
				return !string.IsNullOrEmpty( t.Key ) && env.SignalLatched( t.Key );

			case TipTriggerKind.Flag:
				return !string.IsNullOrEmpty( t.Key ) && env.Flag( t.Key );

			case TipTriggerKind.AtLeast:
				return !string.IsNullOrEmpty( t.Key ) && env.Number( t.Key ) >= t.Threshold;

			case TipTriggerKind.Timer:
				return env.Elapsed >= t.Seconds;

			case TipTriggerKind.AnalogAxis:
				return env.AnalogMagnitude( t.AnalogSource ) >= t.Threshold;

			case TipTriggerKind.AnyOf:
				foreach ( var c in t.Children )
					if ( Evaluate( c, env ) )
						return true;
				return false;

			case TipTriggerKind.AllOf:
				if ( t.Children.Length == 0 )
					return false;
				foreach ( var c in t.Children )
					if ( !Evaluate( c, env ) )
						return false;
				return true;

			default:
				return false;
		}
	}
}
fieldguide.tips / Code/Studio/TipsStudioPanel.razor
Game library
@using Sandbox
@using Sandbox.UI
@using System
@using System.Collections.Generic
@using System.Linq
@namespace FieldGuide.Tips
@inherits PanelComponent
@attribute [StyleSheet]

@*
	TIPS STUDIO - author a tip inside the running game and watch the real card change as you type.

	One modal, three columns, over a dim scrim (the approved Field Kits layout, docs/design/ui-system).
	LEFT is the merged catalog with the source each id resolved from, which is also how a tip left behind
	by another scene gives itself away. MIDDLE is the editor: wording, order, prerequisites, and the two
	trigger pickers, whose action list comes from the project's own input actions. RIGHT is the payoff:
	the same card the player sees, rendered twice side by side so the keyboard and controller wordings
	read together, and under it the bake-out (Copy to clipboard, or stage it for the editor menu action
	that writes Assets/tips).

	The lower-left card is still the REAL one: "show it" pushes the draft into the coach and TipsDisplay
	draws it with the shipped stylesheet, and the device chips pin TipsCoach.PreviewDevice. The two cards
	in the rail are the same component restated inside this panel, because a preview you have to look
	away from is not a preview.

	Toggle: `fg_tips_studio 1` in the console, or press T (a plain letter; the editor eats F1-F12 in
	play-in-editor). The key OPENS only, never closes, so pressing T inside a text box types a T and
	nothing else. Close with the x in the header or `fg_tips_studio 0`.

	Panel rules this file follows, each of which has cost someone a session: rows come from @foreach in
	main markup, never a RenderFragment; the root takes no pointer events and the scrim and modal take
	all of them; both scroll regions are a FIXED pixel height, never a percentage; no field being TYPED
	into is folded into BuildHash, because a rebuild takes the cursor out of the box.
*@

<root class="ts-root">
@if ( TipsStudio.Open )
{
	<div class="ts-scrim">
		<div class="ts-modal">

			@* ================= header ================= *@
			<div class="ts-hdr">
				<div class="ts-hdr-left">
					<div class="ts-title">TIPS STUDIO</div>
					<div class="ts-hdr-meta">@HeaderMeta</div>
				</div>
				<div class="ts-x" onclick=@Close>×</div>
			</div>

			<div class="ts-cols">

				@* ================= left: the merged catalog ================= *@
				<div class="ts-left">
					<div class="ts-list-hdr">
						<div class="ts-list-t">Tips</div>
						<div class="ts-list-m">by priority</div>
					</div>

					<div class="ts-list" @ref="ListBody">
						@if ( Entries.Count == 0 )
						{
							<div class="ts-empty">
								<div class="ts-empty-t">No tips yet</div>
								<div class="ts-empty-l">Press New tip to write one.</div>
							</div>
						}
						@foreach ( var e in Entries )
						{
							var entry = e;
							<div class="ts-row @(entry.Selected ? "on" : "")" onclick=@(() => OpenTip( entry.Id ))>
								<div class="ts-row-id">@entry.Id</div>
								@* State REPLACES the source rather than sitting beside it: the row is 248px wide
									and a third thing in it squeezes the id until it blanks out. *@
								<div class="ts-row-meta">
									@if ( entry.State is null )
									{
										<div class="ts-row-src">@entry.Source</div>
									}
									else
									{
										<div class="ts-row-state">@entry.State</div>
									}
								</div>
							</div>
						}
					</div>

					<div class="ts-left-btns">
						<div class="ts-btn pri grow gap" onclick=@NewTip>New tip</div>
						<div class="ts-btn" onclick=@Rescan>Rescan</div>
					</div>
				</div>

				@* ================= middle: the draft ================= *@
				<div class="ts-mid" @ref="MidBody">

					@if ( TipsStudio.DroppedPredicates )
					{
						<div class="ts-warn">
							<div class="ts-warn-b">!</div>
							<div class="ts-warn-t">
								<div class="ts-warn-l">This tip carries a code predicate.</div>
								<div class="ts-warn-l">A .tip file cannot hold one, so baking</div>
								<div class="ts-warn-l">keeps the triggers and drops the predicate.</div>
							</div>
						</div>
					}

					<div class="ts-frow">
						<div class="ts-field w200">
							<div class="ts-lab">Id</div>
							<TextEntry class="ts-in" Value=@DraftId
								OnTextEdited=@((string v) => { TipsStudio.Draft.Id = v; }) onsubmit=@Commit />
						</div>
						<div class="ts-field w150">
							<div class="ts-lab">Priority</div>
							<div class="ts-step">
								<div class="ts-stp" onclick=@(() => BumpPriority( -10 ))>−</div>
								<div class="ts-val">@DraftPriority</div>
								<div class="ts-stp" onclick=@(() => BumpPriority( 10 ))>+</div>
							</div>
						</div>
						<div class="ts-field grow last">
							<div class="ts-lab">Prerequisites</div>
							<div class="ts-drop">
								<div class="ts-drop-face @(IsOpen( PrereqDrop ) ? "open" : "")"
									onclick=@(() => ToggleDrop( PrereqDrop ))>
									<div class="ts-drop-val">@PrereqFace</div>
									<div class="ts-chev">expand_more</div>
								</div>
								@if ( IsOpen( PrereqDrop ) )
								{
									<div class="ts-drop-list @(Tall( AvailablePrerequisites.Count ))">
										@if ( AvailablePrerequisites.Count == 0 )
										{
											<div class="ts-drop-opt">no other tip ids yet</div>
										}
										@foreach ( var p in AvailablePrerequisites )
										{
											var prereq = p;
											<div class="ts-drop-opt" onclick=@(() => AddPrerequisite( prereq ))>@prereq</div>
										}
									</div>
								}
							</div>
						</div>
					</div>

					@* Its own block under the field, with its own bottom margin: this row used to collapse
						into the wording section and paint its chips over the Text label. *@
					@if ( PrerequisiteList.Count > 0 )
					{
						<div class="ts-chips block">
							@foreach ( var p in PrerequisiteList )
							{
								var prereq = p;
								<div class="ts-chip on mono"
									onclick=@(() => RemovePrerequisite( prereq ))>@($"{prereq} ×")</div>
							}
						</div>
					}

					<div class="ts-frow">
						<div class="ts-field grow last">
							<div class="ts-lab-row">
								<div class="ts-lab">Text</div>
								<div class="ts-cap">*Space* keycap · `A` pad button</div>
							</div>
							<TextEntry class="ts-in" Value=@DraftText
								OnTextEdited=@((string v) => { TipsStudio.Draft.Text = v; Refresh(); }) onsubmit=@Commit />
						</div>
					</div>

					<div class="ts-frow">
						<div class="ts-field grow">
							<div class="ts-lab">Pad text · optional</div>
							<TextEntry class="ts-in" Value=@DraftTextPad
								OnTextEdited=@((string v) => { TipsStudio.Draft.TextPad = v; Refresh(); }) onsubmit=@Commit />
						</div>
						<div class="ts-field w120 last">
							<div class="ts-lab">Icon</div>
							<TextEntry class="ts-in" Value=@DraftIcon
								OnTextEdited=@((string v) => { TipsStudio.Draft.Icon = v; Refresh(); }) onsubmit=@Commit />
						</div>
					</div>

					@* ---- the two trigger pickers, from one block of markup ---- *@
					@foreach ( var s in TriggerSlots )
					{
						var slot = s;
						var trig = slot.Trigger;
						var actionDrop = slot.Key;

						<div class="ts-sec">
							<div class="ts-sec-hd">
								<div class="ts-sec-t">@slot.Title</div>
								<div class="ts-cap flat">@slot.Blurb</div>
							</div>

							<div class="ts-chips">
								@foreach ( var k in TipStudioTrigger.AllKinds )
								{
									var kind = k;
									<div class="ts-chip @(trig.Kind == kind ? "on" : "")"
										onclick=@(() => SetKind( trig, kind ))>@TipStudioTrigger.KindName( kind )</div>
								}
							</div>

							<div class="ts-frow">
								@if ( trig.Kind == TipTriggerKind.InputAction )
								{
									<div class="ts-field w200">
										<div class="ts-lab">Input action</div>
										<div class="ts-drop">
											<div class="ts-drop-face @(IsOpen( actionDrop ) ? "open" : "")"
												onclick=@(() => ToggleDrop( actionDrop ))>
												<div class="ts-drop-val">@ActionFace( trig )</div>
												<div class="ts-chev">expand_more</div>
											</div>
											@if ( IsOpen( actionDrop ) )
											{
												<div class="ts-drop-list @(Tall( ActionNames.Count ))">
													@if ( ActionNames.Count == 0 )
													{
														<div class="ts-drop-opt">no input actions bound</div>
													}
													@foreach ( var a in ActionNames )
													{
														var action = a;
														<div class="ts-drop-opt @(trig.Action == action ? "on" : "")"
															onclick=@(() => PickAction( trig, action ))>@action</div>
													}
												</div>
											}
										</div>
										<div class="ts-cap">from Input.config</div>
									</div>
								}

								@if ( trig.Kind == TipTriggerKind.Key )
								{
									<div class="ts-field w200">
										<div class="ts-lab">Key</div>
										<TextEntry class="ts-in" Value=@TrigKey( trig )
											OnTextEdited=@((string v) => { trig.Key = v; }) onsubmit=@Commit />
										<div class="ts-cap">space, w, mouse1</div>
									</div>
								}

								@if ( slot.NeedsName )
								{
									<div class="ts-field w200">
										<div class="ts-lab">Name</div>
										<TextEntry class="ts-in" Value=@TrigName( trig )
											OnTextEdited=@((string v) => { trig.Name = v; }) onsubmit=@Commit />
										<div class="ts-cap">@slot.NameHint</div>
									</div>
								}

								@if ( trig.Kind == TipTriggerKind.AtLeast )
								{
									<div class="ts-field w150">
										<div class="ts-lab">At least</div>
										<div class="ts-step">
											<div class="ts-stp" onclick=@(() => BumpThreshold( trig, -1f ))>−</div>
											<div class="ts-val">@TrigThreshold( trig )</div>
											<div class="ts-stp" onclick=@(() => BumpThreshold( trig, 1f ))>+</div>
										</div>
									</div>
								}

								@if ( trig.Kind == TipTriggerKind.Timer )
								{
									<div class="ts-field w150">
										<div class="ts-lab">Seconds</div>
										<div class="ts-step">
											<div class="ts-stp" onclick=@(() => BumpSeconds( trig, -1f ))>−</div>
											<div class="ts-val">@TrigSeconds( trig )</div>
											<div class="ts-stp" onclick=@(() => BumpSeconds( trig, 1f ))>+</div>
										</div>
									</div>
								}

								@if ( trig.Kind == TipTriggerKind.AnalogAxis )
								{
									<div class="ts-field w150">
										<div class="ts-lab">Magnitude</div>
										<div class="ts-step">
											<div class="ts-stp" onclick=@(() => BumpMagnitude( trig, -0.1f ))>−</div>
											<div class="ts-val">@TrigMagnitude( trig )</div>
											<div class="ts-stp" onclick=@(() => BumpMagnitude( trig, 0.1f ))>+</div>
										</div>
										<div class="ts-cap">0 to 1</div>
									</div>
								}

								@if ( slot.IsCompletion )
								{
									<div class="ts-field grow last">
										<div class="ts-lab">Max show seconds</div>
										<div class="ts-step">
											<div class="ts-stp" onclick=@(() => BumpMaxShow( -1f ))>−</div>
											<div class="ts-val w80">@DraftMaxShow</div>
											<div class="ts-stp" onclick=@(() => BumpMaxShow( 1f ))>+</div>
										</div>
										<div class="ts-cap">0 = never auto-complete</div>
									</div>
								}
							</div>

							@if ( trig.Kind == TipTriggerKind.AnalogAxis )
							{
								<div class="ts-chips">
									@foreach ( var src in TipStudioTrigger.AllAnalogSources )
									{
										var source = src;
										<div class="ts-chip @(trig.AnalogSource == source ? "on" : "")"
											onclick=@(() => SetSource( trig, source ))>@TipStudioTrigger.SourceName( source )</div>
									}
								</div>
							}

							@if ( slot.IsComposite )
							{
								<div class="ts-chips">
									<div class="ts-chip" onclick=@(() => AddChild( trig ))>add one</div>
									@if ( trig.Children.Count == 0 )
									{
										<div class="ts-cap flat">@slot.EmptyCompositeHint</div>
									}
								</div>

								@foreach ( var c in trig.Children.ToList() )
								{
									var child = c;
									<div class="ts-child">
										<div class="ts-chips">
											@foreach ( var k in TipStudioTrigger.AllKinds )
											{
												var kind = k;
												<div class="ts-chip small @(child.Kind == kind ? "on" : "")"
													onclick=@(() => SetKind( child, kind ))>@TipStudioTrigger.KindName( kind )</div>
											}
											<div class="ts-chip small drop" onclick=@(() => RemoveChild( trig, child ))>remove</div>
										</div>
										<div class="ts-frow">
											<div class="ts-field grow last">
												<div class="ts-lab-row">
													<div class="ts-lab">Value</div>
													<div class="ts-cap flat">@ChildHint( child )</div>
												</div>
												<TextEntry class="ts-in" Value=@ChildValue( child )
													OnTextEdited=@((string v) => SetChildValue( child, v )) onsubmit=@Commit />
											</div>
										</div>
									</div>
								}
							}
						</div>
					}

					@* ---- what the draft would do wrong ---- *@
					@if ( TipsStudio.ShadowedBy is not null )
					{
						<div class="ts-warn">
							<div class="ts-warn-b">!</div>
							<div class="ts-warn-t">
								<div class="ts-warn-l">A code tip already owns this id.</div>
								<div class="ts-warn-l">Your file sits behind it in the catalog.</div>
							</div>
						</div>
					}

					@foreach ( var n in NoteBlocks )
					{
						var note = n;
						<div class="ts-warn">
							<div class="ts-warn-b">!</div>
							<div class="ts-warn-t">
								@foreach ( var l in note.Lines )
								{
									var line = l;
									<div class="ts-warn-l">@line</div>
								}
							</div>
						</div>
					}
				</div>

				@* ================= right: preview and bake ================= *@
				<div class="ts-rail">
					<div class="ts-rail-hdr">
						<div class="ts-kicker">LIVE PREVIEW</div>
						<div class="ts-btn-row">
							<div class="ts-btn small gap" onclick=@CompleteNow>Complete it</div>
							<div class="ts-btn pri small" onclick=@TestFire>Test fire</div>
						</div>
					</div>

					@* Pinned above the scrolling preview: these four pick what the REAL lower-left card
						shows, and a control that scrolls out of sight is a control nobody finds. *@
					<div class="ts-pv-row">
						<div class="ts-pv-key">Live card</div>
						<div class="ts-chip @(TipsStudio.PreviewOn ? "on" : "")"
							onclick=@TogglePreview>@(TipsStudio.PreviewOn ? "showing" : "show it")</div>
						<div class="ts-chip @(TipsStudio.PinnedDevice == TipDevice.KeyboardMouse ? "on" : "")"
							onclick=@(() => PinDevice( TipDevice.KeyboardMouse ))>keyboard</div>
						<div class="ts-chip @(TipsStudio.PinnedDevice == TipDevice.Gamepad ? "on" : "")"
							onclick=@(() => PinDevice( TipDevice.Gamepad ))>pad</div>
						<div class="ts-chip @(TipsStudio.PinnedDevice is null ? "on" : "")"
							onclick=@(() => PinDevice( null ))>live</div>
					</div>

					<div class="ts-rail-body" @ref="RailBody">
					<div class="ts-well">
						@foreach ( var p in PreviewCards )
						{
							var card = p;
							<div class="ts-pv @(card.Last ? "last" : "")">
								<div class="ts-pv-lab">@card.Label</div>
								<div class="tsp-card">
									<div class="tsp-stripe"></div>
									<div class="tsp-in">
										@if ( !string.IsNullOrEmpty( card.Icon ) )
										{
											<div class="tsp-glyph">@card.Icon</div>
										}
										<div class="tsp-body">
											<div class="tsp-kicker">GUIDE</div>
											<div class="tsp-text">
												@foreach ( var seg in card.Segments )
												{
													var run = seg;
													if ( run.Kind == TipSegmentKind.Key )
													{
														<span class="tsp-key">@run.Text</span>
													}
													else if ( run.Kind == TipSegmentKind.GamepadButton )
													{
														<span class="tsp-pad">@run.Text</span>
													}
													else
													{
														<span>@run.Text</span>
													}
												}
											</div>
										</div>
										<div class="tsp-x">×</div>
									</div>
								</div>
							</div>
						}
						<div class="ts-cap well">renders exactly what the coach will show</div>
					</div>
					</div>

					<div class="ts-out">
						<div class="ts-btn-row">
							<div class="ts-btn grow gap" onclick=@CopyJson>@_copyLabel</div>
							<div class="ts-btn pri grow" onclick=@Stage>Write to project</div>
						</div>
						<div class="ts-out-line">@BakeTarget</div>
						<div class="ts-btn-row pad">
							<div class="ts-btn small" onclick=@ClearStaged>Clear staged</div>
							<div class="ts-out-line inline">@StatusLine</div>
						</div>
					</div>
				</div>

			</div>
		</div>
	</div>
}
</root>

@code
{
	// ---- mounting ----

	/// <summary>The raw key that OPENS the Studio. A plain letter on purpose: the editor eats F1 to F12 in
	/// play-in-editor. It only opens, never closes, so pressing it inside a text box just types the letter.
	/// Close with the header's × or <c>fg_tips_studio 0</c>.</summary>
	[Property] public string OpenKey { get; set; } = "T";

	/// <summary>Whether the Studio starts open. Off by default: an authoring panel that appears unbidden over
	/// a game is a bug. This, and only this, decides the boot state; the persisted convar never does.</summary>
	[Property] public bool OpenOnStart { get; set; }

	/// <summary>Force the Studio shut anywhere but the editor. On by default: it is an authoring tool, and a
	/// published build has nothing to author. Turn it off if you want it in your own standalone dev build.</summary>
	[Property] public bool EditorOnly { get; set; } = true;

	// @ref binds to an auto-PROPERTY. On a bare private field it silently never assigns, and the
	// CanDragScroll fix below would quietly do nothing.
	Sandbox.UI.Panel ListBody { get; set; }
	Sandbox.UI.Panel MidBody { get; set; }
	Sandbox.UI.Panel RailBody { get; set; }

	bool _booted;
	bool _wasOpen;
	int _revision;
	string _copyLabel = "Copy .tip JSON";
	string _stageLine = "";

	/// <summary>Which dropdown is showing its options, or null. One at a time: the lists sit in flow under
	/// their field, so two open at once would push the column around for no reason.</summary>
	string _openDrop;

	/// <summary>The prerequisite picker's key. The trigger pickers key off their slot name.</summary>
	const string PrereqDrop = "prereq";

	bool IsOpen( string key ) => _openDrop == key;

	void ToggleDrop( string key )
	{
		_openDrop = _openDrop == key ? null : key;
		Refresh();
	}

	// ---- the catalog list ----

	/// <summary>One row of the tip list. A struct of finished strings so the markup interpolates single
	/// identifiers only, never a chained member read (which renders blank in several razor cases).</summary>
	public struct Entry
	{
		public string Id;
		public string Source;
		public string State;
		public bool Selected;
	}

	/// <summary>Every tip in the merged catalog, read fresh (so an edited .tip appears the moment the
	/// catalog rebuilds), labelled with the source it resolved from and ordered the way the coach picks
	/// them: highest priority first.</summary>
	List<Entry> Entries
	{
		get
		{
			var list = new List<Entry>();
			var view = TipsCatalog.View;
			var activeId = TipsCoach.ActiveTip?.Id;
			var opened = TipsStudio.OpenedFrom;

			foreach ( var def in view.Tips.OrderByDescending( t => t.Priority ).ThenBy( t => t.Id, StringComparer.Ordinal ) )
			{
				var source = view.SourceById.TryGetValue( def.Id, out var s ) ? s : "unknown";

				list.Add( new Entry
				{
					Id = def.Id,
					Source = source,
					State = def.Id == activeId ? "on screen" : ( TipsCoach.IsCompleted( def.Id ) ? "done" : null ),
					Selected = def.Id == opened,
				} );
			}

			return list;
		}
	}

	/// <summary>The header's one line: the package, the catalog, and what the middle column is editing.
	/// One interpolated string rather than three text nodes, because the in-editor codegen drops the
	/// whitespace between a literal and an expression.</summary>
	string HeaderMeta => $"fieldguide.tips · {CatalogSummary} · {DraftOrigin}";

	/// <summary>How many tips and where they came from. A source you did not expect is a tip left over
	/// from somewhere else.</summary>
	string CatalogSummary
	{
		get
		{
			var view = TipsCatalog.View;
			if ( view.Tips.Count == 0 )
				return "no tips yet";

			var counts = new Dictionary<string, int>();
			foreach ( var kv in view.SourceById )
				counts[kv.Value] = counts.TryGetValue( kv.Value, out var n ) ? n + 1 : 1;

			var parts = counts.OrderBy( kv => kv.Key, StringComparer.Ordinal ).Select( kv => $"{kv.Value} {kv.Key}" );
			return $"{view.Tips.Count} tips · {string.Join( ", ", parts )}";
		}
	}

	void OpenTip( string id )
	{
		TipsStudio.OpenTip( id );
		ResetLabels();
		_openDrop = null;
		Refresh();
	}

	void NewTip()
	{
		TipsStudio.NewDraft();
		ResetLabels();
		_openDrop = null;
		Refresh();
	}

	void Rescan()
	{
		TipsCatalog.NoteAssetsChanged();
		Refresh();
	}

	// ---- draft editing ----

	string DraftOrigin => string.IsNullOrEmpty( TipsStudio.OpenedFrom )
		? "new tip"
		: $"editing {TipsStudio.OpenedFrom}";

	// Single-identifier reads for the markup. A razor interpolation of a CHAINED member read
	// (TipsStudio.Draft.Priority) renders blank in several cases; a plain property or a method call does not.
	string DraftId => TipsStudio.Draft.Id;
	string DraftText => TipsStudio.Draft.Text;
	string DraftTextPad => TipsStudio.Draft.TextPad;
	string DraftIcon => TipsStudio.Draft.Icon;
	int DraftPriority => TipsStudio.Draft.Priority;
	string DraftMaxShow => Show( TipsStudio.Draft.MaxShowSeconds );

	static string TrigKey( TipStudioTrigger t ) => t.Key;
	static string TrigName( TipStudioTrigger t ) => t.Name;
	static string TrigThreshold( TipStudioTrigger t ) => Show( t.Threshold );
	static string TrigSeconds( TipStudioTrigger t ) => Show( t.Seconds );
	static string TrigMagnitude( TipStudioTrigger t ) => Show( t.Magnitude );

	static string Show( float value ) => value.ToString( "0.##" );

	void BumpPriority( int delta )
	{
		TipsStudio.Draft.Priority += delta;
		Refresh();
	}

	void BumpMaxShow( float delta )
	{
		TipsStudio.Draft.MaxShowSeconds = MathF.Max( 0f, TipsStudio.Draft.MaxShowSeconds + delta );
		Refresh();
	}

	List<string> PrerequisiteList => TipsStudio.Draft.PrerequisiteTipIds ?? new List<string>();

	/// <summary>What the prerequisite field reads at rest. The list underneath ADDS one; the chips below the
	/// row remove them, which is the only honest shape for a field that holds several values.</summary>
	string PrereqFace
	{
		get
		{
			var have = PrerequisiteList;
			if ( have.Count == 0 )
				return "none";

			return have.Count == 1 ? have[0] : $"{have.Count} tips";
		}
	}

	/// <summary>Catalog ids this tip could wait on: everything except itself, the preview id, and the ones it
	/// already waits on.</summary>
	List<string> AvailablePrerequisites
	{
		get
		{
			var have = new HashSet<string>( PrerequisiteList, StringComparer.Ordinal );
			var mine = TipsStudio.Draft.Id ?? "";
			return TipsCatalog.Active
				.Select( t => t.Id )
				.Where( id => id != mine && id != TipsStudio.PreviewId && !have.Contains( id ) )
				.OrderBy( id => id, StringComparer.Ordinal )
				.ToList();
		}
	}

	void AddPrerequisite( string id )
	{
		TipsStudio.Draft.PrerequisiteTipIds.Add( id );
		_openDrop = null;
		Refresh();
	}

	void RemovePrerequisite( string id )
	{
		TipsStudio.Draft.PrerequisiteTipIds.Remove( id );
		Refresh();
	}

	// ---- the two trigger pickers ----

	/// <summary>The Completion and Relevance pickers as data, so ONE block of markup renders both. A
	/// RenderFragment would be the other way to share it, and RenderFragments under-measure here.</summary>
	public struct TriggerSlot
	{
		public string Key;
		public string Title;
		public string Blurb;
		public TipStudioTrigger Trigger;
		public bool IsCompletion;
		public bool NeedsName;
		public string NameHint;
		public bool IsComposite;
		public string EmptyCompositeHint;
	}

	List<TriggerSlot> TriggerSlots
	{
		get
		{
			var completion = TipsStudio.Draft.Completion ??= new TipStudioTrigger();
			var relevance = TipsStudio.Draft.Relevance ??= new TipStudioTrigger();

			return new List<TriggerSlot>
			{
				Slot( "completion", "COMPLETION TRIGGER", "what retires this tip", completion, true ),
				Slot( "relevance", "RELEVANCE TRIGGER", "an extra gate before it shows", relevance, false ),
			};
		}
	}

	static TriggerSlot Slot( string key, string title, string blurb, TipStudioTrigger trigger, bool isCompletion )
	{
		var kind = trigger.Kind;
		var needsName = kind == TipTriggerKind.Signal || kind == TipTriggerKind.Ever
			|| kind == TipTriggerKind.Flag || kind == TipTriggerKind.AtLeast;

		var hint = kind switch
		{
			TipTriggerKind.Signal => "Signal(...) string",
			TipTriggerKind.Ever => "ctx.Ever(...)",
			TipTriggerKind.Flag => "ctx.SetFlag(...)",
			_ => "ctx.SetNumber(...)",
		};

		return new TriggerSlot
		{
			Key = key,
			Title = title,
			Blurb = blurb,
			Trigger = trigger,
			IsCompletion = isCompletion,
			NeedsName = needsName,
			NameHint = hint,
			IsComposite = kind == TipTriggerKind.AnyOf || kind == TipTriggerKind.AllOf,
			EmptyCompositeHint = isCompletion && kind == TipTriggerKind.AllOf
				? "empty: the shape a TipTriggerObject retires"
				: "empty, so it never fires",
		};
	}

	void SetKind( TipStudioTrigger trigger, TipTriggerKind kind )
	{
		trigger.Kind = kind;
		_openDrop = null;
		Refresh();
	}

	static string ActionFace( TipStudioTrigger trigger )
		=> string.IsNullOrEmpty( trigger.Action ) ? "pick an action" : trigger.Action;

	void PickAction( TipStudioTrigger trigger, string action )
	{
		trigger.Action = action;
		_openDrop = null;
		Refresh();
	}

	void SetSource( TipStudioTrigger trigger, TipTriggerAnalogSource source )
	{
		trigger.AnalogSource = source;
		Refresh();
	}

	void BumpThreshold( TipStudioTrigger trigger, float delta )
	{
		trigger.Threshold = MathF.Max( 0f, trigger.Threshold + delta );
		Refresh();
	}

	void BumpSeconds( TipStudioTrigger trigger, float delta )
	{
		trigger.Seconds = MathF.Max( 0f, trigger.Seconds + delta );
		Refresh();
	}

	void BumpMagnitude( TipStudioTrigger trigger, float delta )
	{
		trigger.Magnitude = Math.Clamp( trigger.Magnitude + delta, 0f, 1f );
		Refresh();
	}

	void AddChild( TipStudioTrigger parent )
	{
		parent.Children.Add( new TipStudioTrigger { Kind = TipTriggerKind.Key } );
		Refresh();
	}

	void RemoveChild( TipStudioTrigger parent, TipStudioTrigger child )
	{
		parent.Children.Remove( child );
		Refresh();
	}

	/// <summary>A composed child edits its one parameter through a single box, whichever box its kind reads.
	/// Nesting a full picker per child would triple the panel for a case the format barely uses.</summary>
	static string ChildValue( TipStudioTrigger child ) => child.Kind switch
	{
		TipTriggerKind.InputAction => child.Action,
		TipTriggerKind.Key => child.Key,
		TipTriggerKind.Signal or TipTriggerKind.Ever or TipTriggerKind.Flag or TipTriggerKind.AtLeast => child.Name,
		TipTriggerKind.Timer => child.Seconds.ToString( "0.##" ),
		TipTriggerKind.AnalogAxis => child.Magnitude.ToString( "0.##" ),
		_ => "",
	};

	static void SetChildValue( TipStudioTrigger child, string value )
	{
		switch ( child.Kind )
		{
			case TipTriggerKind.InputAction: child.Action = value; break;
			case TipTriggerKind.Key: child.Key = value; break;
			case TipTriggerKind.Signal:
			case TipTriggerKind.Ever:
			case TipTriggerKind.Flag:
			case TipTriggerKind.AtLeast: child.Name = value; break;
			case TipTriggerKind.Timer:
				if ( float.TryParse( value, out var seconds ) ) child.Seconds = MathF.Max( 0f, seconds );
				break;
			case TipTriggerKind.AnalogAxis:
				if ( float.TryParse( value, out var magnitude ) ) child.Magnitude = Math.Clamp( magnitude, 0f, 1f );
				break;
		}
	}

	static string ChildHint( TipStudioTrigger child ) => TipStudioTrigger.FieldFor( child.Kind ) switch
	{
		"action" => "an input action name",
		"key" => "a raw key name",
		"name" => "the named condition",
		"name+threshold" => "the named number",
		"seconds" => "seconds",
		"stick+magnitude" => "magnitude, 0 to 1",
		"children" => "nest one level only",
		_ => "this kind takes no value",
	};

	// ---- the two preview cards ----

	/// <summary>One rendered card in the rail: the label above it and the runs inside it. Finished data, so
	/// the markup walks a list rather than calling into the parser mid-tree.</summary>
	public struct PreviewCard
	{
		public string Label;
		public string Icon;
		public List<TipSegment> Segments;
		public bool Last;
	}

	/// <summary>The draft as the player will read it on each device, side by side. The pad card runs the same
	/// keycap remap the shipped display does (TipsCoach.PadLabelFor), so a chip with no controller equivalent
	/// disappears here exactly as it would in the game.</summary>
	List<PreviewCard> PreviewCards
	{
		get
		{
			var text = TipsStudio.Draft.Text ?? "";
			var pad = TipsStudio.Draft.TextPad ?? "";
			var icon = TipsStudio.Draft.Icon ?? "";

			return new List<PreviewCard>
			{
				new PreviewCard
				{
					Label = "KEYBOARD",
					Icon = icon,
					Segments = TipSegment.Parse( text ).ToList(),
				},
				new PreviewCard
				{
					Label = "CONTROLLER",
					Icon = icon,
					Segments = PadRuns( text, pad ),
					Last = true,
				},
			};
		}
	}

	/// <summary>The pad-mode runs for a wording: its pad text when authored, then every keycap put through
	/// the game's pad label map. A mapped label reads as a controller chip; an unmappable one is dropped, the
	/// same two rules the shipped card follows.</summary>
	static List<TipSegment> PadRuns( string text, string textPad )
	{
		var runs = new List<TipSegment>();

		foreach ( var seg in TipSegment.Parse( TipDeviceText.PadTextOr( text, textPad ) ) )
		{
			if ( seg.Kind != TipSegmentKind.Key )
			{
				runs.Add( seg );
				continue;
			}

			var mapped = TipDeviceText.PadCap( seg.Text, TipsCoach.PadLabelFor );
			if ( string.IsNullOrEmpty( mapped ) )
				continue;

			runs.Add( new TipSegment( mapped, mapped == seg.Text ? TipSegmentKind.Key : TipSegmentKind.GamepadButton ) );
		}

		return runs;
	}

	// ---- preview, test fire ----

	List<string> ActionNames => TipsStudio.ActionNames.ToList();

	/// <summary>One authoring note, already broken into lines that fit.</summary>
	public struct NoteBlock
	{
		public List<string> Lines;
	}

	List<NoteBlock> NoteBlocks => TipsStudio.Notes
		.Select( n => new NoteBlock { Lines = Lines( n ) } )
		.ToList();

	/// <summary>An option list longer than this scrolls at a fixed height instead of growing the column.</summary>
	static string Tall( int count ) => count > 6 ? "tall" : "";

	/// <summary>
	/// Chunk a sentence into lines short enough to lay out as text. A run that overflows its box does not
	/// wrap here: the style engine rasterizes it as a solid grey block, or drops it to an empty box. 46
	/// characters is one comfortable line in the widest box this panel has, and it is the same ceiling
	/// TipStudioText warns tip authors about.
	/// </summary>
	static List<string> Lines( string text )
	{
		var lines = new List<string>();
		if ( string.IsNullOrWhiteSpace( text ) )
			return lines;

		var line = "";

		foreach ( var word in text.Split( ' ' ) )
		{
			if ( string.IsNullOrEmpty( word ) )
				continue;

			if ( line.Length == 0 )
				line = word;
			else if ( line.Length + 1 + word.Length > 46 )
			{
				lines.Add( line );
				line = word;
			}
			else
				line = line + " " + word;
		}

		if ( line.Length > 0 )
			lines.Add( line );

		return lines;
	}

	void TogglePreview()
	{
		if ( TipsStudio.PreviewOn )
			TipsStudio.StopPreview( Scene );
		else
			TipsStudio.PushPreview( Scene );

		Refresh();
	}

	void PinDevice( TipDevice? device )
	{
		TipsStudio.PinnedDevice = device;
		Refresh();
	}

	void TestFire()
	{
		TipsStudio.TestFire( Scene );
		Refresh();
	}

	void CompleteNow()
	{
		TipsStudio.CompleteNow();
		Refresh();
	}

	/// <summary>The one-line status under the bake buttons: whatever the last bake action said, or what
	/// is waiting in the staging folder when it has said nothing yet.</summary>
	string StatusLine
	{
		get
		{
			var text = string.IsNullOrEmpty( _stageLine ) ? StagedLine : _stageLine;
			var lines = Lines( text );
			return lines.Count == 0 ? "" : lines[0];
		}
	}

	// ---- bake ----

	string BakeTarget
	{
		get
		{
			var file = TipStudioJson.FileNameFor( TipsStudio.Draft.Id );
			return file is null
				? "Give the tip an id: the file takes its name."
				: $"writes Assets/tips/{file}";
		}
	}

	string StagedLine
	{
		get
		{
			var count = TipsStudio.StagedCount;
			return count == 0 ? "nothing staged" : $"{count} staged for the editor";
		}
	}

	void CopyJson()
	{
		TipsStudio.CopyJson();
		_copyLabel = "Copied!";
		Refresh();
	}

	void Stage()
	{
		_stageLine = TipsStudio.Stage();
		Refresh();
	}

	void ClearStaged()
	{
		_stageLine = TipsStudio.ClearStaged();
		Refresh();
	}

	void ResetLabels()
	{
		_copyLabel = "Copy .tip JSON";
		_stageLine = "";
	}

	// ---- open / close, boot, cursor ----

	/// <summary>Bump the panel's own revision so the next frame rebuilds it. Every click calls this; typing
	/// into the id box does NOT, because a rebuild would take the cursor out of the box you are typing in.</summary>
	void Refresh() => _revision++;

	/// <summary>Enter in a text box: push the wording at the preview card and refresh everything derived
	/// from it.</summary>
	void Commit()
	{
		if ( TipsStudio.PreviewOn )
			TipsStudio.PushPreview( Scene );

		Refresh();
	}

	void Close()
	{
		TipsStudio.StopPreview( Scene );
		TipsStudio.Open = false;
		_openDrop = null;
		ResetLabels();
	}

	protected override void OnTreeBuilt()
	{
		// A background press-drag over a scrolling region must not pan the content or eat a button click; the
		// wheel and the scrollbar still scroll.
		if ( ListBody is not null )
			ListBody.CanDragScroll = false;

		if ( MidBody is not null )
			MidBody.CanDragScroll = false;

		if ( RailBody is not null )
			RailBody.CanDragScroll = false;
	}

	protected override void OnUpdate()
	{
		// BOOT. `fg_tips_studio` is a convar and s&box persists convars across sessions, so a session could
		// otherwise come up with an authoring panel open from a value set weeks ago. This component's own
		// OpenOnStart decides the boot state and the persisted value never does. Deliberately in the FIRST
		// UPDATE, not OnStart: a panel created in code is configured by whatever created it, and OnStart would
		// race that assignment.
		if ( !_booted )
		{
			_booted = true;
			TipsStudio.Open = OpenOnStart;
		}

		// The Studio is an authoring tool; a published build has nothing to author with it.
		if ( EditorOnly && !Application.IsEditor )
		{
			TipsStudio.Open = false;
			return;
		}

		// Opens only. Closing is the header × or the convar, so this key can never fight a text box.
		if ( !TipsStudio.Open && !string.IsNullOrEmpty( OpenKey ) && Input.Keyboard.Pressed( OpenKey ) )
		{
			TipsStudio.Open = true;
			Refresh();
		}

		if ( TipsStudio.Open )
		{
			Mouse.Visibility = MouseVisibility.Visible;
			_wasOpen = true;
		}
		else if ( _wasOpen )
		{
			_wasOpen = false;
			ResetLabels();
			TipsStudio.StopPreview( Scene );
		}
	}

	protected override void OnDestroy()
	{
		// Everything the Studio pins is static and would otherwise follow the developer into the next scene:
		// the preview draft, a test-fire draft, and the pinned preview device.
		TipsStudio.Shutdown();
	}

	// Fold the things a CLICK changes, and nothing anyone TYPES into. A rebuild rehomes every TextEntry, which
	// takes the cursor out of the box mid-word, so Id and a trigger's Key and Name are deliberately absent:
	// the panel repaints when you press Enter or click, via _revision.
	protected override int BuildHash()
	{
		var hc = new HashCode();
		hc.Add( TipsStudio.Open );
		hc.Add( TipsStudio.OpenedFrom );
		hc.Add( TipsStudio.PreviewOn );
		hc.Add( TipsStudio.PinnedDevice );
		hc.Add( TipsCoach.ActiveTip?.Id );
		hc.Add( _copyLabel );
		hc.Add( _stageLine );
		hc.Add( _openDrop );
		hc.Add( _revision );
		hc.Add( TipsStudio.Draft.Priority );
		hc.Add( TipsStudio.Draft.MaxShowSeconds );
		hc.Add( PrerequisiteList.Count );

		foreach ( var slot in TriggerSlots )
		{
			hc.Add( slot.Trigger.Kind );
			hc.Add( slot.Trigger.Action );
			hc.Add( slot.Trigger.Threshold );
			hc.Add( slot.Trigger.Seconds );
			hc.Add( slot.Trigger.AnalogSource );
			hc.Add( slot.Trigger.Magnitude );
			hc.Add( slot.Trigger.Children.Count );
			foreach ( var child in slot.Trigger.Children )
				hc.Add( child.Kind );
		}

		return hc.ToHashCode();
	}
}
fieldguide.tips / Code/TipsWorld.cs
Game library
using System;
using Sandbox;

namespace FieldGuide.Tips;

/// <summary>
/// Optional world seams a game sets once at bootstrap so world-anchored triggers can read the local
/// player. A library cannot reach into a game's player, so these stay null until the game wires them.
///
/// All are fail-inert: a trigger that needs an unset seam is simply INERT (it never fires and never
/// throws), so nothing crashes when a game skips them. Input, Signal and Timer triggers need no seam at
/// all. Set the seams you use, leave the rest null.
///
/// <code>
/// using Sandbox;
/// using FieldGuide.Tips;
///
/// TipsWorld.LocalPlayerPosition = () => MyLocalPlayer.WorldPosition;
/// TipsWorld.AimRay = () => new Ray( MyCamera.WorldPosition, MyCamera.WorldRotation.Forward );
/// </code>
/// </summary>
public static class TipsWorld
{
	/// <summary>Whether a local player exists to coach. Read by the self-driving coach when no context is
	/// pushed. Default: always true, so input-only tips show without any bootstrap wiring.</summary>
	public static Func<bool> HasLocalPlayer { get; set; } = static () => true;

	/// <summary>Local player world position, for <see cref="TipTriggerObject.Mode.PlayerEntered"/> triggers.
	/// Null leaves those triggers inert.</summary>
	public static Func<Vector3> LocalPlayerPosition { get; set; }

	/// <summary>Camera / aim ray, for <see cref="TipTriggerObject.Mode.LookedAt"/> triggers. Null leaves
	/// those triggers inert.</summary>
	public static Func<Ray> AimRay { get; set; }
}
fieldguide.tips / Studio/TipsStudio.cs
Game library
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace FieldGuide.Tips;

/// <summary>
/// The Tips Studio's state and every action its panel takes. The panel
/// (<see cref="TipsStudioPanel"/>) is markup over this; everything that decides something lives here, so the
/// razor stays readable and this stays testable by eye.
///
/// WHAT IT IS. An authoring surface for tips that runs inside your game: list the merged catalog, open any
/// tip in an editor, watch the real card change as you type, fire the tip and its completion for real, and
/// bake the result out as a <c>.tip</c> file. Nothing here is part of a shipped game's runtime: the panel
/// only exists if you add it, it starts closed, and <c>fg_tips_studio</c> is off by default.
///
/// TWO WAYS A DRAFT REACHES THE COACH, and they are deliberately different:
/// <list type="bullet">
/// <item>PREVIEW registers the draft under <see cref="PreviewId"/>, an id nothing else uses, and force-shows
/// it. It is a picture of the card. It cannot be shadowed by a real tip with the same id, which is what would
/// happen if it registered under the draft's own id (drafts are the lowest-precedence source), and it cannot
/// mark anything complete.</item>
/// <item>TEST FIRE registers the draft under its OWN id and shows it, so completing it retires the real tip
/// and the chain advances the way it will in the game. If a code or asset tip already owns that id, that one
/// wins, which is correct: you are testing the chain, not the draft.</item>
/// </list>
///
/// CLEAN-UP. Everything it touches is static and would otherwise outlive the scene: the preview draft, the
/// test-fire draft, and the pinned preview device. <see cref="Shutdown"/> hands all of it back, and the panel
/// calls it from OnDestroy.
/// </summary>
public static class TipsStudio
{
	/// <summary>The id the live preview registers under. Long and namespaced on purpose: it must never
	/// collide with a real tip, because a collision would silently show the real tip instead of the draft.</summary>
	public const string PreviewId = "fg_tips_studio_preview";

	/// <summary>Folder under <c>FileSystem.Data</c> the Studio stages baked tips in, for the editor menu
	/// action to pick up. Staging through a file rather than a live static is what lets you bake in play and
	/// write the asset after you have stopped playing.</summary>
	public const string StageFolder = "fieldguide_tips_studio";

	// ------------------------------------------------------------------
	// Open / close
	// ------------------------------------------------------------------

	private static bool _open;

	/// <summary>Open or close the Tips Studio. Off by default, and the panel forces it off at boot: s&amp;box
	/// persists convars between sessions, so without that a value set weeks ago would open an authoring panel
	/// over someone's game.</summary>
	[ConVar( "fg_tips_studio", Help = "Open or close the Tips Studio authoring panel (dev tool, off by default)" )]
	public static bool Open
	{
		get => _open;
		set => _open = value;
	}

	/// <summary>Which tab is showing.</summary>
	public static StudioTab Tab { get; set; } = StudioTab.Tips;

	/// <summary>The Studio's three tabs.</summary>
	public enum StudioTab
	{
		/// <summary>The merged catalog, with source labels.</summary>
		Tips,

		/// <summary>The draft editor: wording, order, triggers, preview and test fire.</summary>
		Draft,

		/// <summary>The bake-out surface: the .tip JSON, Copy, and staging for the editor.</summary>
		Bake,
	}

	// ------------------------------------------------------------------
	// The draft
	// ------------------------------------------------------------------

	private static TipStudioDraft _draft = new();

	/// <summary>The tip being authored. Never null.</summary>
	public static TipStudioDraft Draft
	{
		get => _draft ??= new TipStudioDraft();
		set => _draft = value ?? new TipStudioDraft();
	}

	/// <summary>The catalog id the draft was opened from, or null for a new tip. Shown so it is obvious
	/// whether you are editing something that already exists.</summary>
	public static string OpenedFrom { get; private set; }

	/// <summary>Start a new, empty tip.</summary>
	public static void NewDraft()
	{
		Draft = new TipStudioDraft { Priority = 100 };
		OpenedFrom = null;
	}

	/// <summary>Open a catalog tip in the editor. The two code-only predicates have no authored form and are
	/// dropped; <see cref="DroppedPredicates"/> says so on screen.</summary>
	public static void OpenTip( string id )
	{
		var def = TipsCatalog.Active.FirstOrDefault( t => t.Id == id );
		if ( def is null )
			return;

		Draft = TipStudioDraft.FromDefinition( def );
		OpenedFrom = id;
		DroppedPredicates = HasCodePredicates( def );
		Tab = StudioTab.Draft;
	}

	/// <summary>True when the tip currently open was carrying a <c>Trigger</c> or <c>CompleteWhen</c>
	/// predicate, which a <c>.tip</c> file cannot hold. Baking it out keeps the declarative triggers and
	/// loses the predicate, so the panel warns before you do.</summary>
	public static bool DroppedPredicates { get; private set; }

	private static bool HasCodePredicates( TipDefinition def )
	{
		// A tip that never set them carries the record's defaults. Comparing against a fresh default is the
		// only way to tell "the author wrote a predicate" from "the record filled one in".
		var plain = new TipDefinition { Id = "probe", Text = "" };
		return def.Trigger != plain.Trigger || def.CompleteWhen != plain.CompleteWhen;
	}

	/// <summary>The authoring notes for the current draft (grey-block run lengths, triggers that can never
	/// fire). Recomputed on read; the panel refreshes them when you press Enter in a box or click anything,
	/// because rebuilding the panel while you type would take the cursor out of the box.</summary>
	public static IReadOnlyList<string> Notes => TipStudioText.Warnings( Draft );

	/// <summary>The draft as <c>.tip</c> JSON: what Copy puts on the clipboard and what a bake writes.</summary>
	public static string Json => TipStudioJson.Write( Draft );

	/// <summary>True when the draft's id already names a tip from a HIGHER-precedence source, so a bake would
	/// be shadowed until that source lets go. Worth saying out loud before someone wonders why their new file
	/// does nothing.</summary>
	public static string ShadowedBy
	{
		get
		{
			if ( string.IsNullOrWhiteSpace( Draft.Id ) )
				return null;

			var source = TipsCatalog.SourceOf( Draft.Id );
			return source == "code" ? "code" : null;
		}
	}

	// ------------------------------------------------------------------
	// Live preview
	// ------------------------------------------------------------------

	/// <summary>Whether the real card is mirroring the draft right now.</summary>
	public static bool PreviewOn { get; private set; }

	/// <summary>Push the draft onto the real card, or refresh what is already there. Registers under
	/// <see cref="PreviewId"/> so a draft of an existing tip is not shadowed by the tip it copies.</summary>
	public static void PushPreview( Scene scene )
	{
		var def = Draft.ToDefinition();

		// The preview stands in for the draft even before it has an id, so an author sees the card from the
		// first character typed rather than after they remember to name it.
		var preview = new TipDefinition
		{
			Id = PreviewId,
			Text = Draft.Text ?? "",
			TextPad = string.IsNullOrEmpty( Draft.TextPad ) ? null : Draft.TextPad,
			Icon = Draft.Icon ?? "",
			Priority = def?.Priority ?? 0,
		};

		TipsCatalog.RegisterRuntime( preview );
		PreviewOn = true;

		var coach = LiveCoach( scene );
		coach?.ForceShow( PreviewId );
	}

	/// <summary>Take the preview off the card and out of the catalog.</summary>
	public static void StopPreview( Scene scene )
	{
		PreviewOn = false;
		TipsCatalog.UnregisterRuntime( PreviewId );
		TipsCoach.PreviewDevice = null;

		// The card may still be showing a tip that no longer exists. Drop it rather than dismiss it: dismissing
		// would write the fake preview id into the player's saved progress and leave it there for good.
		if ( TipsCoach.ActiveTip?.Id == PreviewId )
			LiveCoach( scene )?.DropActive();
	}

	/// <summary>Which device the preview card is pinned to, or null for whatever the player last used.</summary>
	public static TipDevice? PinnedDevice
	{
		get => TipsCoach.PreviewDevice;
		set => TipsCoach.PreviewDevice = value;
	}

	// ------------------------------------------------------------------
	// Test fire
	// ------------------------------------------------------------------

	/// <summary>
	/// Make the draft the live tip UNDER ITS OWN ID and show it now. From here its completion is the real
	/// thing: fire the trigger in the game, or press Complete, and the tip retires and the chain moves on.
	/// Returns false when the draft has no id yet.
	/// </summary>
	public static bool TestFire( Scene scene )
	{
		var def = Draft.ToDefinition();
		if ( def is null )
			return false;

		// A test fire of a tip already marked complete would retire the moment it appeared.
		TipsCoach.Uncomplete( def.Id );
		TipsCatalog.UnregisterRuntime( PreviewId );
		PreviewOn = false;
		TipsCatalog.RegisterRuntime( def );

		var coach = LiveCoach( scene );
		if ( coach is null )
		{
			Log.Warning( "fg_tips: no TipsCoach in the scene, so there is nothing to show the tip on." );
			return false;
		}

		return coach.ForceShow( def.Id );
	}

	/// <summary>Fire the draft's completion by hand, the same path a world trigger uses. The tip retires and
	/// whatever waits on it becomes eligible.</summary>
	public static void CompleteNow()
	{
		if ( string.IsNullOrWhiteSpace( Draft.Id ) )
			return;

		TipsCoach.Complete( Draft.Id );
	}

	private static TipsCoach LiveCoach( Scene scene )
		=> scene?.GetAllComponents<TipsCoach>().FirstOrDefault();

	// ------------------------------------------------------------------
	// Input actions (the action picker)
	// ------------------------------------------------------------------

	/// <summary>
	/// The project's real input actions, for the InputAction picker: <c>Input.ActionNames</c>, which is the
	/// engine's list from the current game's input settings, the same list the <c>[InputAction]</c> inspector
	/// dropdown draws from. Sorted, and empty rather than throwing outside a running game.
	/// </summary>
	public static IReadOnlyList<string> ActionNames
	{
		get
		{
			try
			{
				var names = Input.ActionNames?.Where( n => !string.IsNullOrWhiteSpace( n ) ).ToList();
				if ( names is null || names.Count == 0 )
					return Array.Empty<string>();

				names.Sort( StringComparer.OrdinalIgnoreCase );
				return names;
			}
			catch ( Exception )
			{
				return Array.Empty<string>();
			}
		}
	}

	// ------------------------------------------------------------------
	// Bake out
	// ------------------------------------------------------------------

	/// <summary>Copy the draft's <c>.tip</c> JSON to the system clipboard, from in game. Paste it into a new
	/// file under your project's <c>Assets/</c> and the editor picks it up as a tip.</summary>
	public static void CopyJson()
	{
		Sandbox.UI.Clipboard.SetText( Json );
	}

	/// <summary>
	/// Write the draft into <see cref="StageFolder"/> under <c>FileSystem.Data</c>, where the editor menu
	/// action "Field Guide / Write staged tips" picks it up and writes the real asset. Two steps because game
	/// code cannot write into a project's <c>Assets/</c> folder, and because staging survives the end of the
	/// play session, so you can author in play and land the file afterwards.
	/// </summary>
	/// <returns>A line for the panel saying what happened.</returns>
	public static string Stage()
	{
		var file = TipStudioJson.FileNameFor( Draft.Id );
		if ( file is null )
			return "Give the tip an id first.";

		try
		{
			FileSystem.Data.CreateDirectory( StageFolder );
			var path = $"{StageFolder}/{file}";
			FileSystem.Data.WriteAllText( path, Json );
			Log.Info( $"fg_tips: staged {file}. In the editor, run Field Guide / Write staged tips to Assets/tips." );

			// Short on purpose: this lands in a one-line status slot in the panel, and the console line
			// above already carries the full instruction.
			return $"staged {file}";
		}
		catch ( Exception e )
		{
			Log.Warning( $"fg_tips: could not stage {file} ({e.Message})." );
			return $"Could not stage {file}: {e.Message}";
		}
	}

	/// <summary>How many tips are waiting in the staging folder, so the panel can say whether there is
	/// anything for the editor action to do.</summary>
	public static int StagedCount
	{
		get
		{
			try
			{
				return FileSystem.Data.DirectoryExists( StageFolder )
					? FileSystem.Data.FindFile( StageFolder, "*.tip", false ).Count()
					: 0;
			}
			catch ( Exception )
			{
				return 0;
			}
		}
	}

	/// <summary>Empty the staging folder, for when a bake was a mistake or the files have landed.</summary>
	public static string ClearStaged()
	{
		try
		{
			if ( !FileSystem.Data.DirectoryExists( StageFolder ) )
				return "Nothing staged.";

			var cleared = 0;
			foreach ( var file in FileSystem.Data.FindFile( StageFolder, "*.tip", false ).ToList() )
			{
				FileSystem.Data.DeleteFile( $"{StageFolder}/{file}" );
				cleared++;
			}

			return cleared == 0 ? "Nothing staged." : $"Cleared {cleared} staged tip(s).";
		}
		catch ( Exception e )
		{
			return $"Could not clear the staging folder: {e.Message}";
		}
	}

	// ------------------------------------------------------------------
	// Shutdown
	// ------------------------------------------------------------------

	/// <summary>
	/// Hand back everything the Studio pinned: the preview and test-fire drafts, and the pinned preview
	/// device. Called from the panel's OnDestroy, because all of it is static and would otherwise follow the
	/// developer into the next scene, exactly the trap a scene-registered code catalog falls into.
	/// </summary>
	public static void Shutdown()
	{
		PreviewOn = false;
		TipsCoach.PreviewDevice = null;
		TipsCatalog.ClearRuntime();
		Open = false;
	}
}
fieldguide.tips / TipsCatalog.cs
Game library
using System;
using System.Collections.Generic;
using Sandbox;

namespace FieldGuide.Tips;

/// <summary>
/// The registry the coach reads. Three sources merge into <see cref="Active"/>, keyed by
/// <see cref="TipDefinition.Id"/>, highest precedence first:
/// <list type="number">
/// <item>CODE: whatever the game passed to <see cref="Register(IReadOnlyList{TipDefinition})"/>.</item>
/// <item>ASSETS: every <c>.tip</c> <see cref="TipResource"/> found via
/// <c>ResourceLibrary.GetAll&lt;TipResource&gt;()</c>, mapped through <see cref="TipResource.ToDefinition"/>.</item>
/// <item>DRAFTS: runtime tips injected via <see cref="RegisterRuntime(TipDefinition)"/> (a test-fire / dev
/// tool seam), lowest precedence.</item>
/// </list>
/// On an id collision CODE wins, then the asset, then the draft: a game's own code catalog is authored
/// intent, and assets are usually additive or mod content. This is the inverse of the RPG kit, where
/// authored assets override code demos. When all three sources are empty the shipped <see cref="Example"/>
/// surfaces so the panel still does something before any content is wired in.
///
/// Back-compat: <see cref="Register(IReadOnlyList{TipDefinition})"/> and <see cref="Active"/> keep their
/// v0.2 signatures. A game that only calls Register with no assets and no drafts reads exactly its own
/// list, in its own order, from <see cref="Active"/> just as before.
///
/// LIFETIME. Every source here is STATIC, so a catalog registered from a scene component outlives that
/// scene and that play session: load another scene in the same editor process and the old tips are still
/// the highest-priority thing the coach can pick, with nothing over there able to retire them. Register
/// from a component and you want <see cref="RegisterScoped(IReadOnlyList{TipDefinition})"/>, which hands the
/// previous catalog back when you dispose it in OnDestroy. Runtime drafts have the same rule and the same
/// answer, <see cref="ClearRuntime"/>.
///
/// FRESHNESS. The merged view is DERIVED state: one <see cref="TipCatalogView"/> holding the ordered list
/// and the id-to-source map together, rebuilt whenever a source moves and swapped in whole, so the two can
/// never disagree with each other. Sources announce their own moves: <see cref="Register"/> and the draft calls
/// invalidate directly, and <see cref="TipResource"/> calls <see cref="NoteAssetsChanged"/> from its PostLoad
/// / PostReload, which is what makes an edited <c>.tip</c> reach a running session. <c>fg_tips_rebuild</c> is
/// the manual reset for anything that slips past (a deleted asset, a code hotload).
///
/// Game-specific tip text (which keys open which panels, what the dev overlay does, spell and
/// potion lines) belongs in YOUR catalog, not in the kit. The kit owns the mechanic (priority,
/// prerequisites, trigger, complete-when, timeout); you own the words.
/// </summary>
public static class TipsCatalog
{
	// CODE source: the game's own registered catalog (highest precedence).
	private static IReadOnlyList<TipDefinition> _code = Array.Empty<TipDefinition>();

	// DRAFT source: runtime-authored tips (lowest precedence), kept in their own store so a draft never
	// shadows a code or asset tip and a Rebuild can rescan assets without dropping live drafts.
	private static readonly Dictionary<string, TipDefinition> _drafts = new( StringComparer.Ordinal );

	// Source revisions. Bumped whenever that source moves; the snapshot records the three it was built from
	// and a read that finds any of them changed rebuilds. Comparing them is three field reads, no allocation,
	// which is what lets the coach ask for Active several times a frame.
	private static int _draftRevision;
	private static int _assetRevision;

	// The derived view (list + labels together, from the pure TipCatalogMerge) and the stamp saying what it
	// was built from. One reference, swapped in whole: there is no second static that could disagree with it.
	private static TipCatalogView _view;
	private static TipCatalogStamp _stamp;

	/// <summary>
	/// The merged, deduped walkthrough the coach reads (code + assets + drafts by the precedence above),
	/// or <see cref="Example"/> when every source is empty. Priority breaks ties; PrerequisiteTipIds
	/// sequences the spine. Derived and cached against the source revisions, so an edited or newly created
	/// <c>.tip</c> shows up on the next read; <c>fg_tips_rebuild</c> forces a rescan by hand.
	/// </summary>
	public static IReadOnlyList<TipDefinition> Active => Current().Tips;

	/// <summary>The merged catalog and its source labels together, for a reader that needs both and must not
	/// see them from two different builds (the Tips Studio's tip list).</summary>
	public static TipCatalogView View => Current();

	/// <summary>Install your own tutorial line (the CODE source). Call once at bootstrap; a null or empty
	/// list is ignored. Additive to any <c>.tip</c> assets and drafts; code wins id collisions.
	///
	/// The catalog is static and outlives the scene that registered it. Registering from a component that
	/// can be destroyed (a scene bootstrap, a dev harness) wants
	/// <see cref="RegisterScoped(IReadOnlyList{TipDefinition})"/> instead.</summary>
	public static void Register( IReadOnlyList<TipDefinition> tips )
	{
		if ( tips is null || tips.Count == 0 )
			return;
		_code = tips;
		Invalidate();
	}

	/// <summary>
	/// Register a code catalog that HANDS ITSELF BACK. Dispose the returned token (from your component's
	/// OnDestroy, or with a <c>using</c>) and whatever was registered before is restored, so a scene's tips
	/// cannot follow the player into the next scene. Disposing twice is a no-op, and disposing out of order
	/// still restores what this call displaced rather than clearing the catalog outright.
	///
	/// This is the seam the kit's own demo bootstrap discipline generalizes: statics outlive scenes, so
	/// whoever set one puts it back.
	/// </summary>
	public static IDisposable RegisterScoped( IReadOnlyList<TipDefinition> tips )
	{
		var previous = _code;
		Register( tips );
		return new ScopedCode( previous, tips );
	}

	private sealed class ScopedCode : IDisposable
	{
		private readonly IReadOnlyList<TipDefinition> _previous;
		private IReadOnlyList<TipDefinition> _mine;

		public ScopedCode( IReadOnlyList<TipDefinition> previous, IReadOnlyList<TipDefinition> mine )
		{
			_previous = previous ?? Array.Empty<TipDefinition>();
			_mine = mine;
		}

		public void Dispose()
		{
			if ( _mine is null )
				return; // already handed back

			// Only restore if OUR list is still the installed one. A later Register replaced us, and stomping
			// that would be worse than leaving it: the newer registration is the live intent.
			if ( ReferenceEquals( _code, _mine ) )
			{
				_code = _previous;
				Invalidate();
			}

			_mine = null;
		}
	}

	/// <summary>
	/// Inject (or replace) a live runtime draft tip (the lowest-precedence DRAFT source). An id that also
	/// names a code tip or an authored <c>.tip</c> asset resolves to that real tip, never the draft. The
	/// seam a test-fire / dev tool uses so an in-progress tip is previewable without an editor compile.
	/// </summary>
	public static void RegisterRuntime( TipDefinition def )
	{
		if ( def is null || string.IsNullOrEmpty( def.Id ) )
			return;
		_drafts[def.Id] = def;
		_draftRevision++;
		Invalidate();
	}

	/// <summary>Remove a draft tip previously injected via <see cref="RegisterRuntime(TipDefinition)"/>.</summary>
	public static void UnregisterRuntime( string id )
	{
		if ( string.IsNullOrEmpty( id ) || !_drafts.Remove( id ) )
			return;
		_draftRevision++;
		Invalidate();
	}

	/// <summary>Drop every runtime draft, leaving the code catalog and the <c>.tip</c> assets alone. The
	/// Tips Studio calls this when it shuts down: an authoring draft must not follow the developer into
	/// another scene or another session.</summary>
	public static void ClearRuntime()
	{
		if ( _drafts.Count == 0 )
			return;
		_drafts.Clear();
		_draftRevision++;
		Invalidate();
	}

	/// <summary>The ids of the live runtime drafts, so a dev tool can list or clean up exactly what it put
	/// in. Ordered for a stable readout.</summary>
	public static IReadOnlyList<string> RuntimeIds
	{
		get
		{
			var ids = new List<string>( _drafts.Keys );
			ids.Sort( StringComparer.Ordinal );
			return ids;
		}
	}

	/// <summary>Force a fresh merge (rescans <c>.tip</c> assets). Call after hot-loading new tip assets;
	/// otherwise the lazy build is enough. Code catalog and live drafts are preserved.</summary>
	public static void Rebuild() => Invalidate();

	/// <summary>
	/// The <c>.tip</c> asset source moved: a tip was loaded for the first time, or recompiled from disk after
	/// an edit. <see cref="TipResource"/> calls this from its PostLoad / PostReload, which is the engine hook
	/// a kit can reach (<c>ResourceLibrary.IEventListener</c> is internal), and it is what makes an edited tip
	/// appear in a running session. Cheap and idempotent: it bumps a counter and drops the derived snapshot.
	/// </summary>
	public static void NoteAssetsChanged()
	{
		_assetRevision++;
		Invalidate();
	}

	/// <summary>Rescan <c>.tip</c> assets and rebuild the merged catalog by hand, then print what came back.
	/// The fallback for anything the automatic hooks cannot see: a deleted asset, or a code hotload that left
	/// the derived view holding pre-hotload tips.</summary>
	[ConCmd( "fg_tips_rebuild" )]
	public static void RebuildCommand()
	{
		NoteAssetsChanged();
		var view = Current();
		var counts = new Dictionary<string, int>( StringComparer.Ordinal );
		foreach ( var kv in view.SourceById )
			counts[kv.Value] = counts.TryGetValue( kv.Value, out var n ) ? n + 1 : 1;

		var parts = new List<string>();
		foreach ( var kv in counts )
			parts.Add( $"{kv.Key}={kv.Value}" );
		parts.Sort( StringComparer.Ordinal );

		Log.Info( $"fg_tips: catalog rebuilt, {view.Tips.Count} tip(s) [{( parts.Count > 0 ? string.Join( " ", parts ) : "empty" )}]." );
	}

	/// <summary>Clear the code catalog and drafts so <see cref="Active"/> falls back to any authored assets,
	/// or to the shipped <see cref="Example"/> when there are none (mostly for tests / demos).</summary>
	public static void Reset()
	{
		_code = Array.Empty<TipDefinition>();
		_drafts.Clear();
		_draftRevision++;
		Invalidate();
	}

	private static void Invalidate() => _view = null;

	/// <summary>The current derived view, rebuilt when any source has moved since it was made.</summary>
	private static TipCatalogView Current()
	{
		var view = _view;
		if ( view is not null && _stamp.Matches( _code, _draftRevision, _assetRevision ) )
			return view;

		_stamp = new TipCatalogStamp( _code, _draftRevision, _assetRevision );
		return _view = TipCatalogMerge.Merge( _code, AssetDefinitions(), _drafts.Values, BuildExample() );
	}

	private static IEnumerable<TipDefinition> AssetDefinitions()
	{
		foreach ( var res in LoadAssets() )
		{
			var def = res?.ToDefinition();
			if ( def is not null )
				yield return def;
		}
	}

	/// <summary>Which source a tip id resolved from ("code" / "asset" / "draft" / "example"), or "unknown".
	/// Used by the <c>fg_tips_list</c> console command and the Tips Studio's tip list, where the label is how
	/// a tip left over from another scene gives itself away.</summary>
	public static string SourceOf( string id )
	{
		var view = Current();
		return !string.IsNullOrEmpty( id ) && view.SourceById.TryGetValue( id, out var src ) ? src : "unknown";
	}

	private static IEnumerable<TipResource> LoadAssets()
	{
		// ResourceLibrary is only meaningful inside a running game/editor; guard so a headless or unit
		// context (no resource system) merges cleanly instead of throwing.
		try
		{
			return ResourceLibrary.GetAll<TipResource>();
		}
		catch
		{
			return Array.Empty<TipResource>();
		}
	}

	/// <summary>
	/// A minimal, GENERIC illustration, enough to show every mechanic (a completed-by-behaviour spine,
	/// a prerequisite chain, a contextual interrupt via <see cref="TipDefinition.Trigger"/>, and a
	/// timed "glance" beat via <see cref="TipDefinition.MaxShowSeconds"/>). It is meant to be REPLACED:
	/// register your own game's tips with <see cref="Register(IReadOnlyList{TipDefinition})"/>.
	///
	/// A computed property, not a <c>static readonly</c> field: a field's initializer runs once, so after a
	/// code hotload the old list survives and an edit to these tips is invisible until the editor restarts.
	/// A property is a method, and methods come back fresh from a hotload.
	/// </summary>
	public static IReadOnlyList<TipDefinition> Example => BuildExample();

	private static IReadOnlyList<TipDefinition> BuildExample() => new List<TipDefinition>
	{
		// A calm spine beat that retires when the player performs its action. Note the mixed input
		// markup: *asterisks* render a keyboard keycap, `backticks` render a gamepad button chip, so one
		// line can prompt both control schemes.
		new()
		{
			Id = "move", Icon = "🧭", Priority = 100,
			Text = "Move with *W* *A* *S* *D* or the `Left Stick`. Hold *Shift* / `LB` to sprint.",
			CompleteWhen = static c => c.EverMoved,
		},
		// A second beat gated behind the first (prerequisite chain).
		// The same beat with pad-specific wording: on a controller the display shows TextPad instead of
		// Text (build plan point 2), so the prompt reads the button the player actually has.
		new()
		{
			Id = "interact", Icon = "💬", Priority = 90, PrerequisiteTipIds = new[] { "move" },
			Text = "Walk up to someone and press *E* to interact.",
			TextPad = "Walk up to someone and press `X` to interact.",
			CompleteWhen = static c => c.EverTalked,
		},
		// A "just glance" beat with no behavioural signal, it times out on its own.
		new()
		{
			Id = "look", Icon = "🗺️", Priority = 80, PrerequisiteTipIds = new[] { "interact" },
			Text = "Look around with the mouse to get your bearings.",
			MaxShowSeconds = 6f,
		},
		// A contextual interrupt: it outranks the calm spine while a fight is live.
		new()
		{
			Id = "combat", Icon = "❗", Priority = 130,
			Text = "Something's hostile! Attack with *LMB*, and hold *B* to block.",
			Trigger = static c => c.EverAggroed && c.InCombat,
			CompleteWhen = static c => !c.InCombat,
		},
	};
}
fieldguide.tips / Code/Demo/TipsDemoPawn.cs
Game library
using System.Linq;
using Sandbox;

namespace FieldGuide.Tips;

/// <summary>
/// The demo scene's stand-in player: a citizen that slides along the ground on the movement stick (or
/// W/A/S/D) and hops on the jump action. Deliberately the simplest thing that can be coached: plain
/// transform movement, one hand-integrated hop, no rigidbody, no collider, no controller. It exists so
/// the tips in <c>Assets/demo/</c> have real actions to retire on.
///
/// THE LOOK. The scene authors this object with a plain box renderer. At boot the pawn switches that off
/// and builds a dressed stock citizen in its place (<see cref="TipsDemoCitizen"/>), so the scene file
/// stays as authored and the editor viewport still shows the simple block when nothing is playing. The
/// movement, the play radius and the hop are untouched by the swap: only the visual changed.
///
/// Not part of the kit's runtime surface: delete <c>Code/Demo</c> and <c>Assets/demo</c> when you drop
/// the kit into your own project, and coach your own player instead.
/// </summary>
[Title( "Tips Demo Pawn" )]
[Category( "Field Guide Tips" )]
[Icon( "smart_toy" )]
public sealed class TipsDemoPawn : Component
{
	/// <summary>Ground speed in world units per second.</summary>
	[Property] public float MoveSpeed { get; set; } = 220f;

	/// <summary>Upward speed of one hop, in world units per second.</summary>
	[Property] public float JumpSpeed { get; set; } = 260f;

	/// <summary>Downward acceleration applied to a hop, in world units per second squared.</summary>
	[Property] public float Gravity { get; set; } = 900f;

	/// <summary>How far from its start the citizen may wander. The demo camera is fixed, so this is what
	/// keeps the citizen in frame, and the scene's camera is framed to contain exactly this disc. The
	/// marker sits 205 units from the start, so 220 lets the citizen walk onto it and a little past
	/// without opening up a corner of the yard that the camera would then have to cover for nothing.</summary>
	[Property] public float PlayRadius { get; set; } = 220f;

	/// <summary>The Input.config action that hops. Bound to Space on a keyboard and A on a pad in the
	/// s&amp;box default config, which is what the demo tips prompt.</summary>
	[Property] public string JumpAction { get; set; } = "Jump";

	/// <summary>Yaw the demo camera looks along, so pushing forward moves the citizen away from the camera
	/// instead of sideways. Change it with the camera.</summary>
	[Property] public float CameraYaw { get; set; } = 45f;

	/// <summary>Local Z of the citizen visual. The pawn object sits half a block above the ground because
	/// the authored box is centred on it, and the citizen's origin is at its feet, so the visual drops by
	/// that half height to stand on the floor instead of hovering.</summary>
	[Property] public float VisualZOffset { get; set; } = -25f;

	/// <summary>How briskly the citizen turns to face where it is going, in turns per second-ish. High
	/// enough to read as responsive, low enough that a flick of the stick does not snap it.</summary>
	[Property] public float TurnSpeed { get; set; } = 12f;

	private Vector3 _start;
	private float _height;
	private float _riseSpeed;
	private SkinnedModelRenderer _visual;
	private Rotation _facing;

	protected override void OnStart()
	{
		_start = WorldPosition;

		// Resting yaw looks back down the camera's line, so the citizen greets the player instead of
		// showing its back on the first frame.
		_facing = Rotation.FromYaw( CameraYaw + 180f );

		HideAuthoredBlock();

		_visual = TipsDemoCitizen.Build( GameObject, VisualZOffset );
		if ( _visual.IsValid() )
		{
			_visual.WorldRotation = _facing;
			Log.Info( "[tips] demo pawn: dressed citizen built in code, authored block renderer switched off." );
		}
	}

	protected override void OnUpdate()
	{
		var facing = Rotation.FromYaw( CameraYaw );
		var move = ReadMove();
		var dir = facing.Forward * move.x + facing.Left * move.y;
		if ( dir.Length > 1f )
			dir = dir.Normal;

		var flat = ( WorldPosition + dir * MoveSpeed * Time.Delta - _start ).WithZ( 0f );
		if ( flat.Length > PlayRadius )
			flat = flat.Normal * PlayRadius;

		var hopped = false;
		if ( _height <= 0f && _riseSpeed <= 0f && Input.Pressed( JumpAction ) )
		{
			_riseSpeed = JumpSpeed;
			hopped = true;
		}

		if ( _height > 0f || _riseSpeed > 0f )
		{
			_riseSpeed -= Gravity * Time.Delta;
			_height += _riseSpeed * Time.Delta;
			if ( _height <= 0f )
			{
				_height = 0f;
				_riseSpeed = 0f;
			}
		}

		var previous = WorldPosition;
		WorldPosition = _start + flat + Vector3.Up * _height;

		DriveVisual( previous, dir, hopped );
	}

	/// <summary>
	/// Movement as a forward/left pair. <c>Input.AnalogMove</c> carries the movement stick and, in a
	/// project whose Input.config binds the standard movement actions, the keyboard too. The raw W/A/S/D
	/// fallback keeps the demo drivable in a project that binds movement under other names, which is the
	/// same reason the movement tip completes on either the stick or those keys.
	/// </summary>
	private static Vector3 ReadMove()
	{
		var move = Input.AnalogMove;
		if ( move.Length > 0.01f )
			return move;

		var forward = ( Input.Keyboard.Down( "w" ) ? 1f : 0f ) - ( Input.Keyboard.Down( "s" ) ? 1f : 0f );
		var left = ( Input.Keyboard.Down( "a" ) ? 1f : 0f ) - ( Input.Keyboard.Down( "d" ) ? 1f : 0f );
		return new Vector3( forward, left, 0f );
	}

	/// <summary>
	/// Turn the citizen to face its travel and hand the animgraph a frame of locomotion. The legs read the
	/// distance actually covered rather than the stick, so at the play radius the clamp reads as standing
	/// still instead of running on the spot.
	/// </summary>
	private void DriveVisual( Vector3 previous, Vector3 wishDirection, bool hopped )
	{
		if ( !_visual.IsValid() )
			return;

		var travelled = ( WorldPosition - previous ).WithZ( 0f );
		var velocity = ( Time.Delta > 0f ? travelled / Time.Delta : Vector3.Zero ).WithZ( _riseSpeed );

		if ( travelled.Length > 0.01f )
		{
			var target = Rotation.LookAt( travelled.Normal, Vector3.Up );
			_facing = Rotation.Slerp( _facing, target, ( Time.Delta * TurnSpeed ).Clamp( 0f, 1f ) );
		}

		_visual.WorldRotation = _facing;

		var grounded = _height <= 0f && _riseSpeed <= 0f;
		TipsDemoCitizen.Drive( _visual, velocity, wishDirection * MoveSpeed, grounded );

		if ( hopped )
			TipsDemoCitizen.TriggerJump( _visual );
	}

	/// <summary>
	/// Switch off the box renderer the scene authors on this object, so the citizen stands alone. Disabling
	/// the component at runtime leaves the scene file untouched: reopen it in the editor and the simple
	/// authored block is still what you see. The skinned renderer is skipped by type because it derives
	/// from <c>ModelRenderer</c> too.
	/// </summary>
	private void HideAuthoredBlock()
	{
		var authored = Components.GetAll<ModelRenderer>( FindMode.EverythingInSelf )
			.Where( r => r is not SkinnedModelRenderer )
			.ToArray();

		foreach ( var renderer in authored )
			renderer.Enabled = false;
	}

	/// <summary>Put the citizen back where it started (the demo's replay key).</summary>
	public void ResetPawn()
	{
		_height = 0f;
		_riseSpeed = 0f;
		WorldPosition = _start;

		_facing = Rotation.FromYaw( CameraYaw + 180f );
		if ( _visual.IsValid() )
			_visual.WorldRotation = _facing;
	}
}
fieldguide.tips / Code/TipCatalogMerge.cs
Game library
using System.Collections.Generic;

namespace FieldGuide.Tips;

/// <summary>
/// The merged catalog as one value: the ordered tips and the label saying where each id came from, built
/// together so a reader can never pair a list from one build with labels from another.
/// </summary>
public sealed class TipCatalogView
{
	/// <summary>The deduped, precedence-ordered tips.</summary>
	public IReadOnlyList<TipDefinition> Tips { get; init; } = new List<TipDefinition>();

	/// <summary>Tip id to source label ("code" / "asset" / "draft" / "example").</summary>
	public IReadOnlyDictionary<string, string> SourceById { get; init; } = new Dictionary<string, string>();
}

/// <summary>
/// The pure merge rule behind <see cref="TipsCatalog.Active"/>: which source wins an id collision, what order
/// the survivors come out in, and when the shipped example stands in. Lifted out of the catalog so it has no
/// <c>Sandbox</c> reference and the harness can assert precedence and dedupe without a running engine, which
/// is the half of the catalog that a typo actually breaks.
/// </summary>
public static class TipCatalogMerge
{
	/// <summary>
	/// Merge the three sources into one view, highest precedence first: CODE, then ASSETS, then DRAFTS. The
	/// first tip seen for an id wins and later ones are dropped, so a draft never shadows the real tip it is
	/// a draft of. When all three come back empty, <paramref name="fallback"/> is used and labelled
	/// "example". Null sources are treated as empty; a null tip, or one with a blank id, is skipped.
	/// </summary>
	public static TipCatalogView Merge(
		IEnumerable<TipDefinition> code,
		IEnumerable<TipDefinition> assets,
		IEnumerable<TipDefinition> drafts,
		IEnumerable<TipDefinition> fallback )
	{
		var order = new List<TipDefinition>();
		var sources = new Dictionary<string, string>( System.StringComparer.Ordinal );

		Add( code, "code", order, sources );
		Add( assets, "asset", order, sources );
		Add( drafts, "draft", order, sources );

		if ( order.Count == 0 )
			Add( fallback, "example", order, sources );

		return new TipCatalogView { Tips = order, SourceById = sources };
	}

	// The source map IS the dedupe guard, and it is filled in the same pass as the list it guards. Guarding
	// inserts to one collection by querying a different, longer-lived one is how these two drift apart.
	private static void Add( IEnumerable<TipDefinition> source, string label,
		List<TipDefinition> order, Dictionary<string, string> sources )
	{
		if ( source is null )
			return;

		foreach ( var def in source )
		{
			if ( def is null || string.IsNullOrEmpty( def.Id ) )
				continue;
			if ( sources.ContainsKey( def.Id ) )
				continue; // a higher-precedence source already claimed this id
			order.Add( def );
			sources[def.Id] = label;
		}
	}
}

/// <summary>
/// What a built catalog view was built FROM: the code list it saw, and the draft / asset revision numbers at
/// the time. A read compares the stamp against the live sources; anything that moved means the view is stale
/// and gets rebuilt. Comparing is three field reads with no allocation, which is what lets the coach ask for
/// the catalog several times a frame without a rescan.
///
/// The code source is compared BY REFERENCE, not by content: registering is a whole-list swap, so a new list
/// is a new catalog, and a caller mutating a list it already registered is expected to say so
/// (<see cref="TipsCatalog.Rebuild"/>) the same way it always was.
/// </summary>
public readonly struct TipCatalogStamp
{
	/// <summary>The code catalog this view merged.</summary>
	public object Code { get; }

	/// <summary>The draft revision this view merged.</summary>
	public int DraftRevision { get; }

	/// <summary>The asset revision this view merged.</summary>
	public int AssetRevision { get; }

	public TipCatalogStamp( object code, int draftRevision, int assetRevision )
	{
		Code = code;
		DraftRevision = draftRevision;
		AssetRevision = assetRevision;
	}

	/// <summary>True when nothing has moved since this view was built.</summary>
	public bool Matches( object code, int draftRevision, int assetRevision )
		=> ReferenceEquals( Code, code ) && DraftRevision == draftRevision && AssetRevision == assetRevision;
}
fieldguide.tips / Code/TipTriggerEval.cs
Game library
using System;

namespace FieldGuide.Tips;

/// <summary>
/// The environment a <see cref="TipTrigger"/> is evaluated against: the four world reads plus the active
/// tip's elapsed visible time. <see cref="TipsCoach"/> fills these from <see cref="Sandbox.Input"/>, the
/// pushed <see cref="TipContext"/> and its own timer; a headless harness fills them with fakes. Keeping
/// the reads behind delegates is what lets the trigger truth table be unit-tested without the engine.
/// </summary>
public sealed class TipTriggerEnv
{
	/// <summary>Was the named Input.config action pressed this frame? (Input.Pressed)</summary>
	public Func<string, bool> ActionPressed { get; init; } = static _ => false;

	/// <summary>Was the named raw key / mouse button pressed this frame? (Input.Keyboard.Pressed)</summary>
	public Func<string, bool> KeyPressed { get; init; } = static _ => false;

	/// <summary>Has the named string signal been latched this session? (Signal / Ever kinds)</summary>
	public Func<string, bool> SignalLatched { get; init; } = static _ => false;

	/// <summary>Is the named custom context flag true this frame? (TipContext.Flag)</summary>
	public Func<string, bool> Flag { get; init; } = static _ => false;

	/// <summary>The named custom context number this frame. (TipContext.Number)</summary>
	public Func<string, float> Number { get; init; } = static _ => 0f;

	/// <summary>Seconds the active tip has been visible, for the Timer kind.</summary>
	public float Elapsed { get; init; }

	/// <summary>The current magnitude (0..1-ish) of the given analog stick, for the AnalogAxis kind. (Input.AnalogMove / Input.AnalogLook length.)</summary>
	public Func<TipTriggerAnalogSource, float> AnalogMagnitude { get; init; } = static _ => 0f;
}

/// <summary>
/// The pure, engine-free evaluation core for a declarative <see cref="TipTrigger"/>: the per-kind rule and
/// the AnyOf / AllOf recursion, with every world read behind a <see cref="TipTriggerEnv"/> delegate. This
/// holds the whole completion/relevance truth table, so it can be exercised headlessly (the coach passes
/// real Input / context reads; a harness passes fakes) and stays identical between the two.
/// </summary>
public static class TipTriggerEval
{
	/// <summary>Evaluate a trigger against the given environment. Null trigger evaluates false.</summary>
	public static bool Evaluate( TipTrigger t, TipTriggerEnv env )
	{
		if ( t is null || env is null )
			return false;

		switch ( t.Kind )
		{
			case TipTriggerKind.Always:
				return true;

			case TipTriggerKind.InputAction:
				foreach ( var a in t.Actions )
					if ( !string.IsNullOrEmpty( a ) && env.ActionPressed( a ) )
						return true;
				return false;

			case TipTriggerKind.Key:
				foreach ( var k in t.Keys )
					if ( !string.IsNullOrEmpty( k ) && env.KeyPressed( k ) )
						return true;
				return false;

			case TipTriggerKind.Signal:
			case TipTriggerKind.Ever:
				return !string.IsNullOrEmpty( t.Key ) && env.SignalLatched( t.Key );

			case TipTriggerKind.Flag:
				return !string.IsNullOrEmpty( t.Key ) && env.Flag( t.Key );

			case TipTriggerKind.AtLeast:
				return !string.IsNullOrEmpty( t.Key ) && env.Number( t.Key ) >= t.Threshold;

			case TipTriggerKind.Timer:
				return env.Elapsed >= t.Seconds;

			case TipTriggerKind.AnalogAxis:
				return env.AnalogMagnitude( t.AnalogSource ) >= t.Threshold;

			case TipTriggerKind.AnyOf:
				foreach ( var c in t.Children )
					if ( Evaluate( c, env ) )
						return true;
				return false;

			case TipTriggerKind.AllOf:
				if ( t.Children.Length == 0 )
					return false;
				foreach ( var c in t.Children )
					if ( !Evaluate( c, env ) )
						return false;
				return true;

			default:
				return false;
		}
	}
}
fieldguide.tips / TipTriggerObject.cs
Game library
using System;
using Sandbox;

namespace FieldGuide.Tips;

/// <summary>
/// Drop this on a GameObject (an NPC, a door, a pickup) to retire a tip when the player does something to
/// that object. Pick the tip id and the mode in the inspector; no code for the common cases. So "talk to
/// this NPC" is: add this component to the NPC, set <see cref="TipId"/>, set
/// <see cref="CompleteOn"/> = <see cref="Mode.Interacted"/>, and call <see cref="Interacted"/> (or the
/// static <see cref="NotifyInteracted"/>) from wherever your game already knows an interaction happened.
///
/// <see cref="Mode.PlayerEntered"/> and <see cref="Mode.LookedAt"/> read the <see cref="TipsWorld"/>
/// seams and are INERT until those seams are set (they never fire and never throw), so a game that skips
/// the seams still runs. Input, Signal and Timer completion do not use this component at all; they live on
/// the tip itself (declarative triggers or the coach's input pass).
/// </summary>
[Title( "Tip Trigger" )]
[Category( "Field Guide Tips" )]
[Icon( "ads_click" )]
public sealed class TipTriggerObject : Component
{
	public enum Mode
	{
		/// <summary>Your interaction code (or the fieldguide.interaction bridge) calls <see cref="Interacted"/>.</summary>
		Interacted,

		/// <summary>The local player is within <see cref="Radius"/> of this object (needs <see cref="TipsWorld.LocalPlayerPosition"/>).</summary>
		PlayerEntered,

		/// <summary>The aim ray hits this object within <see cref="Radius"/> (needs <see cref="TipsWorld.AimRay"/>).</summary>
		LookedAt,

		/// <summary>Raise a named signal instead of completing directly (fans out to every coach's Signal).</summary>
		Signal,
	}

	[Property] public string TipId { get; set; } = "";
	[Property] public Mode CompleteOn { get; set; } = Mode.Interacted;

	/// <summary>Trigger distance for PlayerEntered, and the aim-ray length for LookedAt, in world units.</summary>
	[Property] public float Radius { get; set; } = 128f;

	/// <summary>Signal name raised in <see cref="Mode.Signal"/>.</summary>
	[Property] public string SignalName { get; set; } = "";

	protected override void OnUpdate()
	{
		if ( CompleteOn == Mode.PlayerEntered && WithinPlayerRadius() )
			Fire();
		else if ( CompleteOn == Mode.LookedAt && AimRayHitsSelf() )
			Fire();
	}

	/// <summary>Call from your interaction code when this object is interacted with. Inert unless the mode
	/// is <see cref="Mode.Interacted"/>.</summary>
	public void Interacted()
	{
		if ( CompleteOn == Mode.Interacted )
			Fire();
	}

	/// <summary>
	/// Convenience for an interaction bridge: fire the <see cref="Interacted"/> trigger on a target
	/// GameObject if it carries a <see cref="TipTriggerObject"/>. Null-safe on the target and on a missing
	/// component, so a bridge can call it for every interacted object without guarding.
	/// </summary>
	public static void NotifyInteracted( GameObject target )
		=> target?.Components.Get<TipTriggerObject>( FindMode.EnabledInSelfAndDescendants )?.Interacted();

	private void Fire()
	{
		if ( CompleteOn == Mode.Signal )
		{
			if ( string.IsNullOrEmpty( SignalName ) )
				return;
			foreach ( var coach in Scene.GetAllComponents<TipsCoach>() )
				coach.Signal( SignalName );
			return;
		}

		TipsCoach.Complete( TipId );
	}

	private bool WithinPlayerRadius()
	{
		var seam = TipsWorld.LocalPlayerPosition; // fail-inert: null seam disables PlayerEntered
		if ( seam is null )
			return false;

		try
		{
			return WorldPosition.Distance( seam() ) <= Radius;
		}
		catch
		{
			return false;
		}
	}

	private bool AimRayHitsSelf()
	{
		var seam = TipsWorld.AimRay; // fail-inert: null seam disables LookedAt
		if ( seam is null )
			return false;

		try
		{
			var ray = seam();
			var tr = Scene.Trace.Ray( ray.Position, ray.Position + ray.Forward * Radius ).Run();
			return tr.Hit && tr.GameObject.IsValid()
				&& ( tr.GameObject == GameObject || GameObject.IsDescendant( tr.GameObject ) );
		}
		catch
		{
			return false;
		}
	}
}
fieldguide.tips / Code/Demo/TipsDemoCitizen.cs
Game library
using System;
using Sandbox;

namespace FieldGuide.Tips;

/// <summary>
/// Builds and drives the demo pawn's look: a dressed stock citizen, spawned in code as a child of the pawn
/// object. Everything here ships with the engine (the citizen model, its clothing, its animgraph), so the
/// demo still carries zero art of its own.
///
/// WHY IN CODE. The pawn object is authored in <c>tips_demo.scene</c> with a plain box renderer, and the
/// scene stays exactly that on disk. <see cref="TipsDemoPawn"/> switches the box off at boot and builds
/// this instead, so the swap costs no scene churn and the editor viewport still shows the simple authored
/// block when nothing is playing.
///
/// WHY NOT CitizenAnimationHelper. The engine ships a helper component that wraps these same animgraph
/// parameters, but it lives in a namespace a Field Guide kit is not allowed to reference (the isolation
/// lint severs it). The parameter names below are the ones that helper sets, driven directly on the
/// renderer, which is also what the placement and vehicle physics kits do.
///
/// Not part of the kit's runtime surface: delete <c>Code/Demo</c> and <c>Assets/demo</c> when you drop the
/// kit into your own project.
/// </summary>
internal static class TipsDemoCitizen
{
	private const string ModelPath = "models/citizen/citizen.vmdl";

	/// <summary>A plain outfit from the shipped citizen clothing resources, the same two items the
	/// placement kit's demo wears. Each is null-checked, so a missing asset degrades to a barer citizen
	/// rather than a broken spawn.</summary>
	private static readonly string[] Outfit =
	{
		"models/citizen_clothes/shirt/Jumpsuit/blue_jumpsuit.clothing",
		"models/citizen_clothes/shoes/Trainers/trainers.clothing",
	};

	/// <summary>
	/// Spawn the citizen as a child of the pawn. <paramref name="footOffset"/> is its local Z: the pawn
	/// object sits half a block above the ground because the authored box is centred on it, and the citizen
	/// model's origin is at its feet, so the visual drops by that half height to stand on the floor.
	/// Returns null if the model did not load, which leaves the caller free to keep the block.
	/// </summary>
	public static SkinnedModelRenderer Build( GameObject pawn, float footOffset )
	{
		var go = pawn.Scene.CreateObject();
		go.Name = "Demo Citizen Visual";
		go.SetParent( pawn, false );
		go.LocalPosition = Vector3.Up * footOffset;

		var model = Model.Load( ModelPath );
		if ( model is null || model.IsError )
		{
			Log.Warning( $"[tips] demo citizen model '{ModelPath}' did not load; keeping the authored block." );
			go.Destroy();
			return null;
		}

		var renderer = go.Components.Create<SkinnedModelRenderer>();
		renderer.Model = model;
		Dress( renderer );

		// Grounded with no move input is the citizen animgraph's rest state: a standing idle that breathes
		// and shifts weight, which is what the demo wants between tips.
		renderer.Set( "b_grounded", true );

		return renderer;
	}

	/// <summary>
	/// Push a frame of locomotion at the animgraph. <paramref name="velocity"/> is what the pawn actually
	/// travelled, <paramref name="wishVelocity"/> is what the stick asked for; the graph uses the first for
	/// the legs and the second for arm swing in the air, which is why both go across.
	/// </summary>
	public static void Drive( SkinnedModelRenderer renderer, Vector3 velocity, Vector3 wishVelocity, bool grounded )
	{
		if ( !renderer.IsValid() )
			return;

		renderer.Set( "b_grounded", grounded );
		SetMotion( renderer, "move", velocity );
		SetMotion( renderer, "wish", wishVelocity );
	}

	/// <summary>Fire the animgraph's one-shot hop. It self-clears, so it is set and forgotten.</summary>
	public static void TriggerJump( SkinnedModelRenderer renderer )
	{
		if ( renderer.IsValid() )
			renderer.Set( "b_jump", true );
	}

	/// <summary>The six parameters the citizen animgraph reads per motion channel, resolved against the
	/// renderer's own facing so a sideways walk plays the strafe blend rather than a forward one.</summary>
	private static void SetMotion( SkinnedModelRenderer renderer, string channel, Vector3 velocity )
	{
		var rotation = renderer.WorldRotation;
		var forward = rotation.Forward.Dot( velocity );
		var sideward = rotation.Right.Dot( velocity );
		var angle = MathF.Atan2( sideward, forward ).RadianToDegree().NormalizeDegrees();

		renderer.Set( $"{channel}_direction", angle );
		renderer.Set( $"{channel}_speed", velocity.Length );
		renderer.Set( $"{channel}_groundspeed", velocity.WithZ( 0f ).Length );
		renderer.Set( $"{channel}_x", forward );
		renderer.Set( $"{channel}_y", sideward );
		renderer.Set( $"{channel}_z", velocity.z );
	}

	private static void Dress( SkinnedModelRenderer renderer )
	{
		var outfit = new ClothingContainer();
		var any = false;

		foreach ( var path in Outfit )
		{
			var item = ResourceLibrary.Get<Clothing>( path );
			if ( item is null )
			{
				Log.Warning( $"[tips] demo clothing '{path}' did not resolve, skipping that slot." );
				continue;
			}

			outfit.Add( item );
			any = true;
		}

		if ( any )
			outfit.Apply( renderer );
	}
}
fieldguide.tips / Code/Studio/TipStudioText.cs
Game library
using System.Collections.Generic;

namespace FieldGuide.Tips;

/// <summary>
/// The authoring checks the Tips Studio runs on a draft while you type: the things that produce a card that
/// looks broken, or a tip that can never retire, and that are cheap to catch before the file is written.
/// Pure (no <c>Sandbox</c> reference, no engine state), so the harness asserts the same rules the panel shows.
///
/// Every message is a NOTE, never a block. The Studio will happily bake a tip with warnings on it, because
/// several of them are legitimate on purpose (an empty <c>AllOf</c> is the documented "a world trigger
/// retires this one" shape).
/// </summary>
public static class TipStudioText
{
	/// <summary>
	/// How long a single unbroken run of prose can get before the card is at risk of the grey-block quirk:
	/// the style engine rasterizes a text run that overflows one card line as a solid filled rectangle
	/// instead of wrapped glyphs. Chips break a line into separate runs, which is why a tip full of keycaps
	/// stays safe while one long sentence does not. Roughly one line at the card's 500px width; deliberately
	/// a round number rather than a measured one, because the real threshold moves with the wording.
	/// </summary>
	public const int MaxRunLength = 50;

	/// <summary>The length of the longest PLAIN run in a tip line. Chips (<c>*keycap*</c>,
	/// <c>`padchip`</c>) are separate runs and never count toward it, which mirrors how the card lays out.</summary>
	public static int LongestRun( string text )
	{
		var longest = 0;
		foreach ( var segment in TipSegment.Parse( text ) )
		{
			if ( segment.Kind != TipSegmentKind.Plain )
				continue;

			var length = segment.Text is null ? 0 : segment.Text.Trim().Length;
			if ( length > longest )
				longest = length;
		}

		return longest;
	}

	/// <summary>True when a line carries a run long enough to risk the grey-block quirk.</summary>
	public static bool RunTooLong( string text ) => LongestRun( text ) > MaxRunLength;

	/// <summary>The note for a too-long run, or null when the line is fine.</summary>
	public static string RunWarning( string text, string label )
	{
		var longest = LongestRun( text );
		if ( longest <= MaxRunLength )
			return null;

		return $"{label}: one run is {longest} characters. A run longer than a card line can render as a grey " +
			$"block. Break the sentence, or put a key chip in it.";
	}

	/// <summary>
	/// Every note for a draft, in the order the panel lists them. An empty list means nothing to flag.
	/// </summary>
	public static IReadOnlyList<string> Warnings( TipStudioDraft draft )
	{
		var notes = new List<string>();
		if ( draft is null )
			return notes;

		if ( string.IsNullOrWhiteSpace( draft.Id ) )
			notes.Add( "No id yet. The catalog keys tips by id, and the file is named after it." );

		if ( string.IsNullOrWhiteSpace( draft.Text ) )
			notes.Add( "No text yet. This is the line the player reads." );

		var textNote = RunWarning( draft.Text, "Text" );
		if ( textNote is not null )
			notes.Add( textNote );

		if ( !string.IsNullOrEmpty( draft.TextPad ) )
		{
			var padNote = RunWarning( draft.TextPad, "Pad text" );
			if ( padNote is not null )
				notes.Add( padNote );
		}

		AddTriggerNotes( notes, draft.Completion, "Completion", isCompletion: true );
		AddTriggerNotes( notes, draft.Relevance, "Relevance", isCompletion: false );

		return notes;
	}

	private static void AddTriggerNotes( List<string> notes, TipStudioTrigger trigger, string label, bool isCompletion )
	{
		if ( trigger is null )
			return;

		switch ( trigger.Kind )
		{
			case TipTriggerKind.InputAction when string.IsNullOrWhiteSpace( trigger.Action ):
				notes.Add( $"{label} is InputAction with no action picked, so it never fires." );
				break;

			case TipTriggerKind.Key when string.IsNullOrWhiteSpace( trigger.Key ):
				notes.Add( $"{label} is Key with no key name, so it never fires." );
				break;

			case TipTriggerKind.Signal or TipTriggerKind.Ever or TipTriggerKind.Flag or TipTriggerKind.AtLeast
				when string.IsNullOrWhiteSpace( trigger.Name ):
				notes.Add( $"{label} is {TipStudioTrigger.KindName( trigger.Kind )} with no name, so it never fires." );
				break;

			case TipTriggerKind.Timer when trigger.Seconds <= 0f && isCompletion:
				notes.Add( $"{label} is Timer with 0 seconds, so the tip retires the moment it is readable. Set Seconds." );
				break;

			case TipTriggerKind.AnalogAxis when trigger.Magnitude <= 0f:
				notes.Add( $"{label} is AnalogAxis with a magnitude of 0, so a resting stick already fires it." );
				break;

			case TipTriggerKind.AllOf when CountChildren( trigger ) == 0 && isCompletion:
				notes.Add( $"{label} is an empty AllOf, which never fires. That is the right shape when a " +
					"TipTriggerObject in the scene retires this tip." );
				break;

			case TipTriggerKind.AnyOf when CountChildren( trigger ) == 0:
				notes.Add( $"{label} is an empty AnyOf, so it never fires." );
				break;
		}

		if ( trigger.Children is null )
			return;

		foreach ( var child in trigger.Children )
			AddTriggerNotes( notes, child, $"{label} child", isCompletion );
	}

	private static int CountChildren( TipStudioTrigger trigger )
		=> trigger.Children is null ? 0 : trigger.Children.Count;
}
Debug: View Raw JSON Response
{
    "TotalCount": 48,
    "Files": [
        {
            "Ident": "fieldguide.tips",
            "Path": "Code/Demo/TipsDemoBootstrap.cs",
            "FileName": "TipsDemoBootstrap.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 337872,
            "Code": "using System;\r\nusing System.Linq;\r\nusing Sandbox;\r\n\r\nnamespace FieldGuide.Tips;\r\n\r\n/// <summary>\r\n/// Wires the demo scene in one component so the whole kit runs from a single press of play: it points the\r\n/// <see cref=\"TipsWorld\"/> seams at the demo pawn, creates the coach and its display with\r\n/// <see cref=\"TipsCoach.Ensure\"/>, and pushes a context every frame carrying the demo flag the demo tips\r\n/// gate on. The tips themselves are authored assets (<c>Assets/demo/*.tip</c> plus\r\n/// <c>Assets/starter.tip</c>), not code, so the scene also shows the no-code authoring path.\r\n///\r\n/// THE DEMO FLAG. Every demo tip's Relevance is <c>Flag(\"fg_tips_demo\")</c>, and only this component sets\r\n/// that flag. A game that vendors the kit and never runs this scene therefore never sees a demo tip, even\r\n/// if it forgets to delete the assets: the tips merge into the catalog but stay irrelevant forever. Delete\r\n/// them anyway.\r\n///\r\n/// THE ENDING. The walkthrough hands the player to the authoring tool: the last tip coaches <c>T</c>, and\r\n/// opening the Tips Studio is what retires it. Two pieces do that, both of them demo wiring rather than\r\n/// kit runtime. This component builds the Studio's host object at boot (the same one line a game writes\r\n/// from the README), and it raises the <see cref=\"StudioOpenedSignal\"/> string signal while the Studio is\r\n/// open, which is the completion <c>demo_wrap.tip</c> waits on.\r\n///\r\n/// Not part of the kit's runtime surface: delete <c>Code/Demo</c> and <c>Assets/demo</c> when you drop the\r\n/// kit into your own project, and write your own bootstrap from the README instead.\r\n/// </summary>\r\n[Title( \"Tips Demo Bootstrap\" )]\r\n[Category( \"Field Guide Tips\" )]\r\n[Icon( \"auto_awesome\" )]\r\npublic sealed class TipsDemoBootstrap : Component\r\n{\r\n\t/// <summary>The context flag every demo tip's Relevance reads, so demo content is inert anywhere this\r\n\t/// component is not running.</summary>\r\n\tpublic const string DemoFlag = \"fg_tips_demo\";\r\n\r\n\t/// <summary>Progress file the demo writes, kept apart from the kit default so replaying the demo never\r\n\t/// touches the progress your own game saves.</summary>\r\n\tpublic const string DemoSaveFile = \"fieldguide_tips_demo.json\";\r\n\r\n\t/// <summary>The string signal this component latches while the Tips Studio is open, and the completion\r\n\t/// <c>demo_wrap.tip</c> waits on. A demo-side name: the kit knows nothing about it, which is the point.\r\n\t/// Any game can retire a tip on its own UI the same way, with one <see cref=\"TipsCoach.Signal(string)\"/>\r\n\t/// call and a Signal trigger on the tip.</summary>\r\n\tpublic const string StudioOpenedSignal = \"fg_tips_studio_opened\";\r\n\r\n\t/// <summary>Raw key that resets progress and replays the sequence from the top.</summary>\r\n\t[Property] public string ReplayKey { get; set; } = \"r\";\r\n\r\n\tprivate TipsCoach _coach;\r\n\tprivate TipsDemoPawn _pawn;\r\n\tprivate string _previousSaveFile;\r\n\tprivate Func<bool> _previousHasPlayer;\r\n\tprivate Func<Vector3> _previousPlayerPosition;\r\n\tprivate Func<Ray> _previousAimRay;\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\t_pawn = Scene.GetAllComponents<TipsDemoPawn>().FirstOrDefault();\r\n\r\n\t\t// Set the save file BEFORE Ensure: the first load reads whatever name is set then.\r\n\t\t_previousSaveFile = TipsCoach.SaveFileName;\r\n\t\tTipsCoach.SaveFileName = DemoSaveFile;\r\n\r\n\t\t// Always start the demo from the top, the way a first-time player would see it.\r\n\t\tTipsCoach.ResetProgress();\r\n\r\n\t\t// World seams. A library cannot reach into your player, so the demo hands the kit its pawn. The\r\n\t\t// marker zone's PlayerEntered trigger reads LocalPlayerPosition; nothing here uses LookedAt, so\r\n\t\t// AimRay stays null and those triggers stay inert. They are statics, so the old values are kept\r\n\t\t// and handed back in OnDestroy: a demo pawn that no longer exists must not answer for a game\r\n\t\t// scene opened later in the same session.\r\n\t\t_previousHasPlayer = TipsWorld.HasLocalPlayer;\r\n\t\t_previousPlayerPosition = TipsWorld.LocalPlayerPosition;\r\n\t\t_previousAimRay = TipsWorld.AimRay;\r\n\r\n\t\tTipsWorld.HasLocalPlayer = () => _pawn.IsValid();\r\n\t\tTipsWorld.LocalPlayerPosition = () => _pawn.IsValid() ? _pawn.WorldPosition : Vector3.Zero;\r\n\t\tTipsWorld.AimRay = null;\r\n\r\n\t\t_coach = TipsCoach.Ensure( Scene );\r\n\t\tEnsureStudioHost();\r\n\r\n\t\tLog.Info( \"[tips] demo ready: move with W A S D or the left stick, jump with Space or A, \" +\r\n\t\t\t\"switch the marker on inside its ring, then press T for the Tips Studio. \" +\r\n\t\t\t$\"Press {ReplayKey.ToUpperInvariant()} to replay.\" );\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif ( _coach is null )\r\n\t\t\treturn;\r\n\r\n\t\t// Ignored while the Studio is open. The key is read raw, so without this an \"r\" typed into one of\r\n\t\t// the Studio's text boxes would restart the walkthrough under the panel, and the walkthrough now\r\n\t\t// ENDS in that panel. Closed, the last beat is there to walk again.\r\n\t\tif ( !TipsStudio.Open && Input.Keyboard.Pressed( ReplayKey ) )\r\n\t\t\tReplay();\r\n\r\n\t\t// The last beat. Latched while the Studio is OPEN rather than on the frame it opens: a latch is\r\n\t\t// idempotent, so pushing it every frame costs nothing, and it leaves no edge state to go stale\r\n\t\t// when the walkthrough is reset out from under it. Raised before the tick below, because the tick\r\n\t\t// is what reads it.\r\n\t\tif ( TipsStudio.Open )\r\n\t\t\t_coach.Signal( StudioOpenedSignal );\r\n\r\n\t\t// The context path: one small neutral struct per frame. The demo only needs two things in it, a\r\n\t\t// player and the demo flag; a real game fills the fields its own tips read.\r\n\t\tvar ctx = new TipContext { HasPlayer = _pawn.IsValid() };\r\n\t\tctx.SetFlag( DemoFlag, true );\r\n\t\t_coach.Tick( ctx );\r\n\t}\r\n\r\n\tprotected override void OnDestroy()\r\n\t{\r\n\t\t// Hand the statics back so a game scene opened later in the same editor session sees the kit\r\n\t\t// exactly as it was before the demo ran.\r\n\t\tif ( !string.IsNullOrEmpty( _previousSaveFile ) )\r\n\t\t\tTipsCoach.SaveFileName = _previousSaveFile;\r\n\r\n\t\tif ( _previousHasPlayer is not null )\r\n\t\t\tTipsWorld.HasLocalPlayer = _previousHasPlayer;\r\n\t\telse\r\n\t\t\tTipsWorld.HasLocalPlayer = static () => true;\r\n\r\n\t\tTipsWorld.LocalPlayerPosition = _previousPlayerPosition;\r\n\t\tTipsWorld.AimRay = _previousAimRay;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Build the Tips Studio's host: one <see cref=\"ScreenPanel\"/> carrying the panel, created in code so\r\n\t/// the demo scene file holds no razor reference (the same idiom the other kits' demos use). It sits\r\n\t/// above the coach's own panel, since the Studio is a modal over the card it is editing. The Studio\r\n\t/// ships CLOSED and opens on its own OpenKey, which is T; nothing here opens it.\r\n\t///\r\n\t/// Idempotent: a scene that already carries a Studio panel of its own keeps it.\r\n\t/// </summary>\r\n\tprivate void EnsureStudioHost()\r\n\t{\r\n\t\tif ( Scene.GetAllComponents<TipsStudioPanel>().Any() )\r\n\t\t\treturn;\r\n\r\n\t\tvar go = Scene.CreateObject();\r\n\t\tgo.Name = \"UI.TipsStudio\";\r\n\r\n\t\tvar screen = go.Components.Create<ScreenPanel>();\r\n\t\tscreen.ZIndex = 60; // above the coach's card at 50\r\n\r\n\t\tgo.Components.Create<TipsStudioPanel>();\r\n\t}\r\n\r\n\tprivate void Replay()\r\n\t{\r\n\t\tTipsCoach.ResetProgress();\r\n\r\n\t\tforeach ( var marker in Scene.GetAllComponents<TipsDemoMarker>() )\r\n\t\t\tmarker.ResetMarker();\r\n\r\n\t\t_pawn?.ResetPawn();\r\n\t\tLog.Info( \"[tips] demo replayed from the first tip.\" );\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "fieldguide.tips",
            "Path": "Demo/TipsDemoMarker.cs",
            "FileName": "TipsDemoMarker.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 337872,
            "Code": "using System.Linq;\r\nusing Sandbox;\r\n\r\nnamespace FieldGuide.Tips;\r\n\r\n/// <summary>\r\n/// The demo scene's one interactable: a marker the citizen walks up to and switches on. It owns its own\r\n/// state (off / on) and tells the kit about the use through <see cref=\"TipTriggerObject.NotifyInteracted\"/>,\r\n/// which is exactly the one line a real game writes from wherever it already handles \"the player used\r\n/// this object\". The proximity half of the beat is a second <see cref=\"TipTriggerObject\"/> on the sibling\r\n/// zone object, in <see cref=\"TipTriggerObject.Mode.PlayerEntered\"/>, so no code is involved there at all.\r\n///\r\n/// Note the ordering: the marker gates on ITS OWN rule (the pawn is inside the ring and the marker is\r\n/// still off), never on which tip is showing. Game logic drives tips, not the other way round.\r\n///\r\n/// Not part of the kit's runtime surface: delete <c>Code/Demo</c> and <c>Assets/demo</c> when you drop\r\n/// the kit into your own project.\r\n/// </summary>\r\n[Title( \"Tips Demo Marker\" )]\r\n[Category( \"Field Guide Tips\" )]\r\n[Icon( \"emoji_objects\" )]\r\npublic sealed class TipsDemoMarker : Component\r\n{\r\n\t/// <summary>How close the pawn has to be, in world units, before the marker accepts the switch.</summary>\r\n\t[Property] public float Radius { get; set; } = 110f;\r\n\r\n\t/// <summary>The Input.config action that switches the marker on. The demo reuses Jump so the prompt\r\n\t/// reads Space on a keyboard and A on a pad with no extra binding.</summary>\r\n\t[Property] public string SwitchAction { get; set; } = \"Jump\";\r\n\r\n\t[Property] public Color OffTint { get; set; } = new Color( 1f, 0.62f, 0.18f );\r\n\t[Property] public Color OnTint { get; set; } = new Color( 0.35f, 0.95f, 0.5f );\r\n\r\n\tprivate TipsDemoPawn _pawn;\r\n\tprivate ModelRenderer _renderer;\r\n\tprivate bool _on;\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\t_renderer = Components.Get<ModelRenderer>();\r\n\t\t_pawn = Scene.GetAllComponents<TipsDemoPawn>().FirstOrDefault();\r\n\t\tPaint();\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif ( _on || !PawnInside() )\r\n\t\t\treturn;\r\n\t\tif ( !Input.Pressed( SwitchAction ) )\r\n\t\t\treturn;\r\n\r\n\t\t_on = true;\r\n\t\tPaint();\r\n\r\n\t\t// The world-trigger seam: the game says \"this object was used\" and the TipTriggerObject on it\r\n\t\t// retires the tip it is bound to. A game that vendors fieldguide.interaction wires the same call\r\n\t\t// to its InteractionPerformed event instead.\r\n\t\tTipTriggerObject.NotifyInteracted( GameObject );\r\n\t}\r\n\r\n\t/// <summary>True while the demo pawn stands inside the marker ring (flat distance, height ignored so a\r\n\t/// hop does not drop the player out of the ring).</summary>\r\n\tpublic bool PawnInside()\r\n\t\t=> _pawn.IsValid() && WorldPosition.WithZ( 0f ).Distance( _pawn.WorldPosition.WithZ( 0f ) ) <= Radius;\r\n\r\n\t/// <summary>Switch the marker back off (the demo's replay key).</summary>\r\n\tpublic void ResetMarker()\r\n\t{\r\n\t\t_on = false;\r\n\t\tPaint();\r\n\t}\r\n\r\n\tprivate void Paint()\r\n\t{\r\n\t\tif ( _renderer.IsValid() )\r\n\t\t\t_renderer.Tint = _on ? OnTint : OffTint;\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "fieldguide.tips",
            "Path": "Demo/TipsDemoPawn.cs",
            "FileName": "TipsDemoPawn.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 337872,
            "Code": "using System.Linq;\r\nusing Sandbox;\r\n\r\nnamespace FieldGuide.Tips;\r\n\r\n/// <summary>\r\n/// The demo scene's stand-in player: a citizen that slides along the ground on the movement stick (or\r\n/// W/A/S/D) and hops on the jump action. Deliberately the simplest thing that can be coached: plain\r\n/// transform movement, one hand-integrated hop, no rigidbody, no collider, no controller. It exists so\r\n/// the tips in <c>Assets/demo/</c> have real actions to retire on.\r\n///\r\n/// THE LOOK. The scene authors this object with a plain box renderer. At boot the pawn switches that off\r\n/// and builds a dressed stock citizen in its place (<see cref=\"TipsDemoCitizen\"/>), so the scene file\r\n/// stays as authored and the editor viewport still shows the simple block when nothing is playing. The\r\n/// movement, the play radius and the hop are untouched by the swap: only the visual changed.\r\n///\r\n/// Not part of the kit's runtime surface: delete <c>Code/Demo</c> and <c>Assets/demo</c> when you drop\r\n/// the kit into your own project, and coach your own player instead.\r\n/// </summary>\r\n[Title( \"Tips Demo Pawn\" )]\r\n[Category( \"Field Guide Tips\" )]\r\n[Icon( \"smart_toy\" )]\r\npublic sealed class TipsDemoPawn : Component\r\n{\r\n\t/// <summary>Ground speed in world units per second.</summary>\r\n\t[Property] public float MoveSpeed { get; set; } = 220f;\r\n\r\n\t/// <summary>Upward speed of one hop, in world units per second.</summary>\r\n\t[Property] public float JumpSpeed { get; set; } = 260f;\r\n\r\n\t/// <summary>Downward acceleration applied to a hop, in world units per second squared.</summary>\r\n\t[Property] public float Gravity { get; set; } = 900f;\r\n\r\n\t/// <summary>How far from its start the citizen may wander. The demo camera is fixed, so this is what\r\n\t/// keeps the citizen in frame, and the scene's camera is framed to contain exactly this disc. The\r\n\t/// marker sits 205 units from the start, so 220 lets the citizen walk onto it and a little past\r\n\t/// without opening up a corner of the yard that the camera would then have to cover for nothing.</summary>\r\n\t[Property] public float PlayRadius { get; set; } = 220f;\r\n\r\n\t/// <summary>The Input.config action that hops. Bound to Space on a keyboard and A on a pad in the\r\n\t/// s&amp;box default config, which is what the demo tips prompt.</summary>\r\n\t[Property] public string JumpAction { get; set; } = \"Jump\";\r\n\r\n\t/// <summary>Yaw the demo camera looks along, so pushing forward moves the citizen away from the camera\r\n\t/// instead of sideways. Change it with the camera.</summary>\r\n\t[Property] public float CameraYaw { get; set; } = 45f;\r\n\r\n\t/// <summary>Local Z of the citizen visual. The pawn object sits half a block above the ground because\r\n\t/// the authored box is centred on it, and the citizen's origin is at its feet, so the visual drops by\r\n\t/// that half height to stand on the floor instead of hovering.</summary>\r\n\t[Property] public float VisualZOffset { get; set; } = -25f;\r\n\r\n\t/// <summary>How briskly the citizen turns to face where it is going, in turns per second-ish. High\r\n\t/// enough to read as responsive, low enough that a flick of the stick does not snap it.</summary>\r\n\t[Property] public float TurnSpeed { get; set; } = 12f;\r\n\r\n\tprivate Vector3 _start;\r\n\tprivate float _height;\r\n\tprivate float _riseSpeed;\r\n\tprivate SkinnedModelRenderer _visual;\r\n\tprivate Rotation _facing;\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\t_start = WorldPosition;\r\n\r\n\t\t// Resting yaw looks back down the camera's line, so the citizen greets the player instead of\r\n\t\t// showing its back on the first frame.\r\n\t\t_facing = Rotation.FromYaw( CameraYaw + 180f );\r\n\r\n\t\tHideAuthoredBlock();\r\n\r\n\t\t_visual = TipsDemoCitizen.Build( GameObject, VisualZOffset );\r\n\t\tif ( _visual.IsValid() )\r\n\t\t{\r\n\t\t\t_visual.WorldRotation = _facing;\r\n\t\t\tLog.Info( \"[tips] demo pawn: dressed citizen built in code, authored block renderer switched off.\" );\r\n\t\t}\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tvar facing = Rotation.FromYaw( CameraYaw );\r\n\t\tvar move = ReadMove();\r\n\t\tvar dir = facing.Forward * move.x + facing.Left * move.y;\r\n\t\tif ( dir.Length > 1f )\r\n\t\t\tdir = dir.Normal;\r\n\r\n\t\tvar flat = ( WorldPosition + dir * MoveSpeed * Time.Delta - _start ).WithZ( 0f );\r\n\t\tif ( flat.Length > PlayRadius )\r\n\t\t\tflat = flat.Normal * PlayRadius;\r\n\r\n\t\tvar hopped = false;\r\n\t\tif ( _height <= 0f && _riseSpeed <= 0f && Input.Pressed( JumpAction ) )\r\n\t\t{\r\n\t\t\t_riseSpeed = JumpSpeed;\r\n\t\t\thopped = true;\r\n\t\t}\r\n\r\n\t\tif ( _height > 0f || _riseSpeed > 0f )\r\n\t\t{\r\n\t\t\t_riseSpeed -= Gravity * Time.Delta;\r\n\t\t\t_height += _riseSpeed * Time.Delta;\r\n\t\t\tif ( _height <= 0f )\r\n\t\t\t{\r\n\t\t\t\t_height = 0f;\r\n\t\t\t\t_riseSpeed = 0f;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tvar previous = WorldPosition;\r\n\t\tWorldPosition = _start + flat + Vector3.Up * _height;\r\n\r\n\t\tDriveVisual( previous, dir, hopped );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Movement as a forward/left pair. <c>Input.AnalogMove</c> carries the movement stick and, in a\r\n\t/// project whose Input.config binds the standard movement actions, the keyboard too. The raw W/A/S/D\r\n\t/// fallback keeps the demo drivable in a project that binds movement under other names, which is the\r\n\t/// same reason the movement tip completes on either the stick or those keys.\r\n\t/// </summary>\r\n\tprivate static Vector3 ReadMove()\r\n\t{\r\n\t\tvar move = Input.AnalogMove;\r\n\t\tif ( move.Length > 0.01f )\r\n\t\t\treturn move;\r\n\r\n\t\tvar forward = ( Input.Keyboard.Down( \"w\" ) ? 1f : 0f ) - ( Input.Keyboard.Down( \"s\" ) ? 1f : 0f );\r\n\t\tvar left = ( Input.Keyboard.Down( \"a\" ) ? 1f : 0f ) - ( Input.Keyboard.Down( \"d\" ) ? 1f : 0f );\r\n\t\treturn new Vector3( forward, left, 0f );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Turn the citizen to face its travel and hand the animgraph a frame of locomotion. The legs read the\r\n\t/// distance actually covered rather than the stick, so at the play radius the clamp reads as standing\r\n\t/// still instead of running on the spot.\r\n\t/// </summary>\r\n\tprivate void DriveVisual( Vector3 previous, Vector3 wishDirection, bool hopped )\r\n\t{\r\n\t\tif ( !_visual.IsValid() )\r\n\t\t\treturn;\r\n\r\n\t\tvar travelled = ( WorldPosition - previous ).WithZ( 0f );\r\n\t\tvar velocity = ( Time.Delta > 0f ? travelled / Time.Delta : Vector3.Zero ).WithZ( _riseSpeed );\r\n\r\n\t\tif ( travelled.Length > 0.01f )\r\n\t\t{\r\n\t\t\tvar target = Rotation.LookAt( travelled.Normal, Vector3.Up );\r\n\t\t\t_facing = Rotation.Slerp( _facing, target, ( Time.Delta * TurnSpeed ).Clamp( 0f, 1f ) );\r\n\t\t}\r\n\r\n\t\t_visual.WorldRotation = _facing;\r\n\r\n\t\tvar grounded = _height <= 0f && _riseSpeed <= 0f;\r\n\t\tTipsDemoCitizen.Drive( _visual, velocity, wishDirection * MoveSpeed, grounded );\r\n\r\n\t\tif ( hopped )\r\n\t\t\tTipsDemoCitizen.TriggerJump( _visual );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Switch off the box renderer the scene authors on this object, so the citizen stands alone. Disabling\r\n\t/// the component at runtime leaves the scene file untouched: reopen it in the editor and the simple\r\n\t/// authored block is still what you see. The skinned renderer is skipped by type because it derives\r\n\t/// from <c>ModelRenderer</c> too.\r\n\t/// </summary>\r\n\tprivate void HideAuthoredBlock()\r\n\t{\r\n\t\tvar authored = Components.GetAll<ModelRenderer>( FindMode.EverythingInSelf )\r\n\t\t\t.Where( r => r is not SkinnedModelRenderer )\r\n\t\t\t.ToArray();\r\n\r\n\t\tforeach ( var renderer in authored )\r\n\t\t\trenderer.Enabled = false;\r\n\t}\r\n\r\n\t/// <summary>Put the citizen back where it started (the demo's replay key).</summary>\r\n\tpublic void ResetPawn()\r\n\t{\r\n\t\t_height = 0f;\r\n\t\t_riseSpeed = 0f;\r\n\t\tWorldPosition = _start;\r\n\r\n\t\t_facing = Rotation.FromYaw( CameraYaw + 180f );\r\n\t\tif ( _visual.IsValid() )\r\n\t\t\t_visual.WorldRotation = _facing;\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "fieldguide.tips",
            "Path": "TipCatalogMerge.cs",
            "FileName": "TipCatalogMerge.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 337872,
            "Code": "using System.Collections.Generic;\r\n\r\nnamespace FieldGuide.Tips;\r\n\r\n/// <summary>\r\n/// The merged catalog as one value: the ordered tips and the label saying where each id came from, built\r\n/// together so a reader can never pair a list from one build with labels from another.\r\n/// </summary>\r\npublic sealed class TipCatalogView\r\n{\r\n\t/// <summary>The deduped, precedence-ordered tips.</summary>\r\n\tpublic IReadOnlyList<TipDefinition> Tips { get; init; } = new List<TipDefinition>();\r\n\r\n\t/// <summary>Tip id to source label (\"code\" / \"asset\" / \"draft\" / \"example\").</summary>\r\n\tpublic IReadOnlyDictionary<string, string> SourceById { get; init; } = new Dictionary<string, string>();\r\n}\r\n\r\n/// <summary>\r\n/// The pure merge rule behind <see cref=\"TipsCatalog.Active\"/>: which source wins an id collision, what order\r\n/// the survivors come out in, and when the shipped example stands in. Lifted out of the catalog so it has no\r\n/// <c>Sandbox</c> reference and the harness can assert precedence and dedupe without a running engine, which\r\n/// is the half of the catalog that a typo actually breaks.\r\n/// </summary>\r\npublic static class TipCatalogMerge\r\n{\r\n\t/// <summary>\r\n\t/// Merge the three sources into one view, highest precedence first: CODE, then ASSETS, then DRAFTS. The\r\n\t/// first tip seen for an id wins and later ones are dropped, so a draft never shadows the real tip it is\r\n\t/// a draft of. When all three come back empty, <paramref name=\"fallback\"/> is used and labelled\r\n\t/// \"example\". Null sources are treated as empty; a null tip, or one with a blank id, is skipped.\r\n\t/// </summary>\r\n\tpublic static TipCatalogView Merge(\r\n\t\tIEnumerable<TipDefinition> code,\r\n\t\tIEnumerable<TipDefinition> assets,\r\n\t\tIEnumerable<TipDefinition> drafts,\r\n\t\tIEnumerable<TipDefinition> fallback )\r\n\t{\r\n\t\tvar order = new List<TipDefinition>();\r\n\t\tvar sources = new Dictionary<string, string>( System.StringComparer.Ordinal );\r\n\r\n\t\tAdd( code, \"code\", order, sources );\r\n\t\tAdd( assets, \"asset\", order, sources );\r\n\t\tAdd( drafts, \"draft\", order, sources );\r\n\r\n\t\tif ( order.Count == 0 )\r\n\t\t\tAdd( fallback, \"example\", order, sources );\r\n\r\n\t\treturn new TipCatalogView { Tips = order, SourceById = sources };\r\n\t}\r\n\r\n\t// The source map IS the dedupe guard, and it is filled in the same pass as the list it guards. Guarding\r\n\t// inserts to one collection by querying a different, longer-lived one is how these two drift apart.\r\n\tprivate static void Add( IEnumerable<TipDefinition> source, string label,\r\n\t\tList<TipDefinition> order, Dictionary<string, string> sources )\r\n\t{\r\n\t\tif ( source is null )\r\n\t\t\treturn;\r\n\r\n\t\tforeach ( var def in source )\r\n\t\t{\r\n\t\t\tif ( def is null || string.IsNullOrEmpty( def.Id ) )\r\n\t\t\t\tcontinue;\r\n\t\t\tif ( sources.ContainsKey( def.Id ) )\r\n\t\t\t\tcontinue; // a higher-precedence source already claimed this id\r\n\t\t\torder.Add( def );\r\n\t\t\tsources[def.Id] = label;\r\n\t\t}\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// What a built catalog view was built FROM: the code list it saw, and the draft / asset revision numbers at\r\n/// the time. A read compares the stamp against the live sources; anything that moved means the view is stale\r\n/// and gets rebuilt. Comparing is three field reads with no allocation, which is what lets the coach ask for\r\n/// the catalog several times a frame without a rescan.\r\n///\r\n/// The code source is compared BY REFERENCE, not by content: registering is a whole-list swap, so a new list\r\n/// is a new catalog, and a caller mutating a list it already registered is expected to say so\r\n/// (<see cref=\"TipsCatalog.Rebuild\"/>) the same way it always was.\r\n/// </summary>\r\npublic readonly struct TipCatalogStamp\r\n{\r\n\t/// <summary>The code catalog this view merged.</summary>\r\n\tpublic object Code { get; }\r\n\r\n\t/// <summary>The draft revision this view merged.</summary>\r\n\tpublic int DraftRevision { get; }\r\n\r\n\t/// <summary>The asset revision this view merged.</summary>\r\n\tpublic int AssetRevision { get; }\r\n\r\n\tpublic TipCatalogStamp( object code, int draftRevision, int assetRevision )\r\n\t{\r\n\t\tCode = code;\r\n\t\tDraftRevision = draftRevision;\r\n\t\tAssetRevision = assetRevision;\r\n\t}\r\n\r\n\t/// <summary>True when nothing has moved since this view was built.</summary>\r\n\tpublic bool Matches( object code, int draftRevision, int assetRevision )\r\n\t\t=> ReferenceEquals( Code, code ) && DraftRevision == draftRevision && AssetRevision == assetRevision;\r\n}\r\n"
        },
        {
            "Ident": "fieldguide.tips",
            "Path": "Code/Studio/TipsStudio.cs",
            "FileName": "TipsStudio.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 337872,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing Sandbox;\r\n\r\nnamespace FieldGuide.Tips;\r\n\r\n/// <summary>\r\n/// The Tips Studio's state and every action its panel takes. The panel\r\n/// (<see cref=\"TipsStudioPanel\"/>) is markup over this; everything that decides something lives here, so the\r\n/// razor stays readable and this stays testable by eye.\r\n///\r\n/// WHAT IT IS. An authoring surface for tips that runs inside your game: list the merged catalog, open any\r\n/// tip in an editor, watch the real card change as you type, fire the tip and its completion for real, and\r\n/// bake the result out as a <c>.tip</c> file. Nothing here is part of a shipped game's runtime: the panel\r\n/// only exists if you add it, it starts closed, and <c>fg_tips_studio</c> is off by default.\r\n///\r\n/// TWO WAYS A DRAFT REACHES THE COACH, and they are deliberately different:\r\n/// <list type=\"bullet\">\r\n/// <item>PREVIEW registers the draft under <see cref=\"PreviewId\"/>, an id nothing else uses, and force-shows\r\n/// it. It is a picture of the card. It cannot be shadowed by a real tip with the same id, which is what would\r\n/// happen if it registered under the draft's own id (drafts are the lowest-precedence source), and it cannot\r\n/// mark anything complete.</item>\r\n/// <item>TEST FIRE registers the draft under its OWN id and shows it, so completing it retires the real tip\r\n/// and the chain advances the way it will in the game. If a code or asset tip already owns that id, that one\r\n/// wins, which is correct: you are testing the chain, not the draft.</item>\r\n/// </list>\r\n///\r\n/// CLEAN-UP. Everything it touches is static and would otherwise outlive the scene: the preview draft, the\r\n/// test-fire draft, and the pinned preview device. <see cref=\"Shutdown\"/> hands all of it back, and the panel\r\n/// calls it from OnDestroy.\r\n/// </summary>\r\npublic static class TipsStudio\r\n{\r\n\t/// <summary>The id the live preview registers under. Long and namespaced on purpose: it must never\r\n\t/// collide with a real tip, because a collision would silently show the real tip instead of the draft.</summary>\r\n\tpublic const string PreviewId = \"fg_tips_studio_preview\";\r\n\r\n\t/// <summary>Folder under <c>FileSystem.Data</c> the Studio stages baked tips in, for the editor menu\r\n\t/// action to pick up. Staging through a file rather than a live static is what lets you bake in play and\r\n\t/// write the asset after you have stopped playing.</summary>\r\n\tpublic const string StageFolder = \"fieldguide_tips_studio\";\r\n\r\n\t// ------------------------------------------------------------------\r\n\t// Open / close\r\n\t// ------------------------------------------------------------------\r\n\r\n\tprivate static bool _open;\r\n\r\n\t/// <summary>Open or close the Tips Studio. Off by default, and the panel forces it off at boot: s&amp;box\r\n\t/// persists convars between sessions, so without that a value set weeks ago would open an authoring panel\r\n\t/// over someone's game.</summary>\r\n\t[ConVar( \"fg_tips_studio\", Help = \"Open or close the Tips Studio authoring panel (dev tool, off by default)\" )]\r\n\tpublic static bool Open\r\n\t{\r\n\t\tget => _open;\r\n\t\tset => _open = value;\r\n\t}\r\n\r\n\t/// <summary>Which tab is showing.</summary>\r\n\tpublic static StudioTab Tab { get; set; } = StudioTab.Tips;\r\n\r\n\t/// <summary>The Studio's three tabs.</summary>\r\n\tpublic enum StudioTab\r\n\t{\r\n\t\t/// <summary>The merged catalog, with source labels.</summary>\r\n\t\tTips,\r\n\r\n\t\t/// <summary>The draft editor: wording, order, triggers, preview and test fire.</summary>\r\n\t\tDraft,\r\n\r\n\t\t/// <summary>The bake-out surface: the .tip JSON, Copy, and staging for the editor.</summary>\r\n\t\tBake,\r\n\t}\r\n\r\n\t// ------------------------------------------------------------------\r\n\t// The draft\r\n\t// ------------------------------------------------------------------\r\n\r\n\tprivate static TipStudioDraft _draft = new();\r\n\r\n\t/// <summary>The tip being authored. Never null.</summary>\r\n\tpublic static TipStudioDraft Draft\r\n\t{\r\n\t\tget => _draft ??= new TipStudioDraft();\r\n\t\tset => _draft = value ?? new TipStudioDraft();\r\n\t}\r\n\r\n\t/// <summary>The catalog id the draft was opened from, or null for a new tip. Shown so it is obvious\r\n\t/// whether you are editing something that already exists.</summary>\r\n\tpublic static string OpenedFrom { get; private set; }\r\n\r\n\t/// <summary>Start a new, empty tip.</summary>\r\n\tpublic static void NewDraft()\r\n\t{\r\n\t\tDraft = new TipStudioDraft { Priority = 100 };\r\n\t\tOpenedFrom = null;\r\n\t}\r\n\r\n\t/// <summary>Open a catalog tip in the editor. The two code-only predicates have no authored form and are\r\n\t/// dropped; <see cref=\"DroppedPredicates\"/> says so on screen.</summary>\r\n\tpublic static void OpenTip( string id )\r\n\t{\r\n\t\tvar def = TipsCatalog.Active.FirstOrDefault( t => t.Id == id );\r\n\t\tif ( def is null )\r\n\t\t\treturn;\r\n\r\n\t\tDraft = TipStudioDraft.FromDefinition( def );\r\n\t\tOpenedFrom = id;\r\n\t\tDroppedPredicates = HasCodePredicates( def );\r\n\t\tTab = StudioTab.Draft;\r\n\t}\r\n\r\n\t/// <summary>True when the tip currently open was carrying a <c>Trigger</c> or <c>CompleteWhen</c>\r\n\t/// predicate, which a <c>.tip</c> file cannot hold. Baking it out keeps the declarative triggers and\r\n\t/// loses the predicate, so the panel warns before you do.</summary>\r\n\tpublic static bool DroppedPredicates { get; private set; }\r\n\r\n\tprivate static bool HasCodePredicates( TipDefinition def )\r\n\t{\r\n\t\t// A tip that never set them carries the record's defaults. Comparing against a fresh default is the\r\n\t\t// only way to tell \"the author wrote a predicate\" from \"the record filled one in\".\r\n\t\tvar plain = new TipDefinition { Id = \"probe\", Text = \"\" };\r\n\t\treturn def.Trigger != plain.Trigger || def.CompleteWhen != plain.CompleteWhen;\r\n\t}\r\n\r\n\t/// <summary>The authoring notes for the current draft (grey-block run lengths, triggers that can never\r\n\t/// fire). Recomputed on read; the panel refreshes them when you press Enter in a box or click anything,\r\n\t/// because rebuilding the panel while you type would take the cursor out of the box.</summary>\r\n\tpublic static IReadOnlyList<string> Notes => TipStudioText.Warnings( Draft );\r\n\r\n\t/// <summary>The draft as <c>.tip</c> JSON: what Copy puts on the clipboard and what a bake writes.</summary>\r\n\tpublic static string Json => TipStudioJson.Write( Draft );\r\n\r\n\t/// <summary>True when the draft's id already names a tip from a HIGHER-precedence source, so a bake would\r\n\t/// be shadowed until that source lets go. Worth saying out loud before someone wonders why their new file\r\n\t/// does nothing.</summary>\r\n\tpublic static string ShadowedBy\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tif ( string.IsNullOrWhiteSpace( Draft.Id ) )\r\n\t\t\t\treturn null;\r\n\r\n\t\t\tvar source = TipsCatalog.SourceOf( Draft.Id );\r\n\t\t\treturn source == \"code\" ? \"code\" : null;\r\n\t\t}\r\n\t}\r\n\r\n\t// ------------------------------------------------------------------\r\n\t// Live preview\r\n\t// ------------------------------------------------------------------\r\n\r\n\t/// <summary>Whether the real card is mirroring the draft right now.</summary>\r\n\tpublic static bool PreviewOn { get; private set; }\r\n\r\n\t/// <summary>Push the draft onto the real card, or refresh what is already there. Registers under\r\n\t/// <see cref=\"PreviewId\"/> so a draft of an existing tip is not shadowed by the tip it copies.</summary>\r\n\tpublic static void PushPreview( Scene scene )\r\n\t{\r\n\t\tvar def = Draft.ToDefinition();\r\n\r\n\t\t// The preview stands in for the draft even before it has an id, so an author sees the card from the\r\n\t\t// first character typed rather than after they remember to name it.\r\n\t\tvar preview = new TipDefinition\r\n\t\t{\r\n\t\t\tId = PreviewId,\r\n\t\t\tText = Draft.Text ?? \"\",\r\n\t\t\tTextPad = string.IsNullOrEmpty( Draft.TextPad ) ? null : Draft.TextPad,\r\n\t\t\tIcon = Draft.Icon ?? \"\",\r\n\t\t\tPriority = def?.Priority ?? 0,\r\n\t\t};\r\n\r\n\t\tTipsCatalog.RegisterRuntime( preview );\r\n\t\tPreviewOn = true;\r\n\r\n\t\tvar coach = LiveCoach( scene );\r\n\t\tcoach?.ForceShow( PreviewId );\r\n\t}\r\n\r\n\t/// <summary>Take the preview off the card and out of the catalog.</summary>\r\n\tpublic static void StopPreview( Scene scene )\r\n\t{\r\n\t\tPreviewOn = false;\r\n\t\tTipsCatalog.UnregisterRuntime( PreviewId );\r\n\t\tTipsCoach.PreviewDevice = null;\r\n\r\n\t\t// The card may still be showing a tip that no longer exists. Drop it rather than dismiss it: dismissing\r\n\t\t// would write the fake preview id into the player's saved progress and leave it there for good.\r\n\t\tif ( TipsCoach.ActiveTip?.Id == PreviewId )\r\n\t\t\tLiveCoach( scene )?.DropActive();\r\n\t}\r\n\r\n\t/// <summary>Which device the preview card is pinned to, or null for whatever the player last used.</summary>\r\n\tpublic static TipDevice? PinnedDevice\r\n\t{\r\n\t\tget => TipsCoach.PreviewDevice;\r\n\t\tset => TipsCoach.PreviewDevice = value;\r\n\t}\r\n\r\n\t// ------------------------------------------------------------------\r\n\t// Test fire\r\n\t// ------------------------------------------------------------------\r\n\r\n\t/// <summary>\r\n\t/// Make the draft the live tip UNDER ITS OWN ID and show it now. From here its completion is the real\r\n\t/// thing: fire the trigger in the game, or press Complete, and the tip retires and the chain moves on.\r\n\t/// Returns false when the draft has no id yet.\r\n\t/// </summary>\r\n\tpublic static bool TestFire( Scene scene )\r\n\t{\r\n\t\tvar def = Draft.ToDefinition();\r\n\t\tif ( def is null )\r\n\t\t\treturn false;\r\n\r\n\t\t// A test fire of a tip already marked complete would retire the moment it appeared.\r\n\t\tTipsCoach.Uncomplete( def.Id );\r\n\t\tTipsCatalog.UnregisterRuntime( PreviewId );\r\n\t\tPreviewOn = false;\r\n\t\tTipsCatalog.RegisterRuntime( def );\r\n\r\n\t\tvar coach = LiveCoach( scene );\r\n\t\tif ( coach is null )\r\n\t\t{\r\n\t\t\tLog.Warning( \"fg_tips: no TipsCoach in the scene, so there is nothing to show the tip on.\" );\r\n\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\treturn coach.ForceShow( def.Id );\r\n\t}\r\n\r\n\t/// <summary>Fire the draft's completion by hand, the same path a world trigger uses. The tip retires and\r\n\t/// whatever waits on it becomes eligible.</summary>\r\n\tpublic static void CompleteNow()\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( Draft.Id ) )\r\n\t\t\treturn;\r\n\r\n\t\tTipsCoach.Complete( Draft.Id );\r\n\t}\r\n\r\n\tprivate static TipsCoach LiveCoach( Scene scene )\r\n\t\t=> scene?.GetAllComponents<TipsCoach>().FirstOrDefault();\r\n\r\n\t// ------------------------------------------------------------------\r\n\t// Input actions (the action picker)\r\n\t// ------------------------------------------------------------------\r\n\r\n\t/// <summary>\r\n\t/// The project's real input actions, for the InputAction picker: <c>Input.ActionNames</c>, which is the\r\n\t/// engine's list from the current game's input settings, the same list the <c>[InputAction]</c> inspector\r\n\t/// dropdown draws from. Sorted, and empty rather than throwing outside a running game.\r\n\t/// </summary>\r\n\tpublic static IReadOnlyList<string> ActionNames\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\ttry\r\n\t\t\t{\r\n\t\t\t\tvar names = Input.ActionNames?.Where( n => !string.IsNullOrWhiteSpace( n ) ).ToList();\r\n\t\t\t\tif ( names is null || names.Count == 0 )\r\n\t\t\t\t\treturn Array.Empty<string>();\r\n\r\n\t\t\t\tnames.Sort( StringComparer.OrdinalIgnoreCase );\r\n\t\t\t\treturn names;\r\n\t\t\t}\r\n\t\t\tcatch ( Exception )\r\n\t\t\t{\r\n\t\t\t\treturn Array.Empty<string>();\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\t// ------------------------------------------------------------------\r\n\t// Bake out\r\n\t// ------------------------------------------------------------------\r\n\r\n\t/// <summary>Copy the draft's <c>.tip</c> JSON to the system clipboard, from in game. Paste it into a new\r\n\t/// file under your project's <c>Assets/</c> and the editor picks it up as a tip.</summary>\r\n\tpublic static void CopyJson()\r\n\t{\r\n\t\tSandbox.UI.Clipboard.SetText( Json );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Write the draft into <see cref=\"StageFolder\"/> under <c>FileSystem.Data</c>, where the editor menu\r\n\t/// action \"Field Guide / Write staged tips\" picks it up and writes the real asset. Two steps because game\r\n\t/// code cannot write into a project's <c>Assets/</c> folder, and because staging survives the end of the\r\n\t/// play session, so you can author in play and land the file afterwards.\r\n\t/// </summary>\r\n\t/// <returns>A line for the panel saying what happened.</returns>\r\n\tpublic static string Stage()\r\n\t{\r\n\t\tvar file = TipStudioJson.FileNameFor( Draft.Id );\r\n\t\tif ( file is null )\r\n\t\t\treturn \"Give the tip an id first.\";\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\tFileSystem.Data.CreateDirectory( StageFolder );\r\n\t\t\tvar path = $\"{StageFolder}/{file}\";\r\n\t\t\tFileSystem.Data.WriteAllText( path, Json );\r\n\t\t\tLog.Info( $\"fg_tips: staged {file}. In the editor, run Field Guide / Write staged tips to Assets/tips.\" );\r\n\r\n\t\t\t// Short on purpose: this lands in a one-line status slot in the panel, and the console line\r\n\t\t\t// above already carries the full instruction.\r\n\t\t\treturn $\"staged {file}\";\r\n\t\t}\r\n\t\tcatch ( Exception e )\r\n\t\t{\r\n\t\t\tLog.Warning( $\"fg_tips: could not stage {file} ({e.Message}).\" );\r\n\t\t\treturn $\"Could not stage {file}: {e.Message}\";\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>How many tips are waiting in the staging folder, so the panel can say whether there is\r\n\t/// anything for the editor action to do.</summary>\r\n\tpublic static int StagedCount\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\ttry\r\n\t\t\t{\r\n\t\t\t\treturn FileSystem.Data.DirectoryExists( StageFolder )\r\n\t\t\t\t\t? FileSystem.Data.FindFile( StageFolder, \"*.tip\", false ).Count()\r\n\t\t\t\t\t: 0;\r\n\t\t\t}\r\n\t\t\tcatch ( Exception )\r\n\t\t\t{\r\n\t\t\t\treturn 0;\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Empty the staging folder, for when a bake was a mistake or the files have landed.</summary>\r\n\tpublic static string ClearStaged()\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tif ( !FileSystem.Data.DirectoryExists( StageFolder ) )\r\n\t\t\t\treturn \"Nothing staged.\";\r\n\r\n\t\t\tvar cleared = 0;\r\n\t\t\tforeach ( var file in FileSystem.Data.FindFile( StageFolder, \"*.tip\", false ).ToList() )\r\n\t\t\t{\r\n\t\t\t\tFileSystem.Data.DeleteFile( $\"{StageFolder}/{file}\" );\r\n\t\t\t\tcleared++;\r\n\t\t\t}\r\n\r\n\t\t\treturn cleared == 0 ? \"Nothing staged.\" : $\"Cleared {cleared} staged tip(s).\";\r\n\t\t}\r\n\t\tcatch ( Exception e )\r\n\t\t{\r\n\t\t\treturn $\"Could not clear the staging folder: {e.Message}\";\r\n\t\t}\r\n\t}\r\n\r\n\t// ------------------------------------------------------------------\r\n\t// Shutdown\r\n\t// ------------------------------------------------------------------\r\n\r\n\t/// <summary>\r\n\t/// Hand back everything the Studio pinned: the preview and test-fire drafts, and the pinned preview\r\n\t/// device. Called from the panel's OnDestroy, because all of it is static and would otherwise follow the\r\n\t/// developer into the next scene, exactly the trap a scene-registered code catalog falls into.\r\n\t/// </summary>\r\n\tpublic static void Shutdown()\r\n\t{\r\n\t\tPreviewOn = false;\r\n\t\tTipsCoach.PreviewDevice = null;\r\n\t\tTipsCatalog.ClearRuntime();\r\n\t\tOpen = false;\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "fieldguide.tips",
            "Path": "Code/Studio/TipStudioJson.cs",
            "FileName": "TipStudioJson.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 337872,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Text.Json;\r\nusing System.Text.Json.Serialization;\r\n\r\nnamespace FieldGuide.Tips;\r\n\r\n/// <summary>\r\n/// Writes and reads the <c>.tip</c> file format: the exact JSON the s&amp;box editor puts on disk for a\r\n/// <see cref=\"TipResource\"/>. What the Tips Studio's Copy button puts on the clipboard, and what its Editor\r\n/// menu action writes into <c>Assets/tips/</c>, is a file the editor will happily open, edit and re-save.\r\n///\r\n/// THE SHAPE, derived from the shipped assets under <c>Assets/</c> and from <see cref=\"TipResource\"/> itself:\r\n/// every <c>[Property]</c> in declaration order, then the two editor bookkeeping keys. Every trigger field is\r\n/// written whether or not its kind reads it, because that is what a GameResource does (it serializes the\r\n/// object, not the interesting parts of it), and a hand-written file missing those keys would gain them the\r\n/// first time the editor saved it, producing a spurious diff.\r\n///\r\n/// <code>\r\n/// {\r\n///   \"Id\": \"jump\",\r\n///   \"Text\": \"Press *Space* to jump.\",\r\n///   \"TextPad\": \"\",\r\n///   \"Icon\": \"\",\r\n///   \"Priority\": 100,\r\n///   \"PrerequisiteTipIds\": [],\r\n///   \"Completion\": { \"Kind\": \"InputAction\", \"Action\": \"Jump\", ... },\r\n///   \"MaxShowSeconds\": 0,\r\n///   \"Relevance\": { \"Kind\": \"Always\", ... },\r\n///   \"__references\": [],\r\n///   \"__version\": 0\r\n/// }\r\n/// </code>\r\n///\r\n/// Enums go out as their names through hand-written maps rather than a converter, so the on-disk vocabulary\r\n/// is a stated format instead of a by-product of how the enum happens to be spelled today. The round trip\r\n/// (write then read) is asserted headlessly in <c>tools/tips_harness</c>.\r\n/// </summary>\r\npublic static class TipStudioJson\r\n{\r\n\t/// <summary>Two-space indentation, matching what the editor writes, so a Studio-authored file and an\r\n\t/// editor-saved one diff cleanly against each other.</summary>\r\n\tprivate static readonly JsonSerializerOptions WriteOptions = new()\r\n\t{\r\n\t\tWriteIndented = true,\r\n\t};\r\n\r\n\tprivate static readonly JsonSerializerOptions ReadOptions = new()\r\n\t{\r\n\t\tPropertyNameCaseInsensitive = false,\r\n\t};\r\n\r\n\t/// <summary>The file name a draft bakes out to, <c>&lt;id&gt;.tip</c>, with anything awkward for a file\r\n\t/// name folded to an underscore. A blank id yields null: an unnamed tip has nowhere to land.</summary>\r\n\tpublic static string FileNameFor( string id )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( id ) )\r\n\t\t\treturn null;\r\n\r\n\t\tvar chars = id.Trim().ToCharArray();\r\n\t\tfor ( var i = 0; i < chars.Length; i++ )\r\n\t\t{\r\n\t\t\tvar c = chars[i];\r\n\t\t\tvar ok = ( c >= 'a' && c <= 'z' ) || ( c >= 'A' && c <= 'Z' ) || ( c >= '0' && c <= '9' ) || c == '_' || c == '-';\r\n\t\t\tif ( !ok )\r\n\t\t\t\tchars[i] = '_';\r\n\t\t}\r\n\r\n\t\treturn new string( chars ) + \".tip\";\r\n\t}\r\n\r\n\t/// <summary>Serialize a draft as <c>.tip</c> JSON. Never throws: a null draft writes an empty tip.</summary>\r\n\tpublic static string Write( TipStudioDraft draft )\r\n\t\t=> RelaxEscapes( JsonSerializer.Serialize( ToWire( draft ), WriteOptions ) );\r\n\r\n\t/// <summary>\r\n\t/// Put back the characters the default JSON writer escapes but the editor does not. An icon of \"\u2705\" is\r\n\t/// written to a <c>.tip</c> by the editor as that character; the default encoder emits <c>\u2705</c>,\r\n\t/// which parses to the same string but is not the same FILE, so the first inspector save of a\r\n\t/// Studio-written tip would show a diff on a line nobody touched. The same goes for the apostrophes and\r\n\t/// angle brackets the default encoder escapes out of HTML caution, which a tip line is full of.\r\n\t///\r\n\t/// Done as a pass over the finished text rather than by swapping in a relaxed encoder, so the writer\r\n\t/// needs no API beyond the JSON serializer a sibling kit already ships. Escapes JSON actually requires\r\n\t/// (the quote, the backslash, and the control characters below 0x20) are left exactly as they are, and an\r\n\t/// escaped backslash is stepped over as a pair so <c>\\\\u0041</c> stays literal.\r\n\t/// </summary>\r\n\tprivate static string RelaxEscapes( string json )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( json ) || json.IndexOf( '\\\\' ) < 0 )\r\n\t\t\treturn json;\r\n\r\n\t\tvar sb = new System.Text.StringBuilder( json.Length );\r\n\t\tvar i = 0;\r\n\r\n\t\twhile ( i < json.Length )\r\n\t\t{\r\n\t\t\tvar c = json[i];\r\n\t\t\tif ( c != '\\\\' || i + 1 >= json.Length )\r\n\t\t\t{\r\n\t\t\t\tsb.Append( c );\r\n\t\t\t\ti++;\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\tif ( json[i + 1] == 'u' && i + 5 < json.Length && TryHex( json, i + 2, out var code )\r\n\t\t\t\t&& code >= 0x20 && code != '\"' && code != '\\\\' )\r\n\t\t\t{\r\n\t\t\t\tsb.Append( (char)code );\r\n\t\t\t\ti += 6;\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\t// Any other escape (including \\\\) is copied as a PAIR, so its second character can never be read\r\n\t\t\t// as the start of a new escape.\r\n\t\t\tsb.Append( c );\r\n\t\t\tsb.Append( json[i + 1] );\r\n\t\t\ti += 2;\r\n\t\t}\r\n\r\n\t\treturn sb.ToString();\r\n\t}\r\n\r\n\tprivate static bool TryHex( string text, int start, out int value )\r\n\t{\r\n\t\tvalue = 0;\r\n\t\tfor ( var i = start; i < start + 4; i++ )\r\n\t\t{\r\n\t\t\tvar c = text[i];\r\n\t\t\tint digit;\r\n\t\t\tif ( c >= '0' && c <= '9' )\r\n\t\t\t\tdigit = c - '0';\r\n\t\t\telse if ( c >= 'a' && c <= 'f' )\r\n\t\t\t\tdigit = c - 'a' + 10;\r\n\t\t\telse if ( c >= 'A' && c <= 'F' )\r\n\t\t\t\tdigit = c - 'A' + 10;\r\n\t\t\telse\r\n\t\t\t\treturn false;\r\n\r\n\t\t\tvalue = ( value << 4 ) | digit;\r\n\t\t}\r\n\r\n\t\treturn true;\r\n\t}\r\n\r\n\t/// <summary>Parse <c>.tip</c> JSON back into a draft. Returns null on malformed input rather than\r\n\t/// throwing, so a hand-edited file with a stray comma reports a problem instead of taking the panel\r\n\t/// down with it.</summary>\r\n\tpublic static TipStudioDraft Read( string json )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( json ) )\r\n\t\t\treturn null;\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar wire = JsonSerializer.Deserialize<TipWire>( json, ReadOptions );\r\n\t\t\treturn wire is null ? null : FromWire( wire );\r\n\t\t}\r\n\t\tcatch ( Exception )\r\n\t\t{\r\n\t\t\treturn null;\r\n\t\t}\r\n\t}\r\n\r\n\t// ------------------------------------------------------------------\r\n\t// Wire types. Property ORDER here is the on-disk field order.\r\n\t// ------------------------------------------------------------------\r\n\r\n\tprivate sealed class TipWire\r\n\t{\r\n\t\tpublic string Id { get; set; } = \"\";\r\n\t\tpublic string Text { get; set; } = \"\";\r\n\t\tpublic string TextPad { get; set; } = \"\";\r\n\t\tpublic string Icon { get; set; } = \"\";\r\n\t\tpublic int Priority { get; set; }\r\n\t\tpublic List<string> PrerequisiteTipIds { get; set; } = new();\r\n\t\tpublic TriggerWire Completion { get; set; } = new();\r\n\t\tpublic float MaxShowSeconds { get; set; }\r\n\t\tpublic TriggerWire Relevance { get; set; } = new();\r\n\r\n\t\t[JsonPropertyName( \"__references\" )]\r\n\t\tpublic List<string> References { get; set; } = new();\r\n\r\n\t\t[JsonPropertyName( \"__version\" )]\r\n\t\tpublic int Version { get; set; }\r\n\t}\r\n\r\n\tprivate sealed class TriggerWire\r\n\t{\r\n\t\tpublic string Kind { get; set; } = \"Always\";\r\n\t\tpublic string Action { get; set; } = \"\";\r\n\t\tpublic string Key { get; set; } = \"\";\r\n\t\tpublic string Name { get; set; } = \"\";\r\n\t\tpublic float Threshold { get; set; }\r\n\t\tpublic float Seconds { get; set; }\r\n\t\tpublic string AnalogSource { get; set; } = \"AnalogMove\";\r\n\t\tpublic float Magnitude { get; set; }\r\n\t\tpublic List<TriggerWire> Children { get; set; } = new();\r\n\t}\r\n\r\n\tprivate static TipWire ToWire( TipStudioDraft draft )\r\n\t{\r\n\t\tdraft ??= new TipStudioDraft();\r\n\r\n\t\treturn new TipWire\r\n\t\t{\r\n\t\t\tId = draft.Id ?? \"\",\r\n\t\t\tText = draft.Text ?? \"\",\r\n\t\t\tTextPad = draft.TextPad ?? \"\",\r\n\t\t\tIcon = draft.Icon ?? \"\",\r\n\t\t\tPriority = draft.Priority,\r\n\t\t\tPrerequisiteTipIds = draft.PrerequisiteTipIds is null\r\n\t\t\t\t? new List<string>()\r\n\t\t\t\t: new List<string>( draft.PrerequisiteTipIds ),\r\n\t\t\tCompletion = ToWire( draft.Completion ),\r\n\t\t\tMaxShowSeconds = draft.MaxShowSeconds,\r\n\t\t\tRelevance = ToWire( draft.Relevance ),\r\n\t\t};\r\n\t}\r\n\r\n\tprivate static TriggerWire ToWire( TipStudioTrigger spec )\r\n\t{\r\n\t\tspec ??= new TipStudioTrigger();\r\n\r\n\t\tvar wire = new TriggerWire\r\n\t\t{\r\n\t\t\tKind = TipStudioTrigger.KindName( spec.Kind ),\r\n\t\t\tAction = spec.Action ?? \"\",\r\n\t\t\tKey = spec.Key ?? \"\",\r\n\t\t\tName = spec.Name ?? \"\",\r\n\t\t\tThreshold = spec.Threshold,\r\n\t\t\tSeconds = spec.Seconds,\r\n\t\t\tAnalogSource = TipStudioTrigger.SourceName( spec.AnalogSource ),\r\n\t\t\tMagnitude = spec.Magnitude,\r\n\t\t};\r\n\r\n\t\tif ( spec.Children is not null )\r\n\t\t\tforeach ( var child in spec.Children )\r\n\t\t\t\twire.Children.Add( ToWire( child ) );\r\n\r\n\t\treturn wire;\r\n\t}\r\n\r\n\tprivate static TipStudioDraft FromWire( TipWire wire ) => new()\r\n\t{\r\n\t\tId = wire.Id ?? \"\",\r\n\t\tText = wire.Text ?? \"\",\r\n\t\tTextPad = wire.TextPad ?? \"\",\r\n\t\tIcon = wire.Icon ?? \"\",\r\n\t\tPriority = wire.Priority,\r\n\t\tPrerequisiteTipIds = wire.PrerequisiteTipIds is null\r\n\t\t\t? new List<string>()\r\n\t\t\t: new List<string>( wire.PrerequisiteTipIds ),\r\n\t\tCompletion = FromWire( wire.Completion ),\r\n\t\tMaxShowSeconds = wire.MaxShowSeconds,\r\n\t\tRelevance = FromWire( wire.Relevance ),\r\n\t};\r\n\r\n\tprivate static TipStudioTrigger FromWire( TriggerWire wire )\r\n\t{\r\n\t\tif ( wire is null )\r\n\t\t\treturn new TipStudioTrigger();\r\n\r\n\t\tvar spec = new TipStudioTrigger\r\n\t\t{\r\n\t\t\tKind = TipStudioTrigger.ParseKind( wire.Kind ),\r\n\t\t\tAction = wire.Action ?? \"\",\r\n\t\t\tKey = wire.Key ?? \"\",\r\n\t\t\tName = wire.Name ?? \"\",\r\n\t\t\tThreshold = wire.Threshold,\r\n\t\t\tSeconds = wire.Seconds,\r\n\t\t\tAnalogSource = TipStudioTrigger.ParseSource( wire.AnalogSource ),\r\n\t\t\tMagnitude = wire.Magnitude,\r\n\t\t};\r\n\r\n\t\tif ( wire.Children is not null )\r\n\t\t\tforeach ( var child in wire.Children )\r\n\t\t\t\tspec.Children.Add( FromWire( child ) );\r\n\r\n\t\treturn spec;\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "fieldguide.tips",
            "Path": "Code/TipDevice.cs",
            "FileName": "TipDevice.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 337872,
            "Code": "using System;\r\n\r\nnamespace FieldGuide.Tips;\r\n\r\n/// <summary>\r\n/// Which input device the player last used. Backed by <c>Input.UsingController</c>, which the engine\r\n/// flaps to the last-used device (there is no event; it is polled per frame). The coach exposes the\r\n/// live value as <see cref=\"TipsCoach.ActiveDevice\"/> and the display folds it into its BuildHash so a\r\n/// device flip re-renders the active tip with the right wording and chips immediately.\r\n/// </summary>\r\npublic enum TipDevice\r\n{\r\n\t/// <summary>The player is on keyboard and mouse (the default when no controller has been used).</summary>\r\n\tKeyboardMouse,\r\n\r\n\t/// <summary>The player is on a game controller (the last button pressed was a pad button).</summary>\r\n\tGamepad,\r\n}\r\n\r\n/// <summary>\r\n/// Pure, engine-free render-time helpers for device-aware tip text: which wording a tip shows for a\r\n/// device, and how a keycap label remaps when the player is on a pad. Kept free of any <c>Sandbox</c>\r\n/// reference so the exact rules a game sees on screen can be exercised headlessly (the display calls\r\n/// these; a harness calls them with fakes and asserts the truth table).\r\n/// </summary>\r\npublic static class TipDeviceText\r\n{\r\n\t/// <summary>\r\n\t/// The pad-mode wording for a tip: its <paramref name=\"textPad\"/> when authored, else its\r\n\t/// <paramref name=\"text\"/>. This is the single-text fallback (build plan point 3): a tip that leaves\r\n\t/// TextPad unset reads identically from either device, so the \"either\" idiom\r\n\t/// (<c>\"Press *W* or `RT`\"</c>) keeps rendering both chips with no auto-stripping.\r\n\t/// </summary>\r\n\tpublic static string PadTextOr( string text, string textPad )\r\n\t\t=> string.IsNullOrEmpty( textPad ) ? ( text ?? \"\" ) : textPad;\r\n\r\n\t/// <summary>\r\n\t/// The wording a tip shows for <paramref name=\"device\"/>: <paramref name=\"text\"/> on keyboard/mouse,\r\n\t/// and <see cref=\"PadTextOr(string, string)\"/> on a pad. A single seam so the device-to-text choice is\r\n\t/// identical in the display and the harness.\r\n\t/// </summary>\r\n\tpublic static string ForDevice( string text, string textPad, TipDevice device )\r\n\t\t=> device == TipDevice.Gamepad ? PadTextOr( text, textPad ) : ( text ?? \"\" );\r\n\r\n\t/// <summary>\r\n\t/// Remap a keyboard keycap label for pad mode (build plan point 4), generalizing World Builder's\r\n\t/// <c>Cap()</c>. Applied to keycap chips at render time only when the player is on a pad:\r\n\t/// <list type=\"bullet\">\r\n\t/// <item><paramref name=\"padLabelFor\"/> null: no map is set, the label passes through unchanged (the\r\n\t/// single-text behaviour, so a game that never sets a map is unaffected).</item>\r\n\t/// <item>the map returns the same or a different non-empty string: that label is shown (an unmapped\r\n\t/// label the game's map passes through stays unchanged; a mapped one, e.g. \"RMB\" to \"LT\", swaps).</item>\r\n\t/// <item>the map returns null or empty: the chip is unpressable on a pad and is skipped (the caller\r\n\t/// renders nothing for it), matching WB's \"skip the unpressable chip\" behaviour.</item>\r\n\t/// </list>\r\n\t/// Returns the label to render, or null to skip the chip.\r\n\t/// </summary>\r\n\tpublic static string PadCap( string keyLabel, Func<string, string> padLabelFor )\r\n\t{\r\n\t\tif ( padLabelFor is null )\r\n\t\t\treturn keyLabel; // no map set: passthrough\r\n\r\n\t\tvar mapped = padLabelFor( keyLabel );\r\n\t\treturn string.IsNullOrEmpty( mapped ) ? null : mapped; // null/empty = unpressable on pad, skip\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "fieldguide.tips",
            "Path": "TipDevice.cs",
            "FileName": "TipDevice.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 337872,
            "Code": "using System;\r\n\r\nnamespace FieldGuide.Tips;\r\n\r\n/// <summary>\r\n/// Which input device the player last used. Backed by <c>Input.UsingController</c>, which the engine\r\n/// flaps to the last-used device (there is no event; it is polled per frame). The coach exposes the\r\n/// live value as <see cref=\"TipsCoach.ActiveDevice\"/> and the display folds it into its BuildHash so a\r\n/// device flip re-renders the active tip with the right wording and chips immediately.\r\n/// </summary>\r\npublic enum TipDevice\r\n{\r\n\t/// <summary>The player is on keyboard and mouse (the default when no controller has been used).</summary>\r\n\tKeyboardMouse,\r\n\r\n\t/// <summary>The player is on a game controller (the last button pressed was a pad button).</summary>\r\n\tGamepad,\r\n}\r\n\r\n/// <summary>\r\n/// Pure, engine-free render-time helpers for device-aware tip text: which wording a tip shows for a\r\n/// device, and how a keycap label remaps when the player is on a pad. Kept free of any <c>Sandbox</c>\r\n/// reference so the exact rules a game sees on screen can be exercised headlessly (the display calls\r\n/// these; a harness calls them with fakes and asserts the truth table).\r\n/// </summary>\r\npublic static class TipDeviceText\r\n{\r\n\t/// <summary>\r\n\t/// The pad-mode wording for a tip: its <paramref name=\"textPad\"/> when authored, else its\r\n\t/// <paramref name=\"text\"/>. This is the single-text fallback (build plan point 3): a tip that leaves\r\n\t/// TextPad unset reads identically from either device, so the \"either\" idiom\r\n\t/// (<c>\"Press *W* or `RT`\"</c>) keeps rendering both chips with no auto-stripping.\r\n\t/// </summary>\r\n\tpublic static string PadTextOr( string text, string textPad )\r\n\t\t=> string.IsNullOrEmpty( textPad ) ? ( text ?? \"\" ) : textPad;\r\n\r\n\t/// <summary>\r\n\t/// The wording a tip shows for <paramref name=\"device\"/>: <paramref name=\"text\"/> on keyboard/mouse,\r\n\t/// and <see cref=\"PadTextOr(string, string)\"/> on a pad. A single seam so the device-to-text choice is\r\n\t/// identical in the display and the harness.\r\n\t/// </summary>\r\n\tpublic static string ForDevice( string text, string textPad, TipDevice device )\r\n\t\t=> device == TipDevice.Gamepad ? PadTextOr( text, textPad ) : ( text ?? \"\" );\r\n\r\n\t/// <summary>\r\n\t/// Remap a keyboard keycap label for pad mode (build plan point 4), generalizing World Builder's\r\n\t/// <c>Cap()</c>. Applied to keycap chips at render time only when the player is on a pad:\r\n\t/// <list type=\"bullet\">\r\n\t/// <item><paramref name=\"padLabelFor\"/> null: no map is set, the label passes through unchanged (the\r\n\t/// single-text behaviour, so a game that never sets a map is unaffected).</item>\r\n\t/// <item>the map returns the same or a different non-empty string: that label is shown (an unmapped\r\n\t/// label the game's map passes through stays unchanged; a mapped one, e.g. \"RMB\" to \"LT\", swaps).</item>\r\n\t/// <item>the map returns null or empty: the chip is unpressable on a pad and is skipped (the caller\r\n\t/// renders nothing for it), matching WB's \"skip the unpressable chip\" behaviour.</item>\r\n\t/// </list>\r\n\t/// Returns the label to render, or null to skip the chip.\r\n\t/// </summary>\r\n\tpublic static string PadCap( string keyLabel, Func<string, string> padLabelFor )\r\n\t{\r\n\t\tif ( padLabelFor is null )\r\n\t\t\treturn keyLabel; // no map set: passthrough\r\n\r\n\t\tvar mapped = padLabelFor( keyLabel );\r\n\t\treturn string.IsNullOrEmpty( mapped ) ? null : mapped; // null/empty = unpressable on pad, skip\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "fieldguide.tips",
            "Path": "TipSegment.cs",
            "FileName": "TipSegment.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 337872,
            "Code": "using System.Collections.Generic;\r\n\r\nnamespace FieldGuide.Tips;\r\n\r\n/// <summary>\r\n/// What a run of tip text renders as: plain prose, a keyboard keycap, or a gamepad button chip.\r\n/// </summary>\r\npublic enum TipSegmentKind\r\n{\r\n\t/// <summary>Ordinary text, rendered as-is.</summary>\r\n\tPlain,\r\n\r\n\t/// <summary>A keyboard key or mouse-button name, rendered as a square keycap. Markup: <c>*W*</c>.</summary>\r\n\tKey,\r\n\r\n\t/// <summary>A gamepad button or stick, rendered as a rounded controller chip. Markup: <c>`A`</c> (backticks).</summary>\r\n\tGamepadButton,\r\n}\r\n\r\n/// <summary>\r\n/// One run of tip text: either plain prose, a keyboard key that renders as a square keycap, or a\r\n/// gamepad button that renders as a rounded controller chip. <see cref=\"TipsDisplay\"/> walks a tip's\r\n/// segments and styles each kind, so tip authors write a single string with markup around input names\r\n/// instead of embedding UI.\r\n///\r\n/// Markup:\r\n/// <list type=\"bullet\">\r\n/// <item><c>*W*</c> (asterisks) is a keyboard / mouse keycap, e.g. <c>\"Hold *B* to block.\"</c></item>\r\n/// <item><c>`A`</c> (backticks) is a gamepad button, e.g. <c>\"Press `A` to jump.\"</c></item>\r\n/// </list>\r\n/// The two are independent, so one line can carry both for a keyboard-and-gamepad prompt:\r\n/// <c>\"Attack with *LMB* / `RT`.\"</c>. Everything outside the markers is plain.\r\n/// </summary>\r\npublic readonly record struct TipSegment( string Text, TipSegmentKind Kind )\r\n{\r\n\t/// <summary>True for a keyboard keycap run (<see cref=\"TipSegmentKind.Key\"/>). Kept for readers that\r\n\t/// only distinguish keyboard chips from plain text.</summary>\r\n\tpublic bool IsKey => Kind == TipSegmentKind.Key;\r\n\r\n\t/// <summary>True for a gamepad button run (<see cref=\"TipSegmentKind.GamepadButton\"/>).</summary>\r\n\tpublic bool IsGamepadButton => Kind == TipSegmentKind.GamepadButton;\r\n\r\n\t/// <summary>\r\n\t/// Split a tip line into alternating plain / key / gamepad runs. A matched pair of <c>*</c> marks a\r\n\t/// keyboard keycap; a matched pair of <c>`</c> marks a gamepad button; everything else is plain. An\r\n\t/// unmatched trailing marker degrades gracefully: its run stays plain.\r\n\t/// </summary>\r\n\tpublic static IReadOnlyList<TipSegment> Parse( string text )\r\n\t{\r\n\t\tvar segments = new List<TipSegment>();\r\n\t\tif ( string.IsNullOrEmpty( text ) )\r\n\t\t\treturn segments;\r\n\r\n\t\tvar i = 0;\r\n\t\twhile ( i < text.Length )\r\n\t\t{\r\n\t\t\tvar c = text[i];\r\n\r\n\t\t\t// A marker only opens a chip if it has a matching partner later in the line; otherwise it is\r\n\t\t\t// literal text (so a lone `*` or backtick reads plainly instead of eating the rest of the line).\r\n\t\t\tif ( c == '*' || c == '`' )\r\n\t\t\t{\r\n\t\t\t\tvar close = text.IndexOf( c, i + 1 );\r\n\t\t\t\tif ( close > i )\r\n\t\t\t\t{\r\n\t\t\t\t\tif ( close > i + 1 ) // non-empty run between the markers\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tvar kind = c == '*' ? TipSegmentKind.Key : TipSegmentKind.GamepadButton;\r\n\t\t\t\t\t\tsegments.Add( new TipSegment( text.Substring( i + 1, close - i - 1 ), kind ) );\r\n\t\t\t\t\t}\r\n\t\t\t\t\ti = close + 1;\r\n\t\t\t\t\tcontinue;\r\n\t\t\t\t}\r\n\t\t\t}\r\n\r\n\t\t\t// Accumulate a plain run up to the next marker (or end of line).\r\n\t\t\tvar start = i;\r\n\t\t\twhile ( i < text.Length && text[i] != '*' && text[i] != '`' )\r\n\t\t\t\ti++;\r\n\r\n\t\t\t// A marker with no partner ahead is literal: fold it into the plain run and keep going.\r\n\t\t\tif ( i < text.Length && text.IndexOf( text[i], i + 1 ) < 0 )\r\n\t\t\t\ti++;\r\n\r\n\t\t\tif ( i > start )\r\n\t\t\t\tsegments.Add( new TipSegment( text.Substring( start, i - start ), TipSegmentKind.Plain ) );\r\n\t\t}\r\n\r\n\t\treturn segments;\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "fieldguide.tips",
            "Path": "TipTriggerEval.cs",
            "FileName": "TipTriggerEval.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 337872,
            "Code": "using System;\r\n\r\nnamespace FieldGuide.Tips;\r\n\r\n/// <summary>\r\n/// The environment a <see cref=\"TipTrigger\"/> is evaluated against: the four world reads plus the active\r\n/// tip's elapsed visible time. <see cref=\"TipsCoach\"/> fills these from <see cref=\"Sandbox.Input\"/>, the\r\n/// pushed <see cref=\"TipContext\"/> and its own timer; a headless harness fills them with fakes. Keeping\r\n/// the reads behind delegates is what lets the trigger truth table be unit-tested without the engine.\r\n/// </summary>\r\npublic sealed class TipTriggerEnv\r\n{\r\n\t/// <summary>Was the named Input.config action pressed this frame? (Input.Pressed)</summary>\r\n\tpublic Func<string, bool> ActionPressed { get; init; } = static _ => false;\r\n\r\n\t/// <summary>Was the named raw key / mouse button pressed this frame? (Input.Keyboard.Pressed)</summary>\r\n\tpublic Func<string, bool> KeyPressed { get; init; } = static _ => false;\r\n\r\n\t/// <summary>Has the named string signal been latched this session? (Signal / Ever kinds)</summary>\r\n\tpublic Func<string, bool> SignalLatched { get; init; } = static _ => false;\r\n\r\n\t/// <summary>Is the named custom context flag true this frame? (TipContext.Flag)</summary>\r\n\tpublic Func<string, bool> Flag { get; init; } = static _ => false;\r\n\r\n\t/// <summary>The named custom context number this frame. (TipContext.Number)</summary>\r\n\tpublic Func<string, float> Number { get; init; } = static _ => 0f;\r\n\r\n\t/// <summary>Seconds the active tip has been visible, for the Timer kind.</summary>\r\n\tpublic float Elapsed { get; init; }\r\n\r\n\t/// <summary>The current magnitude (0..1-ish) of the given analog stick, for the AnalogAxis kind. (Input.AnalogMove / Input.AnalogLook length.)</summary>\r\n\tpublic Func<TipTriggerAnalogSource, float> AnalogMagnitude { get; init; } = static _ => 0f;\r\n}\r\n\r\n/// <summary>\r\n/// The pure, engine-free evaluation core for a declarative <see cref=\"TipTrigger\"/>: the per-kind rule and\r\n/// the AnyOf / AllOf recursion, with every world read behind a <see cref=\"TipTriggerEnv\"/> delegate. This\r\n/// holds the whole completion/relevance truth table, so it can be exercised headlessly (the coach passes\r\n/// real Input / context reads; a harness passes fakes) and stays identical between the two.\r\n/// </summary>\r\npublic static class TipTriggerEval\r\n{\r\n\t/// <summary>Evaluate a trigger against the given environment. Null trigger evaluates false.</summary>\r\n\tpublic static bool Evaluate( TipTrigger t, TipTriggerEnv env )\r\n\t{\r\n\t\tif ( t is null || env is null )\r\n\t\t\treturn false;\r\n\r\n\t\tswitch ( t.Kind )\r\n\t\t{\r\n\t\t\tcase TipTriggerKind.Always:\r\n\t\t\t\treturn true;\r\n\r\n\t\t\tcase TipTriggerKind.InputAction:\r\n\t\t\t\tforeach ( var a in t.Actions )\r\n\t\t\t\t\tif ( !string.IsNullOrEmpty( a ) && env.ActionPressed( a ) )\r\n\t\t\t\t\t\treturn true;\r\n\t\t\t\treturn false;\r\n\r\n\t\t\tcase TipTriggerKind.Key:\r\n\t\t\t\tforeach ( var k in t.Keys )\r\n\t\t\t\t\tif ( !string.IsNullOrEmpty( k ) && env.KeyPressed( k ) )\r\n\t\t\t\t\t\treturn true;\r\n\t\t\t\treturn false;\r\n\r\n\t\t\tcase TipTriggerKind.Signal:\r\n\t\t\tcase TipTriggerKind.Ever:\r\n\t\t\t\treturn !string.IsNullOrEmpty( t.Key ) && env.SignalLatched( t.Key );\r\n\r\n\t\t\tcase TipTriggerKind.Flag:\r\n\t\t\t\treturn !string.IsNullOrEmpty( t.Key ) && env.Flag( t.Key );\r\n\r\n\t\t\tcase TipTriggerKind.AtLeast:\r\n\t\t\t\treturn !string.IsNullOrEmpty( t.Key ) && env.Number( t.Key ) >= t.Threshold;\r\n\r\n\t\t\tcase TipTriggerKind.Timer:\r\n\t\t\t\treturn env.Elapsed >= t.Seconds;\r\n\r\n\t\t\tcase TipTriggerKind.AnalogAxis:\r\n\t\t\t\treturn env.AnalogMagnitude( t.AnalogSource ) >= t.Threshold;\r\n\r\n\t\t\tcase TipTriggerKind.AnyOf:\r\n\t\t\t\tforeach ( var c in t.Children )\r\n\t\t\t\t\tif ( Evaluate( c, env ) )\r\n\t\t\t\t\t\treturn true;\r\n\t\t\t\treturn false;\r\n\r\n\t\t\tcase TipTriggerKind.AllOf:\r\n\t\t\t\tif ( t.Children.Length == 0 )\r\n\t\t\t\t\treturn false;\r\n\t\t\t\tforeach ( var c in t.Children )\r\n\t\t\t\t\tif ( !Evaluate( c, env ) )\r\n\t\t\t\t\t\treturn false;\r\n\t\t\t\treturn true;\r\n\r\n\t\t\tdefault:\r\n\t\t\t\treturn false;\r\n\t\t}\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "fieldguide.tips",
            "Path": "Code/Studio/TipsStudioPanel.razor",
            "FileName": "TipsStudioPanel.razor",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 337872,
            "Code": "@using Sandbox\r\n@using Sandbox.UI\r\n@using System\r\n@using System.Collections.Generic\r\n@using System.Linq\r\n@namespace FieldGuide.Tips\r\n@inherits PanelComponent\r\n@attribute [StyleSheet]\r\n\r\n@*\r\n\tTIPS STUDIO - author a tip inside the running game and watch the real card change as you type.\r\n\r\n\tOne modal, three columns, over a dim scrim (the approved Field Kits layout, docs/design/ui-system).\r\n\tLEFT is the merged catalog with the source each id resolved from, which is also how a tip left behind\r\n\tby another scene gives itself away. MIDDLE is the editor: wording, order, prerequisites, and the two\r\n\ttrigger pickers, whose action list comes from the project's own input actions. RIGHT is the payoff:\r\n\tthe same card the player sees, rendered twice side by side so the keyboard and controller wordings\r\n\tread together, and under it the bake-out (Copy to clipboard, or stage it for the editor menu action\r\n\tthat writes Assets/tips).\r\n\r\n\tThe lower-left card is still the REAL one: \"show it\" pushes the draft into the coach and TipsDisplay\r\n\tdraws it with the shipped stylesheet, and the device chips pin TipsCoach.PreviewDevice. The two cards\r\n\tin the rail are the same component restated inside this panel, because a preview you have to look\r\n\taway from is not a preview.\r\n\r\n\tToggle: `fg_tips_studio 1` in the console, or press T (a plain letter; the editor eats F1-F12 in\r\n\tplay-in-editor). The key OPENS only, never closes, so pressing T inside a text box types a T and\r\n\tnothing else. Close with the x in the header or `fg_tips_studio 0`.\r\n\r\n\tPanel rules this file follows, each of which has cost someone a session: rows come from @foreach in\r\n\tmain markup, never a RenderFragment; the root takes no pointer events and the scrim and modal take\r\n\tall of them; both scroll regions are a FIXED pixel height, never a percentage; no field being TYPED\r\n\tinto is folded into BuildHash, because a rebuild takes the cursor out of the box.\r\n*@\r\n\r\n<root class=\"ts-root\">\r\n@if ( TipsStudio.Open )\r\n{\r\n\t<div class=\"ts-scrim\">\r\n\t\t<div class=\"ts-modal\">\r\n\r\n\t\t\t@* ================= header ================= *@\r\n\t\t\t<div class=\"ts-hdr\">\r\n\t\t\t\t<div class=\"ts-hdr-left\">\r\n\t\t\t\t\t<div class=\"ts-title\">TIPS STUDIO</div>\r\n\t\t\t\t\t<div class=\"ts-hdr-meta\">@HeaderMeta</div>\r\n\t\t\t\t</div>\r\n\t\t\t\t<div class=\"ts-x\" onclick=@Close>\u00d7</div>\r\n\t\t\t</div>\r\n\r\n\t\t\t<div class=\"ts-cols\">\r\n\r\n\t\t\t\t@* ================= left: the merged catalog ================= *@\r\n\t\t\t\t<div class=\"ts-left\">\r\n\t\t\t\t\t<div class=\"ts-list-hdr\">\r\n\t\t\t\t\t\t<div class=\"ts-list-t\">Tips</div>\r\n\t\t\t\t\t\t<div class=\"ts-list-m\">by priority</div>\r\n\t\t\t\t\t</div>\r\n\r\n\t\t\t\t\t<div class=\"ts-list\" @ref=\"ListBody\">\r\n\t\t\t\t\t\t@if ( Entries.Count == 0 )\r\n\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t<div class=\"ts-empty\">\r\n\t\t\t\t\t\t\t\t<div class=\"ts-empty-t\">No tips yet</div>\r\n\t\t\t\t\t\t\t\t<div class=\"ts-empty-l\">Press New tip to write one.</div>\r\n\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t}\r\n\t\t\t\t\t\t@foreach ( var e in Entries )\r\n\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\tvar entry = e;\r\n\t\t\t\t\t\t\t<div class=\"ts-row @(entry.Selected ? \"on\" : \"\")\" onclick=@(() => OpenTip( entry.Id ))>\r\n\t\t\t\t\t\t\t\t<div class=\"ts-row-id\">@entry.Id</div>\r\n\t\t\t\t\t\t\t\t@* State REPLACES the source rather than sitting beside it: the row is 248px wide\r\n\t\t\t\t\t\t\t\t\tand a third thing in it squeezes the id until it blanks out. *@\r\n\t\t\t\t\t\t\t\t<div class=\"ts-row-meta\">\r\n\t\t\t\t\t\t\t\t\t@if ( entry.State is null )\r\n\t\t\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t\t\t<div class=\"ts-row-src\">@entry.Source</div>\r\n\t\t\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\t\t\telse\r\n\t\t\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t\t\t<div class=\"ts-row-state\">@entry.State</div>\r\n\t\t\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t}\r\n\t\t\t\t\t</div>\r\n\r\n\t\t\t\t\t<div class=\"ts-left-btns\">\r\n\t\t\t\t\t\t<div class=\"ts-btn pri grow gap\" onclick=@NewTip>New tip</div>\r\n\t\t\t\t\t\t<div class=\"ts-btn\" onclick=@Rescan>Rescan</div>\r\n\t\t\t\t\t</div>\r\n\t\t\t\t</div>\r\n\r\n\t\t\t\t@* ================= middle: the draft ================= *@\r\n\t\t\t\t<div class=\"ts-mid\" @ref=\"MidBody\">\r\n\r\n\t\t\t\t\t@if ( TipsStudio.DroppedPredicates )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\t<div class=\"ts-warn\">\r\n\t\t\t\t\t\t\t<div class=\"ts-warn-b\">!</div>\r\n\t\t\t\t\t\t\t<div class=\"ts-warn-t\">\r\n\t\t\t\t\t\t\t\t<div class=\"ts-warn-l\">This tip carries a code predicate.</div>\r\n\t\t\t\t\t\t\t\t<div class=\"ts-warn-l\">A .tip file cannot hold one, so baking</div>\r\n\t\t\t\t\t\t\t\t<div class=\"ts-warn-l\">keeps the triggers and drops the predicate.</div>\r\n\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t</div>\r\n\t\t\t\t\t}\r\n\r\n\t\t\t\t\t<div class=\"ts-frow\">\r\n\t\t\t\t\t\t<div class=\"ts-field w200\">\r\n\t\t\t\t\t\t\t<div class=\"ts-lab\">Id</div>\r\n\t\t\t\t\t\t\t<TextEntry class=\"ts-in\" Value=@DraftId\r\n\t\t\t\t\t\t\t\tOnTextEdited=@((string v) => { TipsStudio.Draft.Id = v; }) onsubmit=@Commit />\r\n\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t<div class=\"ts-field w150\">\r\n\t\t\t\t\t\t\t<div class=\"ts-lab\">Priority</div>\r\n\t\t\t\t\t\t\t<div class=\"ts-step\">\r\n\t\t\t\t\t\t\t\t<div class=\"ts-stp\" onclick=@(() => BumpPriority( -10 ))>\u2212</div>\r\n\t\t\t\t\t\t\t\t<div class=\"ts-val\">@DraftPriority</div>\r\n\t\t\t\t\t\t\t\t<div class=\"ts-stp\" onclick=@(() => BumpPriority( 10 ))>+</div>\r\n\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t<div class=\"ts-field grow last\">\r\n\t\t\t\t\t\t\t<div class=\"ts-lab\">Prerequisites</div>\r\n\t\t\t\t\t\t\t<div class=\"ts-drop\">\r\n\t\t\t\t\t\t\t\t<div class=\"ts-drop-face @(IsOpen( PrereqDrop ) ? \"open\" : \"\")\"\r\n\t\t\t\t\t\t\t\t\tonclick=@(() => ToggleDrop( PrereqDrop ))>\r\n\t\t\t\t\t\t\t\t\t<div class=\"ts-drop-val\">@PrereqFace</div>\r\n\t\t\t\t\t\t\t\t\t<div class=\"ts-chev\">expand_more</div>\r\n\t\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t\t\t@if ( IsOpen( PrereqDrop ) )\r\n\t\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t\t<div class=\"ts-drop-list @(Tall( AvailablePrerequisites.Count ))\">\r\n\t\t\t\t\t\t\t\t\t\t@if ( AvailablePrerequisites.Count == 0 )\r\n\t\t\t\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"ts-drop-opt\">no other tip ids yet</div>\r\n\t\t\t\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\t\t\t\t@foreach ( var p in AvailablePrerequisites )\r\n\t\t\t\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t\t\t\tvar prereq = p;\r\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"ts-drop-opt\" onclick=@(() => AddPrerequisite( prereq ))>@prereq</div>\r\n\t\t\t\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t</div>\r\n\t\t\t\t\t</div>\r\n\r\n\t\t\t\t\t@* Its own block under the field, with its own bottom margin: this row used to collapse\r\n\t\t\t\t\t\tinto the wording section and paint its chips over the Text label. *@\r\n\t\t\t\t\t@if ( PrerequisiteList.Count > 0 )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\t<div class=\"ts-chips block\">\r\n\t\t\t\t\t\t\t@foreach ( var p in PrerequisiteList )\r\n\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\tvar prereq = p;\r\n\t\t\t\t\t\t\t\t<div class=\"ts-chip on mono\"\r\n\t\t\t\t\t\t\t\t\tonclick=@(() => RemovePrerequisite( prereq ))>@($\"{prereq} \u00d7\")</div>\r\n\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t</div>\r\n\t\t\t\t\t}\r\n\r\n\t\t\t\t\t<div class=\"ts-frow\">\r\n\t\t\t\t\t\t<div class=\"ts-field grow last\">\r\n\t\t\t\t\t\t\t<div class=\"ts-lab-row\">\r\n\t\t\t\t\t\t\t\t<div class=\"ts-lab\">Text</div>\r\n\t\t\t\t\t\t\t\t<div class=\"ts-cap\">*Space* keycap \u00b7 `A` pad button</div>\r\n\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t\t<TextEntry class=\"ts-in\" Value=@DraftText\r\n\t\t\t\t\t\t\t\tOnTextEdited=@((string v) => { TipsStudio.Draft.Text = v; Refresh(); }) onsubmit=@Commit />\r\n\t\t\t\t\t\t</div>\r\n\t\t\t\t\t</div>\r\n\r\n\t\t\t\t\t<div class=\"ts-frow\">\r\n\t\t\t\t\t\t<div class=\"ts-field grow\">\r\n\t\t\t\t\t\t\t<div class=\"ts-lab\">Pad text \u00b7 optional</div>\r\n\t\t\t\t\t\t\t<TextEntry class=\"ts-in\" Value=@DraftTextPad\r\n\t\t\t\t\t\t\t\tOnTextEdited=@((string v) => { TipsStudio.Draft.TextPad = v; Refresh(); }) onsubmit=@Commit />\r\n\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t<div class=\"ts-field w120 last\">\r\n\t\t\t\t\t\t\t<div class=\"ts-lab\">Icon</div>\r\n\t\t\t\t\t\t\t<TextEntry class=\"ts-in\" Value=@DraftIcon\r\n\t\t\t\t\t\t\t\tOnTextEdited=@((string v) => { TipsStudio.Draft.Icon = v; Refresh(); }) onsubmit=@Commit />\r\n\t\t\t\t\t\t</div>\r\n\t\t\t\t\t</div>\r\n\r\n\t\t\t\t\t@* ---- the two trigger pickers, from one block of markup ---- *@\r\n\t\t\t\t\t@foreach ( var s in TriggerSlots )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tvar slot = s;\r\n\t\t\t\t\t\tvar trig = slot.Trigger;\r\n\t\t\t\t\t\tvar actionDrop = slot.Key;\r\n\r\n\t\t\t\t\t\t<div class=\"ts-sec\">\r\n\t\t\t\t\t\t\t<div class=\"ts-sec-hd\">\r\n\t\t\t\t\t\t\t\t<div class=\"ts-sec-t\">@slot.Title</div>\r\n\t\t\t\t\t\t\t\t<div class=\"ts-cap flat\">@slot.Blurb</div>\r\n\t\t\t\t\t\t\t</div>\r\n\r\n\t\t\t\t\t\t\t<div class=\"ts-chips\">\r\n\t\t\t\t\t\t\t\t@foreach ( var k in TipStudioTrigger.AllKinds )\r\n\t\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t\tvar kind = k;\r\n\t\t\t\t\t\t\t\t\t<div class=\"ts-chip @(trig.Kind == kind ? \"on\" : \"\")\"\r\n\t\t\t\t\t\t\t\t\t\tonclick=@(() => SetKind( trig, kind ))>@TipStudioTrigger.KindName( kind )</div>\r\n\t\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\t</div>\r\n\r\n\t\t\t\t\t\t\t<div class=\"ts-frow\">\r\n\t\t\t\t\t\t\t\t@if ( trig.Kind == TipTriggerKind.InputAction )\r\n\t\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t\t<div class=\"ts-field w200\">\r\n\t\t\t\t\t\t\t\t\t\t<div class=\"ts-lab\">Input action</div>\r\n\t\t\t\t\t\t\t\t\t\t<div class=\"ts-drop\">\r\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"ts-drop-face @(IsOpen( actionDrop ) ? \"open\" : \"\")\"\r\n\t\t\t\t\t\t\t\t\t\t\t\tonclick=@(() => ToggleDrop( actionDrop ))>\r\n\t\t\t\t\t\t\t\t\t\t\t\t<div class=\"ts-drop-val\">@ActionFace( trig )</div>\r\n\t\t\t\t\t\t\t\t\t\t\t\t<div class=\"ts-chev\">expand_more</div>\r\n\t\t\t\t\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t\t\t\t\t\t@if ( IsOpen( actionDrop ) )\r\n\t\t\t\t\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t\t\t\t\t<div class=\"ts-drop-list @(Tall( ActionNames.Count ))\">\r\n\t\t\t\t\t\t\t\t\t\t\t\t\t@if ( ActionNames.Count == 0 )\r\n\t\t\t\t\t\t\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<div class=\"ts-drop-opt\">no input actions bound</div>\r\n\t\t\t\t\t\t\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\t\t\t\t\t\t\t@foreach ( var a in ActionNames )\r\n\t\t\t\t\t\t\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tvar action = a;\r\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<div class=\"ts-drop-opt @(trig.Action == action ? \"on\" : \"\")\"\r\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tonclick=@(() => PickAction( trig, action ))>@action</div>\r\n\t\t\t\t\t\t\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t\t\t\t\t<div class=\"ts-cap\">from Input.config</div>\r\n\t\t\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t\t\t}\r\n\r\n\t\t\t\t\t\t\t\t@if ( trig.Kind == TipTriggerKind.Key )\r\n\t\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t\t<div class=\"ts-field w200\">\r\n\t\t\t\t\t\t\t\t\t\t<div class=\"ts-lab\">Key</div>\r\n\t\t\t\t\t\t\t\t\t\t<TextEntry class=\"ts-in\" Value=@TrigKey( trig )\r\n\t\t\t\t\t\t\t\t\t\t\tOnTextEdited=@((string v) => { trig.Key = v; }) onsubmit=@Commit />\r\n\t\t\t\t\t\t\t\t\t\t<div class=\"ts-cap\">space, w, mouse1</div>\r\n\t\t\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t\t\t}\r\n\r\n\t\t\t\t\t\t\t\t@if ( slot.NeedsName )\r\n\t\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t\t<div class=\"ts-field w200\">\r\n\t\t\t\t\t\t\t\t\t\t<div class=\"ts-lab\">Name</div>\r\n\t\t\t\t\t\t\t\t\t\t<TextEntry class=\"ts-in\" Value=@TrigName( trig )\r\n\t\t\t\t\t\t\t\t\t\t\tOnTextEdited=@((string v) => { trig.Name = v; }) onsubmit=@Commit />\r\n\t\t\t\t\t\t\t\t\t\t<div class=\"ts-cap\">@slot.NameHint</div>\r\n\t\t\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t\t\t}\r\n\r\n\t\t\t\t\t\t\t\t@if ( trig.Kind == TipTriggerKind.AtLeast )\r\n\t\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t\t<div class=\"ts-field w150\">\r\n\t\t\t\t\t\t\t\t\t\t<div class=\"ts-lab\">At least</div>\r\n\t\t\t\t\t\t\t\t\t\t<div class=\"ts-step\">\r\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"ts-stp\" onclick=@(() => BumpThreshold( trig, -1f ))>\u2212</div>\r\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"ts-val\">@TrigThreshold( trig )</div>\r\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"ts-stp\" onclick=@(() => BumpThreshold( trig, 1f ))>+</div>\r\n\t\t\t\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t\t\t}\r\n\r\n\t\t\t\t\t\t\t\t@if ( trig.Kind == TipTriggerKind.Timer )\r\n\t\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t\t<div class=\"ts-field w150\">\r\n\t\t\t\t\t\t\t\t\t\t<div class=\"ts-lab\">Seconds</div>\r\n\t\t\t\t\t\t\t\t\t\t<div class=\"ts-step\">\r\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"ts-stp\" onclick=@(() => BumpSeconds( trig, -1f ))>\u2212</div>\r\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"ts-val\">@TrigSeconds( trig )</div>\r\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"ts-stp\" onclick=@(() => BumpSeconds( trig, 1f ))>+</div>\r\n\t\t\t\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t\t\t}\r\n\r\n\t\t\t\t\t\t\t\t@if ( trig.Kind == TipTriggerKind.AnalogAxis )\r\n\t\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t\t<div class=\"ts-field w150\">\r\n\t\t\t\t\t\t\t\t\t\t<div class=\"ts-lab\">Magnitude</div>\r\n\t\t\t\t\t\t\t\t\t\t<div class=\"ts-step\">\r\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"ts-stp\" onclick=@(() => BumpMagnitude( trig, -0.1f ))>\u2212</div>\r\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"ts-val\">@TrigMagnitude( trig )</div>\r\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"ts-stp\" onclick=@(() => BumpMagnitude( trig, 0.1f ))>+</div>\r\n\t\t\t\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t\t\t\t\t<div class=\"ts-cap\">0 to 1</div>\r\n\t\t\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t\t\t}\r\n\r\n\t\t\t\t\t\t\t\t@if ( slot.IsCompletion )\r\n\t\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t\t<div class=\"ts-field grow last\">\r\n\t\t\t\t\t\t\t\t\t\t<div class=\"ts-lab\">Max show seconds</div>\r\n\t\t\t\t\t\t\t\t\t\t<div class=\"ts-step\">\r\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"ts-stp\" onclick=@(() => BumpMaxShow( -1f ))>\u2212</div>\r\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"ts-val w80\">@DraftMaxShow</div>\r\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"ts-stp\" onclick=@(() => BumpMaxShow( 1f ))>+</div>\r\n\t\t\t\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t\t\t\t\t<div class=\"ts-cap\">0 = never auto-complete</div>\r\n\t\t\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\t</div>\r\n\r\n\t\t\t\t\t\t\t@if ( trig.Kind == TipTriggerKind.AnalogAxis )\r\n\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t<div class=\"ts-chips\">\r\n\t\t\t\t\t\t\t\t\t@foreach ( var src in TipStudioTrigger.AllAnalogSources )\r\n\t\t\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t\t\tvar source = src;\r\n\t\t\t\t\t\t\t\t\t\t<div class=\"ts-chip @(trig.AnalogSource == source ? \"on\" : \"\")\"\r\n\t\t\t\t\t\t\t\t\t\t\tonclick=@(() => SetSource( trig, source ))>@TipStudioTrigger.SourceName( source )</div>\r\n\t\t\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t\t}\r\n\r\n\t\t\t\t\t\t\t@if ( slot.IsComposite )\r\n\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t<div class=\"ts-chips\">\r\n\t\t\t\t\t\t\t\t\t<div class=\"ts-chip\" onclick=@(() => AddChild( trig ))>add one</div>\r\n\t\t\t\t\t\t\t\t\t@if ( trig.Children.Count == 0 )\r\n\t\t\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t\t\t<div class=\"ts-cap flat\">@slot.EmptyCompositeHint</div>\r\n\t\t\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\t\t</div>\r\n\r\n\t\t\t\t\t\t\t\t@foreach ( var c in trig.Children.ToList() )\r\n\t\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t\tvar child = c;\r\n\t\t\t\t\t\t\t\t\t<div class=\"ts-child\">\r\n\t\t\t\t\t\t\t\t\t\t<div class=\"ts-chips\">\r\n\t\t\t\t\t\t\t\t\t\t\t@foreach ( var k in TipStudioTrigger.AllKinds )\r\n\t\t\t\t\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t\t\t\t\tvar kind = k;\r\n\t\t\t\t\t\t\t\t\t\t\t\t<div class=\"ts-chip small @(child.Kind == kind ? \"on\" : \"\")\"\r\n\t\t\t\t\t\t\t\t\t\t\t\t\tonclick=@(() => SetKind( child, kind ))>@TipStudioTrigger.KindName( kind )</div>\r\n\t\t\t\t\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"ts-chip small drop\" onclick=@(() => RemoveChild( trig, child ))>remove</div>\r\n\t\t\t\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t\t\t\t\t<div class=\"ts-frow\">\r\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"ts-field grow last\">\r\n\t\t\t\t\t\t\t\t\t\t\t\t<div class=\"ts-lab-row\">\r\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div class=\"ts-lab\">Value</div>\r\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div class=\"ts-cap flat\">@ChildHint( child )</div>\r\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t\t\t\t\t\t\t<TextEntry class=\"ts-in\" Value=@ChildValue( child )\r\n\t\t\t\t\t\t\t\t\t\t\t\t\tOnTextEdited=@((string v) => SetChildValue( child, v )) onsubmit=@Commit />\r\n\t\t\t\t\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t</div>\r\n\t\t\t\t\t}\r\n\r\n\t\t\t\t\t@* ---- what the draft would do wrong ---- *@\r\n\t\t\t\t\t@if ( TipsStudio.ShadowedBy is not null )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\t<div class=\"ts-warn\">\r\n\t\t\t\t\t\t\t<div class=\"ts-warn-b\">!</div>\r\n\t\t\t\t\t\t\t<div class=\"ts-warn-t\">\r\n\t\t\t\t\t\t\t\t<div class=\"ts-warn-l\">A code tip already owns this id.</div>\r\n\t\t\t\t\t\t\t\t<div class=\"ts-warn-l\">Your file sits behind it in the catalog.</div>\r\n\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t</div>\r\n\t\t\t\t\t}\r\n\r\n\t\t\t\t\t@foreach ( var n in NoteBlocks )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tvar note = n;\r\n\t\t\t\t\t\t<div class=\"ts-warn\">\r\n\t\t\t\t\t\t\t<div class=\"ts-warn-b\">!</div>\r\n\t\t\t\t\t\t\t<div class=\"ts-warn-t\">\r\n\t\t\t\t\t\t\t\t@foreach ( var l in note.Lines )\r\n\t\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t\tvar line = l;\r\n\t\t\t\t\t\t\t\t\t<div class=\"ts-warn-l\">@line</div>\r\n\t\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t</div>\r\n\t\t\t\t\t}\r\n\t\t\t\t</div>\r\n\r\n\t\t\t\t@* ================= right: preview and bake ================= *@\r\n\t\t\t\t<div class=\"ts-rail\">\r\n\t\t\t\t\t<div class=\"ts-rail-hdr\">\r\n\t\t\t\t\t\t<div class=\"ts-kicker\">LIVE PREVIEW</div>\r\n\t\t\t\t\t\t<div class=\"ts-btn-row\">\r\n\t\t\t\t\t\t\t<div class=\"ts-btn small gap\" onclick=@CompleteNow>Complete it</div>\r\n\t\t\t\t\t\t\t<div class=\"ts-btn pri small\" onclick=@TestFire>Test fire</div>\r\n\t\t\t\t\t\t</div>\r\n\t\t\t\t\t</div>\r\n\r\n\t\t\t\t\t@* Pinned above the scrolling preview: these four pick what the REAL lower-left card\r\n\t\t\t\t\t\tshows, and a control that scrolls out of sight is a control nobody finds. *@\r\n\t\t\t\t\t<div class=\"ts-pv-row\">\r\n\t\t\t\t\t\t<div class=\"ts-pv-key\">Live card</div>\r\n\t\t\t\t\t\t<div class=\"ts-chip @(TipsStudio.PreviewOn ? \"on\" : \"\")\"\r\n\t\t\t\t\t\t\tonclick=@TogglePreview>@(TipsStudio.PreviewOn ? \"showing\" : \"show it\")</div>\r\n\t\t\t\t\t\t<div class=\"ts-chip @(TipsStudio.PinnedDevice == TipDevice.KeyboardMouse ? \"on\" : \"\")\"\r\n\t\t\t\t\t\t\tonclick=@(() => PinDevice( TipDevice.KeyboardMouse ))>keyboard</div>\r\n\t\t\t\t\t\t<div class=\"ts-chip @(TipsStudio.PinnedDevice == TipDevice.Gamepad ? \"on\" : \"\")\"\r\n\t\t\t\t\t\t\tonclick=@(() => PinDevice( TipDevice.Gamepad ))>pad</div>\r\n\t\t\t\t\t\t<div class=\"ts-chip @(TipsStudio.PinnedDevice is null ? \"on\" : \"\")\"\r\n\t\t\t\t\t\t\tonclick=@(() => PinDevice( null ))>live</div>\r\n\t\t\t\t\t</div>\r\n\r\n\t\t\t\t\t<div class=\"ts-rail-body\" @ref=\"RailBody\">\r\n\t\t\t\t\t<div class=\"ts-well\">\r\n\t\t\t\t\t\t@foreach ( var p in PreviewCards )\r\n\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\tvar card = p;\r\n\t\t\t\t\t\t\t<div class=\"ts-pv @(card.Last ? \"last\" : \"\")\">\r\n\t\t\t\t\t\t\t\t<div class=\"ts-pv-lab\">@card.Label</div>\r\n\t\t\t\t\t\t\t\t<div class=\"tsp-card\">\r\n\t\t\t\t\t\t\t\t\t<div class=\"tsp-stripe\"></div>\r\n\t\t\t\t\t\t\t\t\t<div class=\"tsp-in\">\r\n\t\t\t\t\t\t\t\t\t\t@if ( !string.IsNullOrEmpty( card.Icon ) )\r\n\t\t\t\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"tsp-glyph\">@card.Icon</div>\r\n\t\t\t\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\t\t\t\t<div class=\"tsp-body\">\r\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"tsp-kicker\">GUIDE</div>\r\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"tsp-text\">\r\n\t\t\t\t\t\t\t\t\t\t\t\t@foreach ( var seg in card.Segments )\r\n\t\t\t\t\t\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t\t\t\t\t\tvar run = seg;\r\n\t\t\t\t\t\t\t\t\t\t\t\t\tif ( run.Kind == TipSegmentKind.Key )\r\n\t\t\t\t\t\t\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span class=\"tsp-key\">@run.Text</span>\r\n\t\t\t\t\t\t\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\t\t\t\t\t\t\telse if ( run.Kind == TipSegmentKind.GamepadButton )\r\n\t\t\t\t\t\t\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span class=\"tsp-pad\">@run.Text</span>\r\n\t\t\t\t\t\t\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\t\t\t\t\t\t\telse\r\n\t\t\t\t\t\t\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span>@run.Text</span>\r\n\t\t\t\t\t\t\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t\t\t\t\t<div class=\"tsp-x\">\u00d7</div>\r\n\t\t\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t}\r\n\t\t\t\t\t\t<div class=\"ts-cap well\">renders exactly what the coach will show</div>\r\n\t\t\t\t\t</div>\r\n\t\t\t\t\t</div>\r\n\r\n\t\t\t\t\t<div class=\"ts-out\">\r\n\t\t\t\t\t\t<div class=\"ts-btn-row\">\r\n\t\t\t\t\t\t\t<div class=\"ts-btn grow gap\" onclick=@CopyJson>@_copyLabel</div>\r\n\t\t\t\t\t\t\t<div class=\"ts-btn pri grow\" onclick=@Stage>Write to project</div>\r\n\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t<div class=\"ts-out-line\">@BakeTarget</div>\r\n\t\t\t\t\t\t<div class=\"ts-btn-row pad\">\r\n\t\t\t\t\t\t\t<div class=\"ts-btn small\" onclick=@ClearStaged>Clear staged</div>\r\n\t\t\t\t\t\t\t<div class=\"ts-out-line inline\">@StatusLine</div>\r\n\t\t\t\t\t\t</div>\r\n\t\t\t\t\t</div>\r\n\t\t\t\t</div>\r\n\r\n\t\t\t</div>\r\n\t\t</div>\r\n\t</div>\r\n}\r\n</root>\r\n\r\n@code\r\n{\r\n\t// ---- mounting ----\r\n\r\n\t/// <summary>The raw key that OPENS the Studio. A plain letter on purpose: the editor eats F1 to F12 in\r\n\t/// play-in-editor. It only opens, never closes, so pressing it inside a text box just types the letter.\r\n\t/// Close with the header's \u00d7 or <c>fg_tips_studio 0</c>.</summary>\r\n\t[Property] public string OpenKey { get; set; } = \"T\";\r\n\r\n\t/// <summary>Whether the Studio starts open. Off by default: an authoring panel that appears unbidden over\r\n\t/// a game is a bug. This, and only this, decides the boot state; the persisted convar never does.</summary>\r\n\t[Property] public bool OpenOnStart { get; set; }\r\n\r\n\t/// <summary>Force the Studio shut anywhere but the editor. On by default: it is an authoring tool, and a\r\n\t/// published build has nothing to author. Turn it off if you want it in your own standalone dev build.</summary>\r\n\t[Property] public bool EditorOnly { get; set; } = true;\r\n\r\n\t// @ref binds to an auto-PROPERTY. On a bare private field it silently never assigns, and the\r\n\t// CanDragScroll fix below would quietly do nothing.\r\n\tSandbox.UI.Panel ListBody { get; set; }\r\n\tSandbox.UI.Panel MidBody { get; set; }\r\n\tSandbox.UI.Panel RailBody { get; set; }\r\n\r\n\tbool _booted;\r\n\tbool _wasOpen;\r\n\tint _revision;\r\n\tstring _copyLabel = \"Copy .tip JSON\";\r\n\tstring _stageLine = \"\";\r\n\r\n\t/// <summary>Which dropdown is showing its options, or null. One at a time: the lists sit in flow under\r\n\t/// their field, so two open at once would push the column around for no reason.</summary>\r\n\tstring _openDrop;\r\n\r\n\t/// <summary>The prerequisite picker's key. The trigger pickers key off their slot name.</summary>\r\n\tconst string PrereqDrop = \"prereq\";\r\n\r\n\tbool IsOpen( string key ) => _openDrop == key;\r\n\r\n\tvoid ToggleDrop( string key )\r\n\t{\r\n\t\t_openDrop = _openDrop == key ? null : key;\r\n\t\tRefresh();\r\n\t}\r\n\r\n\t// ---- the catalog list ----\r\n\r\n\t/// <summary>One row of the tip list. A struct of finished strings so the markup interpolates single\r\n\t/// identifiers only, never a chained member read (which renders blank in several razor cases).</summary>\r\n\tpublic struct Entry\r\n\t{\r\n\t\tpublic string Id;\r\n\t\tpublic string Source;\r\n\t\tpublic string State;\r\n\t\tpublic bool Selected;\r\n\t}\r\n\r\n\t/// <summary>Every tip in the merged catalog, read fresh (so an edited .tip appears the moment the\r\n\t/// catalog rebuilds), labelled with the source it resolved from and ordered the way the coach picks\r\n\t/// them: highest priority first.</summary>\r\n\tList<Entry> Entries\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tvar list = new List<Entry>();\r\n\t\t\tvar view = TipsCatalog.View;\r\n\t\t\tvar activeId = TipsCoach.ActiveTip?.Id;\r\n\t\t\tvar opened = TipsStudio.OpenedFrom;\r\n\r\n\t\t\tforeach ( var def in view.Tips.OrderByDescending( t => t.Priority ).ThenBy( t => t.Id, StringComparer.Ordinal ) )\r\n\t\t\t{\r\n\t\t\t\tvar source = view.SourceById.TryGetValue( def.Id, out var s ) ? s : \"unknown\";\r\n\r\n\t\t\t\tlist.Add( new Entry\r\n\t\t\t\t{\r\n\t\t\t\t\tId = def.Id,\r\n\t\t\t\t\tSource = source,\r\n\t\t\t\t\tState = def.Id == activeId ? \"on screen\" : ( TipsCoach.IsCompleted( def.Id ) ? \"done\" : null ),\r\n\t\t\t\t\tSelected = def.Id == opened,\r\n\t\t\t\t} );\r\n\t\t\t}\r\n\r\n\t\t\treturn list;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>The header's one line: the package, the catalog, and what the middle column is editing.\r\n\t/// One interpolated string rather than three text nodes, because the in-editor codegen drops the\r\n\t/// whitespace between a literal and an expression.</summary>\r\n\tstring HeaderMeta => $\"fieldguide.tips \u00b7 {CatalogSummary} \u00b7 {DraftOrigin}\";\r\n\r\n\t/// <summary>How many tips and where they came from. A source you did not expect is a tip left over\r\n\t/// from somewhere else.</summary>\r\n\tstring CatalogSummary\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tvar view = TipsCatalog.View;\r\n\t\t\tif ( view.Tips.Count == 0 )\r\n\t\t\t\treturn \"no tips yet\";\r\n\r\n\t\t\tvar counts = new Dictionary<string, int>();\r\n\t\t\tforeach ( var kv in view.SourceById )\r\n\t\t\t\tcounts[kv.Value] = counts.TryGetValue( kv.Value, out var n ) ? n + 1 : 1;\r\n\r\n\t\t\tvar parts = counts.OrderBy( kv => kv.Key, StringComparer.Ordinal ).Select( kv => $\"{kv.Value} {kv.Key}\" );\r\n\t\t\treturn $\"{view.Tips.Count} tips \u00b7 {string.Join( \", \", parts )}\";\r\n\t\t}\r\n\t}\r\n\r\n\tvoid OpenTip( string id )\r\n\t{\r\n\t\tTipsStudio.OpenTip( id );\r\n\t\tResetLabels();\r\n\t\t_openDrop = null;\r\n\t\tRefresh();\r\n\t}\r\n\r\n\tvoid NewTip()\r\n\t{\r\n\t\tTipsStudio.NewDraft();\r\n\t\tResetLabels();\r\n\t\t_openDrop = null;\r\n\t\tRefresh();\r\n\t}\r\n\r\n\tvoid Rescan()\r\n\t{\r\n\t\tTipsCatalog.NoteAssetsChanged();\r\n\t\tRefresh();\r\n\t}\r\n\r\n\t// ---- draft editing ----\r\n\r\n\tstring DraftOrigin => string.IsNullOrEmpty( TipsStudio.OpenedFrom )\r\n\t\t? \"new tip\"\r\n\t\t: $\"editing {TipsStudio.OpenedFrom}\";\r\n\r\n\t// Single-identifier reads for the markup. A razor interpolation of a CHAINED member read\r\n\t// (TipsStudio.Draft.Priority) renders blank in several cases; a plain property or a method call does not.\r\n\tstring DraftId => TipsStudio.Draft.Id;\r\n\tstring DraftText => TipsStudio.Draft.Text;\r\n\tstring DraftTextPad => TipsStudio.Draft.TextPad;\r\n\tstring DraftIcon => TipsStudio.Draft.Icon;\r\n\tint DraftPriority => TipsStudio.Draft.Priority;\r\n\tstring DraftMaxShow => Show( TipsStudio.Draft.MaxShowSeconds );\r\n\r\n\tstatic string TrigKey( TipStudioTrigger t ) => t.Key;\r\n\tstatic string TrigName( TipStudioTrigger t ) => t.Name;\r\n\tstatic string TrigThreshold( TipStudioTrigger t ) => Show( t.Threshold );\r\n\tstatic string TrigSeconds( TipStudioTrigger t ) => Show( t.Seconds );\r\n\tstatic string TrigMagnitude( TipStudioTrigger t ) => Show( t.Magnitude );\r\n\r\n\tstatic string Show( float value ) => value.ToString( \"0.##\" );\r\n\r\n\tvoid BumpPriority( int delta )\r\n\t{\r\n\t\tTipsStudio.Draft.Priority += delta;\r\n\t\tRefresh();\r\n\t}\r\n\r\n\tvoid BumpMaxShow( float delta )\r\n\t{\r\n\t\tTipsStudio.Draft.MaxShowSeconds = MathF.Max( 0f, TipsStudio.Draft.MaxShowSeconds + delta );\r\n\t\tRefresh();\r\n\t}\r\n\r\n\tList<string> PrerequisiteList => TipsStudio.Draft.PrerequisiteTipIds ?? new List<string>();\r\n\r\n\t/// <summary>What the prerequisite field reads at rest. The list underneath ADDS one; the chips below the\r\n\t/// row remove them, which is the only honest shape for a field that holds several values.</summary>\r\n\tstring PrereqFace\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tvar have = PrerequisiteList;\r\n\t\t\tif ( have.Count == 0 )\r\n\t\t\t\treturn \"none\";\r\n\r\n\t\t\treturn have.Count == 1 ? have[0] : $\"{have.Count} tips\";\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Catalog ids this tip could wait on: everything except itself, the preview id, and the ones it\r\n\t/// already waits on.</summary>\r\n\tList<string> AvailablePrerequisites\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tvar have = new HashSet<string>( PrerequisiteList, StringComparer.Ordinal );\r\n\t\t\tvar mine = TipsStudio.Draft.Id ?? \"\";\r\n\t\t\treturn TipsCatalog.Active\r\n\t\t\t\t.Select( t => t.Id )\r\n\t\t\t\t.Where( id => id != mine && id != TipsStudio.PreviewId && !have.Contains( id ) )\r\n\t\t\t\t.OrderBy( id => id, StringComparer.Ordinal )\r\n\t\t\t\t.ToList();\r\n\t\t}\r\n\t}\r\n\r\n\tvoid AddPrerequisite( string id )\r\n\t{\r\n\t\tTipsStudio.Draft.PrerequisiteTipIds.Add( id );\r\n\t\t_openDrop = null;\r\n\t\tRefresh();\r\n\t}\r\n\r\n\tvoid RemovePrerequisite( string id )\r\n\t{\r\n\t\tTipsStudio.Draft.PrerequisiteTipIds.Remove( id );\r\n\t\tRefresh();\r\n\t}\r\n\r\n\t// ---- the two trigger pickers ----\r\n\r\n\t/// <summary>The Completion and Relevance pickers as data, so ONE block of markup renders both. A\r\n\t/// RenderFragment would be the other way to share it, and RenderFragments under-measure here.</summary>\r\n\tpublic struct TriggerSlot\r\n\t{\r\n\t\tpublic string Key;\r\n\t\tpublic string Title;\r\n\t\tpublic string Blurb;\r\n\t\tpublic TipStudioTrigger Trigger;\r\n\t\tpublic bool IsCompletion;\r\n\t\tpublic bool NeedsName;\r\n\t\tpublic string NameHint;\r\n\t\tpublic bool IsComposite;\r\n\t\tpublic string EmptyCompositeHint;\r\n\t}\r\n\r\n\tList<TriggerSlot> TriggerSlots\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tvar completion = TipsStudio.Draft.Completion ??= new TipStudioTrigger();\r\n\t\t\tvar relevance = TipsStudio.Draft.Relevance ??= new TipStudioTrigger();\r\n\r\n\t\t\treturn new List<TriggerSlot>\r\n\t\t\t{\r\n\t\t\t\tSlot( \"completion\", \"COMPLETION TRIGGER\", \"what retires this tip\", completion, true ),\r\n\t\t\t\tSlot( \"relevance\", \"RELEVANCE TRIGGER\", \"an extra gate before it shows\", relevance, false ),\r\n\t\t\t};\r\n\t\t}\r\n\t}\r\n\r\n\tstatic TriggerSlot Slot( string key, string title, string blurb, TipStudioTrigger trigger, bool isCompletion )\r\n\t{\r\n\t\tvar kind = trigger.Kind;\r\n\t\tvar needsName = kind == TipTriggerKind.Signal || kind == TipTriggerKind.Ever\r\n\t\t\t|| kind == TipTriggerKind.Flag || kind == TipTriggerKind.AtLeast;\r\n\r\n\t\tvar hint = kind switch\r\n\t\t{\r\n\t\t\tTipTriggerKind.Signal => \"Signal(...) string\",\r\n\t\t\tTipTriggerKind.Ever => \"ctx.Ever(...)\",\r\n\t\t\tTipTriggerKind.Flag => \"ctx.SetFlag(...)\",\r\n\t\t\t_ => \"ctx.SetNumber(...)\",\r\n\t\t};\r\n\r\n\t\treturn new TriggerSlot\r\n\t\t{\r\n\t\t\tKey = key,\r\n\t\t\tTitle = title,\r\n\t\t\tBlurb = blurb,\r\n\t\t\tTrigger = trigger,\r\n\t\t\tIsCompletion = isCompletion,\r\n\t\t\tNeedsName = needsName,\r\n\t\t\tNameHint = hint,\r\n\t\t\tIsComposite = kind == TipTriggerKind.AnyOf || kind == TipTriggerKind.AllOf,\r\n\t\t\tEmptyCompositeHint = isCompletion && kind == TipTriggerKind.AllOf\r\n\t\t\t\t? \"empty: the shape a TipTriggerObject retires\"\r\n\t\t\t\t: \"empty, so it never fires\",\r\n\t\t};\r\n\t}\r\n\r\n\tvoid SetKind( TipStudioTrigger trigger, TipTriggerKind kind )\r\n\t{\r\n\t\ttrigger.Kind = kind;\r\n\t\t_openDrop = null;\r\n\t\tRefresh();\r\n\t}\r\n\r\n\tstatic string ActionFace( TipStudioTrigger trigger )\r\n\t\t=> string.IsNullOrEmpty( trigger.Action ) ? \"pick an action\" : trigger.Action;\r\n\r\n\tvoid PickAction( TipStudioTrigger trigger, string action )\r\n\t{\r\n\t\ttrigger.Action = action;\r\n\t\t_openDrop = null;\r\n\t\tRefresh();\r\n\t}\r\n\r\n\tvoid SetSource( TipStudioTrigger trigger, TipTriggerAnalogSource source )\r\n\t{\r\n\t\ttrigger.AnalogSource = source;\r\n\t\tRefresh();\r\n\t}\r\n\r\n\tvoid BumpThreshold( TipStudioTrigger trigger, float delta )\r\n\t{\r\n\t\ttrigger.Threshold = MathF.Max( 0f, trigger.Threshold + delta );\r\n\t\tRefresh();\r\n\t}\r\n\r\n\tvoid BumpSeconds( TipStudioTrigger trigger, float delta )\r\n\t{\r\n\t\ttrigger.Seconds = MathF.Max( 0f, trigger.Seconds + delta );\r\n\t\tRefresh();\r\n\t}\r\n\r\n\tvoid BumpMagnitude( TipStudioTrigger trigger, float delta )\r\n\t{\r\n\t\ttrigger.Magnitude = Math.Clamp( trigger.Magnitude + delta, 0f, 1f );\r\n\t\tRefresh();\r\n\t}\r\n\r\n\tvoid AddChild( TipStudioTrigger parent )\r\n\t{\r\n\t\tparent.Children.Add( new TipStudioTrigger { Kind = TipTriggerKind.Key } );\r\n\t\tRefresh();\r\n\t}\r\n\r\n\tvoid RemoveChild( TipStudioTrigger parent, TipStudioTrigger child )\r\n\t{\r\n\t\tparent.Children.Remove( child );\r\n\t\tRefresh();\r\n\t}\r\n\r\n\t/// <summary>A composed child edits its one parameter through a single box, whichever box its kind reads.\r\n\t/// Nesting a full picker per child would triple the panel for a case the format barely uses.</summary>\r\n\tstatic string ChildValue( TipStudioTrigger child ) => child.Kind switch\r\n\t{\r\n\t\tTipTriggerKind.InputAction => child.Action,\r\n\t\tTipTriggerKind.Key => child.Key,\r\n\t\tTipTriggerKind.Signal or TipTriggerKind.Ever or TipTriggerKind.Flag or TipTriggerKind.AtLeast => child.Name,\r\n\t\tTipTriggerKind.Timer => child.Seconds.ToString( \"0.##\" ),\r\n\t\tTipTriggerKind.AnalogAxis => child.Magnitude.ToString( \"0.##\" ),\r\n\t\t_ => \"\",\r\n\t};\r\n\r\n\tstatic void SetChildValue( TipStudioTrigger child, string value )\r\n\t{\r\n\t\tswitch ( child.Kind )\r\n\t\t{\r\n\t\t\tcase TipTriggerKind.InputAction: child.Action = value; break;\r\n\t\t\tcase TipTriggerKind.Key: child.Key = value; break;\r\n\t\t\tcase TipTriggerKind.Signal:\r\n\t\t\tcase TipTriggerKind.Ever:\r\n\t\t\tcase TipTriggerKind.Flag:\r\n\t\t\tcase TipTriggerKind.AtLeast: child.Name = value; break;\r\n\t\t\tcase TipTriggerKind.Timer:\r\n\t\t\t\tif ( float.TryParse( value, out var seconds ) ) child.Seconds = MathF.Max( 0f, seconds );\r\n\t\t\t\tbreak;\r\n\t\t\tcase TipTriggerKind.AnalogAxis:\r\n\t\t\t\tif ( float.TryParse( value, out var magnitude ) ) child.Magnitude = Math.Clamp( magnitude, 0f, 1f );\r\n\t\t\t\tbreak;\r\n\t\t}\r\n\t}\r\n\r\n\tstatic string ChildHint( TipStudioTrigger child ) => TipStudioTrigger.FieldFor( child.Kind ) switch\r\n\t{\r\n\t\t\"action\" => \"an input action name\",\r\n\t\t\"key\" => \"a raw key name\",\r\n\t\t\"name\" => \"the named condition\",\r\n\t\t\"name+threshold\" => \"the named number\",\r\n\t\t\"seconds\" => \"seconds\",\r\n\t\t\"stick+magnitude\" => \"magnitude, 0 to 1\",\r\n\t\t\"children\" => \"nest one level only\",\r\n\t\t_ => \"this kind takes no value\",\r\n\t};\r\n\r\n\t// ---- the two preview cards ----\r\n\r\n\t/// <summary>One rendered card in the rail: the label above it and the runs inside it. Finished data, so\r\n\t/// the markup walks a list rather than calling into the parser mid-tree.</summary>\r\n\tpublic struct PreviewCard\r\n\t{\r\n\t\tpublic string Label;\r\n\t\tpublic string Icon;\r\n\t\tpublic List<TipSegment> Segments;\r\n\t\tpublic bool Last;\r\n\t}\r\n\r\n\t/// <summary>The draft as the player will read it on each device, side by side. The pad card runs the same\r\n\t/// keycap remap the shipped display does (TipsCoach.PadLabelFor), so a chip with no controller equivalent\r\n\t/// disappears here exactly as it would in the game.</summary>\r\n\tList<PreviewCard> PreviewCards\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tvar text = TipsStudio.Draft.Text ?? \"\";\r\n\t\t\tvar pad = TipsStudio.Draft.TextPad ?? \"\";\r\n\t\t\tvar icon = TipsStudio.Draft.Icon ?? \"\";\r\n\r\n\t\t\treturn new List<PreviewCard>\r\n\t\t\t{\r\n\t\t\t\tnew PreviewCard\r\n\t\t\t\t{\r\n\t\t\t\t\tLabel = \"KEYBOARD\",\r\n\t\t\t\t\tIcon = icon,\r\n\t\t\t\t\tSegments = TipSegment.Parse( text ).ToList(),\r\n\t\t\t\t},\r\n\t\t\t\tnew PreviewCard\r\n\t\t\t\t{\r\n\t\t\t\t\tLabel = \"CONTROLLER\",\r\n\t\t\t\t\tIcon = icon,\r\n\t\t\t\t\tSegments = PadRuns( text, pad ),\r\n\t\t\t\t\tLast = true,\r\n\t\t\t\t},\r\n\t\t\t};\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>The pad-mode runs for a wording: its pad text when authored, then every keycap put through\r\n\t/// the game's pad label map. A mapped label reads as a controller chip; an unmappable one is dropped, the\r\n\t/// same two rules the shipped card follows.</summary>\r\n\tstatic List<TipSegment> PadRuns( string text, string textPad )\r\n\t{\r\n\t\tvar runs = new List<TipSegment>();\r\n\r\n\t\tforeach ( var seg in TipSegment.Parse( TipDeviceText.PadTextOr( text, textPad ) ) )\r\n\t\t{\r\n\t\t\tif ( seg.Kind != TipSegmentKind.Key )\r\n\t\t\t{\r\n\t\t\t\truns.Add( seg );\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\tvar mapped = TipDeviceText.PadCap( seg.Text, TipsCoach.PadLabelFor );\r\n\t\t\tif ( string.IsNullOrEmpty( mapped ) )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\truns.Add( new TipSegment( mapped, mapped == seg.Text ? TipSegmentKind.Key : TipSegmentKind.GamepadButton ) );\r\n\t\t}\r\n\r\n\t\treturn runs;\r\n\t}\r\n\r\n\t// ---- preview, test fire ----\r\n\r\n\tList<string> ActionNames => TipsStudio.ActionNames.ToList();\r\n\r\n\t/// <summary>One authoring note, already broken into lines that fit.</summary>\r\n\tpublic struct NoteBlock\r\n\t{\r\n\t\tpublic List<string> Lines;\r\n\t}\r\n\r\n\tList<NoteBlock> NoteBlocks => TipsStudio.Notes\r\n\t\t.Select( n => new NoteBlock { Lines = Lines( n ) } )\r\n\t\t.ToList();\r\n\r\n\t/// <summary>An option list longer than this scrolls at a fixed height instead of growing the column.</summary>\r\n\tstatic string Tall( int count ) => count > 6 ? \"tall\" : \"\";\r\n\r\n\t/// <summary>\r\n\t/// Chunk a sentence into lines short enough to lay out as text. A run that overflows its box does not\r\n\t/// wrap here: the style engine rasterizes it as a solid grey block, or drops it to an empty box. 46\r\n\t/// characters is one comfortable line in the widest box this panel has, and it is the same ceiling\r\n\t/// TipStudioText warns tip authors about.\r\n\t/// </summary>\r\n\tstatic List<string> Lines( string text )\r\n\t{\r\n\t\tvar lines = new List<string>();\r\n\t\tif ( string.IsNullOrWhiteSpace( text ) )\r\n\t\t\treturn lines;\r\n\r\n\t\tvar line = \"\";\r\n\r\n\t\tforeach ( var word in text.Split( ' ' ) )\r\n\t\t{\r\n\t\t\tif ( string.IsNullOrEmpty( word ) )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tif ( line.Length == 0 )\r\n\t\t\t\tline = word;\r\n\t\t\telse if ( line.Length + 1 + word.Length > 46 )\r\n\t\t\t{\r\n\t\t\t\tlines.Add( line );\r\n\t\t\t\tline = word;\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t\tline = line + \" \" + word;\r\n\t\t}\r\n\r\n\t\tif ( line.Length > 0 )\r\n\t\t\tlines.Add( line );\r\n\r\n\t\treturn lines;\r\n\t}\r\n\r\n\tvoid TogglePreview()\r\n\t{\r\n\t\tif ( TipsStudio.PreviewOn )\r\n\t\t\tTipsStudio.StopPreview( Scene );\r\n\t\telse\r\n\t\t\tTipsStudio.PushPreview( Scene );\r\n\r\n\t\tRefresh();\r\n\t}\r\n\r\n\tvoid PinDevice( TipDevice? device )\r\n\t{\r\n\t\tTipsStudio.PinnedDevice = device;\r\n\t\tRefresh();\r\n\t}\r\n\r\n\tvoid TestFire()\r\n\t{\r\n\t\tTipsStudio.TestFire( Scene );\r\n\t\tRefresh();\r\n\t}\r\n\r\n\tvoid CompleteNow()\r\n\t{\r\n\t\tTipsStudio.CompleteNow();\r\n\t\tRefresh();\r\n\t}\r\n\r\n\t/// <summary>The one-line status under the bake buttons: whatever the last bake action said, or what\r\n\t/// is waiting in the staging folder when it has said nothing yet.</summary>\r\n\tstring StatusLine\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tvar text = string.IsNullOrEmpty( _stageLine ) ? StagedLine : _stageLine;\r\n\t\t\tvar lines = Lines( text );\r\n\t\t\treturn lines.Count == 0 ? \"\" : lines[0];\r\n\t\t}\r\n\t}\r\n\r\n\t// ---- bake ----\r\n\r\n\tstring BakeTarget\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tvar file = TipStudioJson.FileNameFor( TipsStudio.Draft.Id );\r\n\t\t\treturn file is null\r\n\t\t\t\t? \"Give the tip an id: the file takes its name.\"\r\n\t\t\t\t: $\"writes Assets/tips/{file}\";\r\n\t\t}\r\n\t}\r\n\r\n\tstring StagedLine\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tvar count = TipsStudio.StagedCount;\r\n\t\t\treturn count == 0 ? \"nothing staged\" : $\"{count} staged for the editor\";\r\n\t\t}\r\n\t}\r\n\r\n\tvoid CopyJson()\r\n\t{\r\n\t\tTipsStudio.CopyJson();\r\n\t\t_copyLabel = \"Copied!\";\r\n\t\tRefresh();\r\n\t}\r\n\r\n\tvoid Stage()\r\n\t{\r\n\t\t_stageLine = TipsStudio.Stage();\r\n\t\tRefresh();\r\n\t}\r\n\r\n\tvoid ClearStaged()\r\n\t{\r\n\t\t_stageLine = TipsStudio.ClearStaged();\r\n\t\tRefresh();\r\n\t}\r\n\r\n\tvoid ResetLabels()\r\n\t{\r\n\t\t_copyLabel = \"Copy .tip JSON\";\r\n\t\t_stageLine = \"\";\r\n\t}\r\n\r\n\t// ---- open / close, boot, cursor ----\r\n\r\n\t/// <summary>Bump the panel's own revision so the next frame rebuilds it. Every click calls this; typing\r\n\t/// into the id box does NOT, because a rebuild would take the cursor out of the box you are typing in.</summary>\r\n\tvoid Refresh() => _revision++;\r\n\r\n\t/// <summary>Enter in a text box: push the wording at the preview card and refresh everything derived\r\n\t/// from it.</summary>\r\n\tvoid Commit()\r\n\t{\r\n\t\tif ( TipsStudio.PreviewOn )\r\n\t\t\tTipsStudio.PushPreview( Scene );\r\n\r\n\t\tRefresh();\r\n\t}\r\n\r\n\tvoid Close()\r\n\t{\r\n\t\tTipsStudio.StopPreview( Scene );\r\n\t\tTipsStudio.Open = false;\r\n\t\t_openDrop = null;\r\n\t\tResetLabels();\r\n\t}\r\n\r\n\tprotected override void OnTreeBuilt()\r\n\t{\r\n\t\t// A background press-drag over a scrolling region must not pan the content or eat a button click; the\r\n\t\t// wheel and the scrollbar still scroll.\r\n\t\tif ( ListBody is not null )\r\n\t\t\tListBody.CanDragScroll = false;\r\n\r\n\t\tif ( MidBody is not null )\r\n\t\t\tMidBody.CanDragScroll = false;\r\n\r\n\t\tif ( RailBody is not null )\r\n\t\t\tRailBody.CanDragScroll = false;\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\t// BOOT. `fg_tips_studio` is a convar and s&box persists convars across sessions, so a session could\r\n\t\t// otherwise come up with an authoring panel open from a value set weeks ago. This component's own\r\n\t\t// OpenOnStart decides the boot state and the persisted value never does. Deliberately in the FIRST\r\n\t\t// UPDATE, not OnStart: a panel created in code is configured by whatever created it, and OnStart would\r\n\t\t// race that assignment.\r\n\t\tif ( !_booted )\r\n\t\t{\r\n\t\t\t_booted = true;\r\n\t\t\tTipsStudio.Open = OpenOnStart;\r\n\t\t}\r\n\r\n\t\t// The Studio is an authoring tool; a published build has nothing to author with it.\r\n\t\tif ( EditorOnly && !Application.IsEditor )\r\n\t\t{\r\n\t\t\tTipsStudio.Open = false;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// Opens only. Closing is the header \u00d7 or the convar, so this key can never fight a text box.\r\n\t\tif ( !TipsStudio.Open && !string.IsNullOrEmpty( OpenKey ) && Input.Keyboard.Pressed( OpenKey ) )\r\n\t\t{\r\n\t\t\tTipsStudio.Open = true;\r\n\t\t\tRefresh();\r\n\t\t}\r\n\r\n\t\tif ( TipsStudio.Open )\r\n\t\t{\r\n\t\t\tMouse.Visibility = MouseVisibility.Visible;\r\n\t\t\t_wasOpen = true;\r\n\t\t}\r\n\t\telse if ( _wasOpen )\r\n\t\t{\r\n\t\t\t_wasOpen = false;\r\n\t\t\tResetLabels();\r\n\t\t\tTipsStudio.StopPreview( Scene );\r\n\t\t}\r\n\t}\r\n\r\n\tprotected override void OnDestroy()\r\n\t{\r\n\t\t// Everything the Studio pins is static and would otherwise follow the developer into the next scene:\r\n\t\t// the preview draft, a test-fire draft, and the pinned preview device.\r\n\t\tTipsStudio.Shutdown();\r\n\t}\r\n\r\n\t// Fold the things a CLICK changes, and nothing anyone TYPES into. A rebuild rehomes every TextEntry, which\r\n\t// takes the cursor out of the box mid-word, so Id and a trigger's Key and Name are deliberately absent:\r\n\t// the panel repaints when you press Enter or click, via _revision.\r\n\tprotected override int BuildHash()\r\n\t{\r\n\t\tvar hc = new HashCode();\r\n\t\thc.Add( TipsStudio.Open );\r\n\t\thc.Add( TipsStudio.OpenedFrom );\r\n\t\thc.Add( TipsStudio.PreviewOn );\r\n\t\thc.Add( TipsStudio.PinnedDevice );\r\n\t\thc.Add( TipsCoach.ActiveTip?.Id );\r\n\t\thc.Add( _copyLabel );\r\n\t\thc.Add( _stageLine );\r\n\t\thc.Add( _openDrop );\r\n\t\thc.Add( _revision );\r\n\t\thc.Add( TipsStudio.Draft.Priority );\r\n\t\thc.Add( TipsStudio.Draft.MaxShowSeconds );\r\n\t\thc.Add( PrerequisiteList.Count );\r\n\r\n\t\tforeach ( var slot in TriggerSlots )\r\n\t\t{\r\n\t\t\thc.Add( slot.Trigger.Kind );\r\n\t\t\thc.Add( slot.Trigger.Action );\r\n\t\t\thc.Add( slot.Trigger.Threshold );\r\n\t\t\thc.Add( slot.Trigger.Seconds );\r\n\t\t\thc.Add( slot.Trigger.AnalogSource );\r\n\t\t\thc.Add( slot.Trigger.Magnitude );\r\n\t\t\thc.Add( slot.Trigger.Children.Count );\r\n\t\t\tforeach ( var child in slot.Trigger.Children )\r\n\t\t\t\thc.Add( child.Kind );\r\n\t\t}\r\n\r\n\t\treturn hc.ToHashCode();\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "fieldguide.tips",
            "Path": "Code/TipsWorld.cs",
            "FileName": "TipsWorld.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 337872,
            "Code": "using System;\r\nusing Sandbox;\r\n\r\nnamespace FieldGuide.Tips;\r\n\r\n/// <summary>\r\n/// Optional world seams a game sets once at bootstrap so world-anchored triggers can read the local\r\n/// player. A library cannot reach into a game's player, so these stay null until the game wires them.\r\n///\r\n/// All are fail-inert: a trigger that needs an unset seam is simply INERT (it never fires and never\r\n/// throws), so nothing crashes when a game skips them. Input, Signal and Timer triggers need no seam at\r\n/// all. Set the seams you use, leave the rest null.\r\n///\r\n/// <code>\r\n/// using Sandbox;\r\n/// using FieldGuide.Tips;\r\n///\r\n/// TipsWorld.LocalPlayerPosition = () => MyLocalPlayer.WorldPosition;\r\n/// TipsWorld.AimRay = () => new Ray( MyCamera.WorldPosition, MyCamera.WorldRotation.Forward );\r\n/// </code>\r\n/// </summary>\r\npublic static class TipsWorld\r\n{\r\n\t/// <summary>Whether a local player exists to coach. Read by the self-driving coach when no context is\r\n\t/// pushed. Default: always true, so input-only tips show without any bootstrap wiring.</summary>\r\n\tpublic static Func<bool> HasLocalPlayer { get; set; } = static () => true;\r\n\r\n\t/// <summary>Local player world position, for <see cref=\"TipTriggerObject.Mode.PlayerEntered\"/> triggers.\r\n\t/// Null leaves those triggers inert.</summary>\r\n\tpublic static Func<Vector3> LocalPlayerPosition { get; set; }\r\n\r\n\t/// <summary>Camera / aim ray, for <see cref=\"TipTriggerObject.Mode.LookedAt\"/> triggers. Null leaves\r\n\t/// those triggers inert.</summary>\r\n\tpublic static Func<Ray> AimRay { get; set; }\r\n}\r\n"
        },
        {
            "Ident": "fieldguide.tips",
            "Path": "Studio/TipsStudio.cs",
            "FileName": "TipsStudio.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 337872,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing Sandbox;\r\n\r\nnamespace FieldGuide.Tips;\r\n\r\n/// <summary>\r\n/// The Tips Studio's state and every action its panel takes. The panel\r\n/// (<see cref=\"TipsStudioPanel\"/>) is markup over this; everything that decides something lives here, so the\r\n/// razor stays readable and this stays testable by eye.\r\n///\r\n/// WHAT IT IS. An authoring surface for tips that runs inside your game: list the merged catalog, open any\r\n/// tip in an editor, watch the real card change as you type, fire the tip and its completion for real, and\r\n/// bake the result out as a <c>.tip</c> file. Nothing here is part of a shipped game's runtime: the panel\r\n/// only exists if you add it, it starts closed, and <c>fg_tips_studio</c> is off by default.\r\n///\r\n/// TWO WAYS A DRAFT REACHES THE COACH, and they are deliberately different:\r\n/// <list type=\"bullet\">\r\n/// <item>PREVIEW registers the draft under <see cref=\"PreviewId\"/>, an id nothing else uses, and force-shows\r\n/// it. It is a picture of the card. It cannot be shadowed by a real tip with the same id, which is what would\r\n/// happen if it registered under the draft's own id (drafts are the lowest-precedence source), and it cannot\r\n/// mark anything complete.</item>\r\n/// <item>TEST FIRE registers the draft under its OWN id and shows it, so completing it retires the real tip\r\n/// and the chain advances the way it will in the game. If a code or asset tip already owns that id, that one\r\n/// wins, which is correct: you are testing the chain, not the draft.</item>\r\n/// </list>\r\n///\r\n/// CLEAN-UP. Everything it touches is static and would otherwise outlive the scene: the preview draft, the\r\n/// test-fire draft, and the pinned preview device. <see cref=\"Shutdown\"/> hands all of it back, and the panel\r\n/// calls it from OnDestroy.\r\n/// </summary>\r\npublic static class TipsStudio\r\n{\r\n\t/// <summary>The id the live preview registers under. Long and namespaced on purpose: it must never\r\n\t/// collide with a real tip, because a collision would silently show the real tip instead of the draft.</summary>\r\n\tpublic const string PreviewId = \"fg_tips_studio_preview\";\r\n\r\n\t/// <summary>Folder under <c>FileSystem.Data</c> the Studio stages baked tips in, for the editor menu\r\n\t/// action to pick up. Staging through a file rather than a live static is what lets you bake in play and\r\n\t/// write the asset after you have stopped playing.</summary>\r\n\tpublic const string StageFolder = \"fieldguide_tips_studio\";\r\n\r\n\t// ------------------------------------------------------------------\r\n\t// Open / close\r\n\t// ------------------------------------------------------------------\r\n\r\n\tprivate static bool _open;\r\n\r\n\t/// <summary>Open or close the Tips Studio. Off by default, and the panel forces it off at boot: s&amp;box\r\n\t/// persists convars between sessions, so without that a value set weeks ago would open an authoring panel\r\n\t/// over someone's game.</summary>\r\n\t[ConVar( \"fg_tips_studio\", Help = \"Open or close the Tips Studio authoring panel (dev tool, off by default)\" )]\r\n\tpublic static bool Open\r\n\t{\r\n\t\tget => _open;\r\n\t\tset => _open = value;\r\n\t}\r\n\r\n\t/// <summary>Which tab is showing.</summary>\r\n\tpublic static StudioTab Tab { get; set; } = StudioTab.Tips;\r\n\r\n\t/// <summary>The Studio's three tabs.</summary>\r\n\tpublic enum StudioTab\r\n\t{\r\n\t\t/// <summary>The merged catalog, with source labels.</summary>\r\n\t\tTips,\r\n\r\n\t\t/// <summary>The draft editor: wording, order, triggers, preview and test fire.</summary>\r\n\t\tDraft,\r\n\r\n\t\t/// <summary>The bake-out surface: the .tip JSON, Copy, and staging for the editor.</summary>\r\n\t\tBake,\r\n\t}\r\n\r\n\t// ------------------------------------------------------------------\r\n\t// The draft\r\n\t// ------------------------------------------------------------------\r\n\r\n\tprivate static TipStudioDraft _draft = new();\r\n\r\n\t/// <summary>The tip being authored. Never null.</summary>\r\n\tpublic static TipStudioDraft Draft\r\n\t{\r\n\t\tget => _draft ??= new TipStudioDraft();\r\n\t\tset => _draft = value ?? new TipStudioDraft();\r\n\t}\r\n\r\n\t/// <summary>The catalog id the draft was opened from, or null for a new tip. Shown so it is obvious\r\n\t/// whether you are editing something that already exists.</summary>\r\n\tpublic static string OpenedFrom { get; private set; }\r\n\r\n\t/// <summary>Start a new, empty tip.</summary>\r\n\tpublic static void NewDraft()\r\n\t{\r\n\t\tDraft = new TipStudioDraft { Priority = 100 };\r\n\t\tOpenedFrom = null;\r\n\t}\r\n\r\n\t/// <summary>Open a catalog tip in the editor. The two code-only predicates have no authored form and are\r\n\t/// dropped; <see cref=\"DroppedPredicates\"/> says so on screen.</summary>\r\n\tpublic static void OpenTip( string id )\r\n\t{\r\n\t\tvar def = TipsCatalog.Active.FirstOrDefault( t => t.Id == id );\r\n\t\tif ( def is null )\r\n\t\t\treturn;\r\n\r\n\t\tDraft = TipStudioDraft.FromDefinition( def );\r\n\t\tOpenedFrom = id;\r\n\t\tDroppedPredicates = HasCodePredicates( def );\r\n\t\tTab = StudioTab.Draft;\r\n\t}\r\n\r\n\t/// <summary>True when the tip currently open was carrying a <c>Trigger</c> or <c>CompleteWhen</c>\r\n\t/// predicate, which a <c>.tip</c> file cannot hold. Baking it out keeps the declarative triggers and\r\n\t/// loses the predicate, so the panel warns before you do.</summary>\r\n\tpublic static bool DroppedPredicates { get; private set; }\r\n\r\n\tprivate static bool HasCodePredicates( TipDefinition def )\r\n\t{\r\n\t\t// A tip that never set them carries the record's defaults. Comparing against a fresh default is the\r\n\t\t// only way to tell \"the author wrote a predicate\" from \"the record filled one in\".\r\n\t\tvar plain = new TipDefinition { Id = \"probe\", Text = \"\" };\r\n\t\treturn def.Trigger != plain.Trigger || def.CompleteWhen != plain.CompleteWhen;\r\n\t}\r\n\r\n\t/// <summary>The authoring notes for the current draft (grey-block run lengths, triggers that can never\r\n\t/// fire). Recomputed on read; the panel refreshes them when you press Enter in a box or click anything,\r\n\t/// because rebuilding the panel while you type would take the cursor out of the box.</summary>\r\n\tpublic static IReadOnlyList<string> Notes => TipStudioText.Warnings( Draft );\r\n\r\n\t/// <summary>The draft as <c>.tip</c> JSON: what Copy puts on the clipboard and what a bake writes.</summary>\r\n\tpublic static string Json => TipStudioJson.Write( Draft );\r\n\r\n\t/// <summary>True when the draft's id already names a tip from a HIGHER-precedence source, so a bake would\r\n\t/// be shadowed until that source lets go. Worth saying out loud before someone wonders why their new file\r\n\t/// does nothing.</summary>\r\n\tpublic static string ShadowedBy\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tif ( string.IsNullOrWhiteSpace( Draft.Id ) )\r\n\t\t\t\treturn null;\r\n\r\n\t\t\tvar source = TipsCatalog.SourceOf( Draft.Id );\r\n\t\t\treturn source == \"code\" ? \"code\" : null;\r\n\t\t}\r\n\t}\r\n\r\n\t// ------------------------------------------------------------------\r\n\t// Live preview\r\n\t// ------------------------------------------------------------------\r\n\r\n\t/// <summary>Whether the real card is mirroring the draft right now.</summary>\r\n\tpublic static bool PreviewOn { get; private set; }\r\n\r\n\t/// <summary>Push the draft onto the real card, or refresh what is already there. Registers under\r\n\t/// <see cref=\"PreviewId\"/> so a draft of an existing tip is not shadowed by the tip it copies.</summary>\r\n\tpublic static void PushPreview( Scene scene )\r\n\t{\r\n\t\tvar def = Draft.ToDefinition();\r\n\r\n\t\t// The preview stands in for the draft even before it has an id, so an author sees the card from the\r\n\t\t// first character typed rather than after they remember to name it.\r\n\t\tvar preview = new TipDefinition\r\n\t\t{\r\n\t\t\tId = PreviewId,\r\n\t\t\tText = Draft.Text ?? \"\",\r\n\t\t\tTextPad = string.IsNullOrEmpty( Draft.TextPad ) ? null : Draft.TextPad,\r\n\t\t\tIcon = Draft.Icon ?? \"\",\r\n\t\t\tPriority = def?.Priority ?? 0,\r\n\t\t};\r\n\r\n\t\tTipsCatalog.RegisterRuntime( preview );\r\n\t\tPreviewOn = true;\r\n\r\n\t\tvar coach = LiveCoach( scene );\r\n\t\tcoach?.ForceShow( PreviewId );\r\n\t}\r\n\r\n\t/// <summary>Take the preview off the card and out of the catalog.</summary>\r\n\tpublic static void StopPreview( Scene scene )\r\n\t{\r\n\t\tPreviewOn = false;\r\n\t\tTipsCatalog.UnregisterRuntime( PreviewId );\r\n\t\tTipsCoach.PreviewDevice = null;\r\n\r\n\t\t// The card may still be showing a tip that no longer exists. Drop it rather than dismiss it: dismissing\r\n\t\t// would write the fake preview id into the player's saved progress and leave it there for good.\r\n\t\tif ( TipsCoach.ActiveTip?.Id == PreviewId )\r\n\t\t\tLiveCoach( scene )?.DropActive();\r\n\t}\r\n\r\n\t/// <summary>Which device the preview card is pinned to, or null for whatever the player last used.</summary>\r\n\tpublic static TipDevice? PinnedDevice\r\n\t{\r\n\t\tget => TipsCoach.PreviewDevice;\r\n\t\tset => TipsCoach.PreviewDevice = value;\r\n\t}\r\n\r\n\t// ------------------------------------------------------------------\r\n\t// Test fire\r\n\t// ------------------------------------------------------------------\r\n\r\n\t/// <summary>\r\n\t/// Make the draft the live tip UNDER ITS OWN ID and show it now. From here its completion is the real\r\n\t/// thing: fire the trigger in the game, or press Complete, and the tip retires and the chain moves on.\r\n\t/// Returns false when the draft has no id yet.\r\n\t/// </summary>\r\n\tpublic static bool TestFire( Scene scene )\r\n\t{\r\n\t\tvar def = Draft.ToDefinition();\r\n\t\tif ( def is null )\r\n\t\t\treturn false;\r\n\r\n\t\t// A test fire of a tip already marked complete would retire the moment it appeared.\r\n\t\tTipsCoach.Uncomplete( def.Id );\r\n\t\tTipsCatalog.UnregisterRuntime( PreviewId );\r\n\t\tPreviewOn = false;\r\n\t\tTipsCatalog.RegisterRuntime( def );\r\n\r\n\t\tvar coach = LiveCoach( scene );\r\n\t\tif ( coach is null )\r\n\t\t{\r\n\t\t\tLog.Warning( \"fg_tips: no TipsCoach in the scene, so there is nothing to show the tip on.\" );\r\n\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\treturn coach.ForceShow( def.Id );\r\n\t}\r\n\r\n\t/// <summary>Fire the draft's completion by hand, the same path a world trigger uses. The tip retires and\r\n\t/// whatever waits on it becomes eligible.</summary>\r\n\tpublic static void CompleteNow()\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( Draft.Id ) )\r\n\t\t\treturn;\r\n\r\n\t\tTipsCoach.Complete( Draft.Id );\r\n\t}\r\n\r\n\tprivate static TipsCoach LiveCoach( Scene scene )\r\n\t\t=> scene?.GetAllComponents<TipsCoach>().FirstOrDefault();\r\n\r\n\t// ------------------------------------------------------------------\r\n\t// Input actions (the action picker)\r\n\t// ------------------------------------------------------------------\r\n\r\n\t/// <summary>\r\n\t/// The project's real input actions, for the InputAction picker: <c>Input.ActionNames</c>, which is the\r\n\t/// engine's list from the current game's input settings, the same list the <c>[InputAction]</c> inspector\r\n\t/// dropdown draws from. Sorted, and empty rather than throwing outside a running game.\r\n\t/// </summary>\r\n\tpublic static IReadOnlyList<string> ActionNames\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\ttry\r\n\t\t\t{\r\n\t\t\t\tvar names = Input.ActionNames?.Where( n => !string.IsNullOrWhiteSpace( n ) ).ToList();\r\n\t\t\t\tif ( names is null || names.Count == 0 )\r\n\t\t\t\t\treturn Array.Empty<string>();\r\n\r\n\t\t\t\tnames.Sort( StringComparer.OrdinalIgnoreCase );\r\n\t\t\t\treturn names;\r\n\t\t\t}\r\n\t\t\tcatch ( Exception )\r\n\t\t\t{\r\n\t\t\t\treturn Array.Empty<string>();\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\t// ------------------------------------------------------------------\r\n\t// Bake out\r\n\t// ------------------------------------------------------------------\r\n\r\n\t/// <summary>Copy the draft's <c>.tip</c> JSON to the system clipboard, from in game. Paste it into a new\r\n\t/// file under your project's <c>Assets/</c> and the editor picks it up as a tip.</summary>\r\n\tpublic static void CopyJson()\r\n\t{\r\n\t\tSandbox.UI.Clipboard.SetText( Json );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Write the draft into <see cref=\"StageFolder\"/> under <c>FileSystem.Data</c>, where the editor menu\r\n\t/// action \"Field Guide / Write staged tips\" picks it up and writes the real asset. Two steps because game\r\n\t/// code cannot write into a project's <c>Assets/</c> folder, and because staging survives the end of the\r\n\t/// play session, so you can author in play and land the file afterwards.\r\n\t/// </summary>\r\n\t/// <returns>A line for the panel saying what happened.</returns>\r\n\tpublic static string Stage()\r\n\t{\r\n\t\tvar file = TipStudioJson.FileNameFor( Draft.Id );\r\n\t\tif ( file is null )\r\n\t\t\treturn \"Give the tip an id first.\";\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\tFileSystem.Data.CreateDirectory( StageFolder );\r\n\t\t\tvar path = $\"{StageFolder}/{file}\";\r\n\t\t\tFileSystem.Data.WriteAllText( path, Json );\r\n\t\t\tLog.Info( $\"fg_tips: staged {file}. In the editor, run Field Guide / Write staged tips to Assets/tips.\" );\r\n\r\n\t\t\t// Short on purpose: this lands in a one-line status slot in the panel, and the console line\r\n\t\t\t// above already carries the full instruction.\r\n\t\t\treturn $\"staged {file}\";\r\n\t\t}\r\n\t\tcatch ( Exception e )\r\n\t\t{\r\n\t\t\tLog.Warning( $\"fg_tips: could not stage {file} ({e.Message}).\" );\r\n\t\t\treturn $\"Could not stage {file}: {e.Message}\";\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>How many tips are waiting in the staging folder, so the panel can say whether there is\r\n\t/// anything for the editor action to do.</summary>\r\n\tpublic static int StagedCount\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\ttry\r\n\t\t\t{\r\n\t\t\t\treturn FileSystem.Data.DirectoryExists( StageFolder )\r\n\t\t\t\t\t? FileSystem.Data.FindFile( StageFolder, \"*.tip\", false ).Count()\r\n\t\t\t\t\t: 0;\r\n\t\t\t}\r\n\t\t\tcatch ( Exception )\r\n\t\t\t{\r\n\t\t\t\treturn 0;\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Empty the staging folder, for when a bake was a mistake or the files have landed.</summary>\r\n\tpublic static string ClearStaged()\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tif ( !FileSystem.Data.DirectoryExists( StageFolder ) )\r\n\t\t\t\treturn \"Nothing staged.\";\r\n\r\n\t\t\tvar cleared = 0;\r\n\t\t\tforeach ( var file in FileSystem.Data.FindFile( StageFolder, \"*.tip\", false ).ToList() )\r\n\t\t\t{\r\n\t\t\t\tFileSystem.Data.DeleteFile( $\"{StageFolder}/{file}\" );\r\n\t\t\t\tcleared++;\r\n\t\t\t}\r\n\r\n\t\t\treturn cleared == 0 ? \"Nothing staged.\" : $\"Cleared {cleared} staged tip(s).\";\r\n\t\t}\r\n\t\tcatch ( Exception e )\r\n\t\t{\r\n\t\t\treturn $\"Could not clear the staging folder: {e.Message}\";\r\n\t\t}\r\n\t}\r\n\r\n\t// ------------------------------------------------------------------\r\n\t// Shutdown\r\n\t// ------------------------------------------------------------------\r\n\r\n\t/// <summary>\r\n\t/// Hand back everything the Studio pinned: the preview and test-fire drafts, and the pinned preview\r\n\t/// device. Called from the panel's OnDestroy, because all of it is static and would otherwise follow the\r\n\t/// developer into the next scene, exactly the trap a scene-registered code catalog falls into.\r\n\t/// </summary>\r\n\tpublic static void Shutdown()\r\n\t{\r\n\t\tPreviewOn = false;\r\n\t\tTipsCoach.PreviewDevice = null;\r\n\t\tTipsCatalog.ClearRuntime();\r\n\t\tOpen = false;\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "fieldguide.tips",
            "Path": "TipsCatalog.cs",
            "FileName": "TipsCatalog.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 337872,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing Sandbox;\r\n\r\nnamespace FieldGuide.Tips;\r\n\r\n/// <summary>\r\n/// The registry the coach reads. Three sources merge into <see cref=\"Active\"/>, keyed by\r\n/// <see cref=\"TipDefinition.Id\"/>, highest precedence first:\r\n/// <list type=\"number\">\r\n/// <item>CODE: whatever the game passed to <see cref=\"Register(IReadOnlyList{TipDefinition})\"/>.</item>\r\n/// <item>ASSETS: every <c>.tip</c> <see cref=\"TipResource\"/> found via\r\n/// <c>ResourceLibrary.GetAll&lt;TipResource&gt;()</c>, mapped through <see cref=\"TipResource.ToDefinition\"/>.</item>\r\n/// <item>DRAFTS: runtime tips injected via <see cref=\"RegisterRuntime(TipDefinition)\"/> (a test-fire / dev\r\n/// tool seam), lowest precedence.</item>\r\n/// </list>\r\n/// On an id collision CODE wins, then the asset, then the draft: a game's own code catalog is authored\r\n/// intent, and assets are usually additive or mod content. This is the inverse of the RPG kit, where\r\n/// authored assets override code demos. When all three sources are empty the shipped <see cref=\"Example\"/>\r\n/// surfaces so the panel still does something before any content is wired in.\r\n///\r\n/// Back-compat: <see cref=\"Register(IReadOnlyList{TipDefinition})\"/> and <see cref=\"Active\"/> keep their\r\n/// v0.2 signatures. A game that only calls Register with no assets and no drafts reads exactly its own\r\n/// list, in its own order, from <see cref=\"Active\"/> just as before.\r\n///\r\n/// LIFETIME. Every source here is STATIC, so a catalog registered from a scene component outlives that\r\n/// scene and that play session: load another scene in the same editor process and the old tips are still\r\n/// the highest-priority thing the coach can pick, with nothing over there able to retire them. Register\r\n/// from a component and you want <see cref=\"RegisterScoped(IReadOnlyList{TipDefinition})\"/>, which hands the\r\n/// previous catalog back when you dispose it in OnDestroy. Runtime drafts have the same rule and the same\r\n/// answer, <see cref=\"ClearRuntime\"/>.\r\n///\r\n/// FRESHNESS. The merged view is DERIVED state: one <see cref=\"TipCatalogView\"/> holding the ordered list\r\n/// and the id-to-source map together, rebuilt whenever a source moves and swapped in whole, so the two can\r\n/// never disagree with each other. Sources announce their own moves: <see cref=\"Register\"/> and the draft calls\r\n/// invalidate directly, and <see cref=\"TipResource\"/> calls <see cref=\"NoteAssetsChanged\"/> from its PostLoad\r\n/// / PostReload, which is what makes an edited <c>.tip</c> reach a running session. <c>fg_tips_rebuild</c> is\r\n/// the manual reset for anything that slips past (a deleted asset, a code hotload).\r\n///\r\n/// Game-specific tip text (which keys open which panels, what the dev overlay does, spell and\r\n/// potion lines) belongs in YOUR catalog, not in the kit. The kit owns the mechanic (priority,\r\n/// prerequisites, trigger, complete-when, timeout); you own the words.\r\n/// </summary>\r\npublic static class TipsCatalog\r\n{\r\n\t// CODE source: the game's own registered catalog (highest precedence).\r\n\tprivate static IReadOnlyList<TipDefinition> _code = Array.Empty<TipDefinition>();\r\n\r\n\t// DRAFT source: runtime-authored tips (lowest precedence), kept in their own store so a draft never\r\n\t// shadows a code or asset tip and a Rebuild can rescan assets without dropping live drafts.\r\n\tprivate static readonly Dictionary<string, TipDefinition> _drafts = new( StringComparer.Ordinal );\r\n\r\n\t// Source revisions. Bumped whenever that source moves; the snapshot records the three it was built from\r\n\t// and a read that finds any of them changed rebuilds. Comparing them is three field reads, no allocation,\r\n\t// which is what lets the coach ask for Active several times a frame.\r\n\tprivate static int _draftRevision;\r\n\tprivate static int _assetRevision;\r\n\r\n\t// The derived view (list + labels together, from the pure TipCatalogMerge) and the stamp saying what it\r\n\t// was built from. One reference, swapped in whole: there is no second static that could disagree with it.\r\n\tprivate static TipCatalogView _view;\r\n\tprivate static TipCatalogStamp _stamp;\r\n\r\n\t/// <summary>\r\n\t/// The merged, deduped walkthrough the coach reads (code + assets + drafts by the precedence above),\r\n\t/// or <see cref=\"Example\"/> when every source is empty. Priority breaks ties; PrerequisiteTipIds\r\n\t/// sequences the spine. Derived and cached against the source revisions, so an edited or newly created\r\n\t/// <c>.tip</c> shows up on the next read; <c>fg_tips_rebuild</c> forces a rescan by hand.\r\n\t/// </summary>\r\n\tpublic static IReadOnlyList<TipDefinition> Active => Current().Tips;\r\n\r\n\t/// <summary>The merged catalog and its source labels together, for a reader that needs both and must not\r\n\t/// see them from two different builds (the Tips Studio's tip list).</summary>\r\n\tpublic static TipCatalogView View => Current();\r\n\r\n\t/// <summary>Install your own tutorial line (the CODE source). Call once at bootstrap; a null or empty\r\n\t/// list is ignored. Additive to any <c>.tip</c> assets and drafts; code wins id collisions.\r\n\t///\r\n\t/// The catalog is static and outlives the scene that registered it. Registering from a component that\r\n\t/// can be destroyed (a scene bootstrap, a dev harness) wants\r\n\t/// <see cref=\"RegisterScoped(IReadOnlyList{TipDefinition})\"/> instead.</summary>\r\n\tpublic static void Register( IReadOnlyList<TipDefinition> tips )\r\n\t{\r\n\t\tif ( tips is null || tips.Count == 0 )\r\n\t\t\treturn;\r\n\t\t_code = tips;\r\n\t\tInvalidate();\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Register a code catalog that HANDS ITSELF BACK. Dispose the returned token (from your component's\r\n\t/// OnDestroy, or with a <c>using</c>) and whatever was registered before is restored, so a scene's tips\r\n\t/// cannot follow the player into the next scene. Disposing twice is a no-op, and disposing out of order\r\n\t/// still restores what this call displaced rather than clearing the catalog outright.\r\n\t///\r\n\t/// This is the seam the kit's own demo bootstrap discipline generalizes: statics outlive scenes, so\r\n\t/// whoever set one puts it back.\r\n\t/// </summary>\r\n\tpublic static IDisposable RegisterScoped( IReadOnlyList<TipDefinition> tips )\r\n\t{\r\n\t\tvar previous = _code;\r\n\t\tRegister( tips );\r\n\t\treturn new ScopedCode( previous, tips );\r\n\t}\r\n\r\n\tprivate sealed class ScopedCode : IDisposable\r\n\t{\r\n\t\tprivate readonly IReadOnlyList<TipDefinition> _previous;\r\n\t\tprivate IReadOnlyList<TipDefinition> _mine;\r\n\r\n\t\tpublic ScopedCode( IReadOnlyList<TipDefinition> previous, IReadOnlyList<TipDefinition> mine )\r\n\t\t{\r\n\t\t\t_previous = previous ?? Array.Empty<TipDefinition>();\r\n\t\t\t_mine = mine;\r\n\t\t}\r\n\r\n\t\tpublic void Dispose()\r\n\t\t{\r\n\t\t\tif ( _mine is null )\r\n\t\t\t\treturn; // already handed back\r\n\r\n\t\t\t// Only restore if OUR list is still the installed one. A later Register replaced us, and stomping\r\n\t\t\t// that would be worse than leaving it: the newer registration is the live intent.\r\n\t\t\tif ( ReferenceEquals( _code, _mine ) )\r\n\t\t\t{\r\n\t\t\t\t_code = _previous;\r\n\t\t\t\tInvalidate();\r\n\t\t\t}\r\n\r\n\t\t\t_mine = null;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Inject (or replace) a live runtime draft tip (the lowest-precedence DRAFT source). An id that also\r\n\t/// names a code tip or an authored <c>.tip</c> asset resolves to that real tip, never the draft. The\r\n\t/// seam a test-fire / dev tool uses so an in-progress tip is previewable without an editor compile.\r\n\t/// </summary>\r\n\tpublic static void RegisterRuntime( TipDefinition def )\r\n\t{\r\n\t\tif ( def is null || string.IsNullOrEmpty( def.Id ) )\r\n\t\t\treturn;\r\n\t\t_drafts[def.Id] = def;\r\n\t\t_draftRevision++;\r\n\t\tInvalidate();\r\n\t}\r\n\r\n\t/// <summary>Remove a draft tip previously injected via <see cref=\"RegisterRuntime(TipDefinition)\"/>.</summary>\r\n\tpublic static void UnregisterRuntime( string id )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( id ) || !_drafts.Remove( id ) )\r\n\t\t\treturn;\r\n\t\t_draftRevision++;\r\n\t\tInvalidate();\r\n\t}\r\n\r\n\t/// <summary>Drop every runtime draft, leaving the code catalog and the <c>.tip</c> assets alone. The\r\n\t/// Tips Studio calls this when it shuts down: an authoring draft must not follow the developer into\r\n\t/// another scene or another session.</summary>\r\n\tpublic static void ClearRuntime()\r\n\t{\r\n\t\tif ( _drafts.Count == 0 )\r\n\t\t\treturn;\r\n\t\t_drafts.Clear();\r\n\t\t_draftRevision++;\r\n\t\tInvalidate();\r\n\t}\r\n\r\n\t/// <summary>The ids of the live runtime drafts, so a dev tool can list or clean up exactly what it put\r\n\t/// in. Ordered for a stable readout.</summary>\r\n\tpublic static IReadOnlyList<string> RuntimeIds\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tvar ids = new List<string>( _drafts.Keys );\r\n\t\t\tids.Sort( StringComparer.Ordinal );\r\n\t\t\treturn ids;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Force a fresh merge (rescans <c>.tip</c> assets). Call after hot-loading new tip assets;\r\n\t/// otherwise the lazy build is enough. Code catalog and live drafts are preserved.</summary>\r\n\tpublic static void Rebuild() => Invalidate();\r\n\r\n\t/// <summary>\r\n\t/// The <c>.tip</c> asset source moved: a tip was loaded for the first time, or recompiled from disk after\r\n\t/// an edit. <see cref=\"TipResource\"/> calls this from its PostLoad / PostReload, which is the engine hook\r\n\t/// a kit can reach (<c>ResourceLibrary.IEventListener</c> is internal), and it is what makes an edited tip\r\n\t/// appear in a running session. Cheap and idempotent: it bumps a counter and drops the derived snapshot.\r\n\t/// </summary>\r\n\tpublic static void NoteAssetsChanged()\r\n\t{\r\n\t\t_assetRevision++;\r\n\t\tInvalidate();\r\n\t}\r\n\r\n\t/// <summary>Rescan <c>.tip</c> assets and rebuild the merged catalog by hand, then print what came back.\r\n\t/// The fallback for anything the automatic hooks cannot see: a deleted asset, or a code hotload that left\r\n\t/// the derived view holding pre-hotload tips.</summary>\r\n\t[ConCmd( \"fg_tips_rebuild\" )]\r\n\tpublic static void RebuildCommand()\r\n\t{\r\n\t\tNoteAssetsChanged();\r\n\t\tvar view = Current();\r\n\t\tvar counts = new Dictionary<string, int>( StringComparer.Ordinal );\r\n\t\tforeach ( var kv in view.SourceById )\r\n\t\t\tcounts[kv.Value] = counts.TryGetValue( kv.Value, out var n ) ? n + 1 : 1;\r\n\r\n\t\tvar parts = new List<string>();\r\n\t\tforeach ( var kv in counts )\r\n\t\t\tparts.Add( $\"{kv.Key}={kv.Value}\" );\r\n\t\tparts.Sort( StringComparer.Ordinal );\r\n\r\n\t\tLog.Info( $\"fg_tips: catalog rebuilt, {view.Tips.Count} tip(s) [{( parts.Count > 0 ? string.Join( \" \", parts ) : \"empty\" )}].\" );\r\n\t}\r\n\r\n\t/// <summary>Clear the code catalog and drafts so <see cref=\"Active\"/> falls back to any authored assets,\r\n\t/// or to the shipped <see cref=\"Example\"/> when there are none (mostly for tests / demos).</summary>\r\n\tpublic static void Reset()\r\n\t{\r\n\t\t_code = Array.Empty<TipDefinition>();\r\n\t\t_drafts.Clear();\r\n\t\t_draftRevision++;\r\n\t\tInvalidate();\r\n\t}\r\n\r\n\tprivate static void Invalidate() => _view = null;\r\n\r\n\t/// <summary>The current derived view, rebuilt when any source has moved since it was made.</summary>\r\n\tprivate static TipCatalogView Current()\r\n\t{\r\n\t\tvar view = _view;\r\n\t\tif ( view is not null && _stamp.Matches( _code, _draftRevision, _assetRevision ) )\r\n\t\t\treturn view;\r\n\r\n\t\t_stamp = new TipCatalogStamp( _code, _draftRevision, _assetRevision );\r\n\t\treturn _view = TipCatalogMerge.Merge( _code, AssetDefinitions(), _drafts.Values, BuildExample() );\r\n\t}\r\n\r\n\tprivate static IEnumerable<TipDefinition> AssetDefinitions()\r\n\t{\r\n\t\tforeach ( var res in LoadAssets() )\r\n\t\t{\r\n\t\t\tvar def = res?.ToDefinition();\r\n\t\t\tif ( def is not null )\r\n\t\t\t\tyield return def;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Which source a tip id resolved from (\"code\" / \"asset\" / \"draft\" / \"example\"), or \"unknown\".\r\n\t/// Used by the <c>fg_tips_list</c> console command and the Tips Studio's tip list, where the label is how\r\n\t/// a tip left over from another scene gives itself away.</summary>\r\n\tpublic static string SourceOf( string id )\r\n\t{\r\n\t\tvar view = Current();\r\n\t\treturn !string.IsNullOrEmpty( id ) && view.SourceById.TryGetValue( id, out var src ) ? src : \"unknown\";\r\n\t}\r\n\r\n\tprivate static IEnumerable<TipResource> LoadAssets()\r\n\t{\r\n\t\t// ResourceLibrary is only meaningful inside a running game/editor; guard so a headless or unit\r\n\t\t// context (no resource system) merges cleanly instead of throwing.\r\n\t\ttry\r\n\t\t{\r\n\t\t\treturn ResourceLibrary.GetAll<TipResource>();\r\n\t\t}\r\n\t\tcatch\r\n\t\t{\r\n\t\t\treturn Array.Empty<TipResource>();\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// A minimal, GENERIC illustration, enough to show every mechanic (a completed-by-behaviour spine,\r\n\t/// a prerequisite chain, a contextual interrupt via <see cref=\"TipDefinition.Trigger\"/>, and a\r\n\t/// timed \"glance\" beat via <see cref=\"TipDefinition.MaxShowSeconds\"/>). It is meant to be REPLACED:\r\n\t/// register your own game's tips with <see cref=\"Register(IReadOnlyList{TipDefinition})\"/>.\r\n\t///\r\n\t/// A computed property, not a <c>static readonly</c> field: a field's initializer runs once, so after a\r\n\t/// code hotload the old list survives and an edit to these tips is invisible until the editor restarts.\r\n\t/// A property is a method, and methods come back fresh from a hotload.\r\n\t/// </summary>\r\n\tpublic static IReadOnlyList<TipDefinition> Example => BuildExample();\r\n\r\n\tprivate static IReadOnlyList<TipDefinition> BuildExample() => new List<TipDefinition>\r\n\t{\r\n\t\t// A calm spine beat that retires when the player performs its action. Note the mixed input\r\n\t\t// markup: *asterisks* render a keyboard keycap, `backticks` render a gamepad button chip, so one\r\n\t\t// line can prompt both control schemes.\r\n\t\tnew()\r\n\t\t{\r\n\t\t\tId = \"move\", Icon = \"\ud83e\udded\", Priority = 100,\r\n\t\t\tText = \"Move with *W* *A* *S* *D* or the `Left Stick`. Hold *Shift* / `LB` to sprint.\",\r\n\t\t\tCompleteWhen = static c => c.EverMoved,\r\n\t\t},\r\n\t\t// A second beat gated behind the first (prerequisite chain).\r\n\t\t// The same beat with pad-specific wording: on a controller the display shows TextPad instead of\r\n\t\t// Text (build plan point 2), so the prompt reads the button the player actually has.\r\n\t\tnew()\r\n\t\t{\r\n\t\t\tId = \"interact\", Icon = \"\ud83d\udcac\", Priority = 90, PrerequisiteTipIds = new[] { \"move\" },\r\n\t\t\tText = \"Walk up to someone and press *E* to interact.\",\r\n\t\t\tTextPad = \"Walk up to someone and press `X` to interact.\",\r\n\t\t\tCompleteWhen = static c => c.EverTalked,\r\n\t\t},\r\n\t\t// A \"just glance\" beat with no behavioural signal, it times out on its own.\r\n\t\tnew()\r\n\t\t{\r\n\t\t\tId = \"look\", Icon = \"\ud83d\uddfa\ufe0f\", Priority = 80, PrerequisiteTipIds = new[] { \"interact\" },\r\n\t\t\tText = \"Look around with the mouse to get your bearings.\",\r\n\t\t\tMaxShowSeconds = 6f,\r\n\t\t},\r\n\t\t// A contextual interrupt: it outranks the calm spine while a fight is live.\r\n\t\tnew()\r\n\t\t{\r\n\t\t\tId = \"combat\", Icon = \"\u2757\", Priority = 130,\r\n\t\t\tText = \"Something's hostile! Attack with *LMB*, and hold *B* to block.\",\r\n\t\t\tTrigger = static c => c.EverAggroed && c.InCombat,\r\n\t\t\tCompleteWhen = static c => !c.InCombat,\r\n\t\t},\r\n\t};\r\n}\r\n"
        },
        {
            "Ident": "fieldguide.tips",
            "Path": "Code/Demo/TipsDemoPawn.cs",
            "FileName": "TipsDemoPawn.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 337872,
            "Code": "using System.Linq;\r\nusing Sandbox;\r\n\r\nnamespace FieldGuide.Tips;\r\n\r\n/// <summary>\r\n/// The demo scene's stand-in player: a citizen that slides along the ground on the movement stick (or\r\n/// W/A/S/D) and hops on the jump action. Deliberately the simplest thing that can be coached: plain\r\n/// transform movement, one hand-integrated hop, no rigidbody, no collider, no controller. It exists so\r\n/// the tips in <c>Assets/demo/</c> have real actions to retire on.\r\n///\r\n/// THE LOOK. The scene authors this object with a plain box renderer. At boot the pawn switches that off\r\n/// and builds a dressed stock citizen in its place (<see cref=\"TipsDemoCitizen\"/>), so the scene file\r\n/// stays as authored and the editor viewport still shows the simple block when nothing is playing. The\r\n/// movement, the play radius and the hop are untouched by the swap: only the visual changed.\r\n///\r\n/// Not part of the kit's runtime surface: delete <c>Code/Demo</c> and <c>Assets/demo</c> when you drop\r\n/// the kit into your own project, and coach your own player instead.\r\n/// </summary>\r\n[Title( \"Tips Demo Pawn\" )]\r\n[Category( \"Field Guide Tips\" )]\r\n[Icon( \"smart_toy\" )]\r\npublic sealed class TipsDemoPawn : Component\r\n{\r\n\t/// <summary>Ground speed in world units per second.</summary>\r\n\t[Property] public float MoveSpeed { get; set; } = 220f;\r\n\r\n\t/// <summary>Upward speed of one hop, in world units per second.</summary>\r\n\t[Property] public float JumpSpeed { get; set; } = 260f;\r\n\r\n\t/// <summary>Downward acceleration applied to a hop, in world units per second squared.</summary>\r\n\t[Property] public float Gravity { get; set; } = 900f;\r\n\r\n\t/// <summary>How far from its start the citizen may wander. The demo camera is fixed, so this is what\r\n\t/// keeps the citizen in frame, and the scene's camera is framed to contain exactly this disc. The\r\n\t/// marker sits 205 units from the start, so 220 lets the citizen walk onto it and a little past\r\n\t/// without opening up a corner of the yard that the camera would then have to cover for nothing.</summary>\r\n\t[Property] public float PlayRadius { get; set; } = 220f;\r\n\r\n\t/// <summary>The Input.config action that hops. Bound to Space on a keyboard and A on a pad in the\r\n\t/// s&amp;box default config, which is what the demo tips prompt.</summary>\r\n\t[Property] public string JumpAction { get; set; } = \"Jump\";\r\n\r\n\t/// <summary>Yaw the demo camera looks along, so pushing forward moves the citizen away from the camera\r\n\t/// instead of sideways. Change it with the camera.</summary>\r\n\t[Property] public float CameraYaw { get; set; } = 45f;\r\n\r\n\t/// <summary>Local Z of the citizen visual. The pawn object sits half a block above the ground because\r\n\t/// the authored box is centred on it, and the citizen's origin is at its feet, so the visual drops by\r\n\t/// that half height to stand on the floor instead of hovering.</summary>\r\n\t[Property] public float VisualZOffset { get; set; } = -25f;\r\n\r\n\t/// <summary>How briskly the citizen turns to face where it is going, in turns per second-ish. High\r\n\t/// enough to read as responsive, low enough that a flick of the stick does not snap it.</summary>\r\n\t[Property] public float TurnSpeed { get; set; } = 12f;\r\n\r\n\tprivate Vector3 _start;\r\n\tprivate float _height;\r\n\tprivate float _riseSpeed;\r\n\tprivate SkinnedModelRenderer _visual;\r\n\tprivate Rotation _facing;\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\t_start = WorldPosition;\r\n\r\n\t\t// Resting yaw looks back down the camera's line, so the citizen greets the player instead of\r\n\t\t// showing its back on the first frame.\r\n\t\t_facing = Rotation.FromYaw( CameraYaw + 180f );\r\n\r\n\t\tHideAuthoredBlock();\r\n\r\n\t\t_visual = TipsDemoCitizen.Build( GameObject, VisualZOffset );\r\n\t\tif ( _visual.IsValid() )\r\n\t\t{\r\n\t\t\t_visual.WorldRotation = _facing;\r\n\t\t\tLog.Info( \"[tips] demo pawn: dressed citizen built in code, authored block renderer switched off.\" );\r\n\t\t}\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tvar facing = Rotation.FromYaw( CameraYaw );\r\n\t\tvar move = ReadMove();\r\n\t\tvar dir = facing.Forward * move.x + facing.Left * move.y;\r\n\t\tif ( dir.Length > 1f )\r\n\t\t\tdir = dir.Normal;\r\n\r\n\t\tvar flat = ( WorldPosition + dir * MoveSpeed * Time.Delta - _start ).WithZ( 0f );\r\n\t\tif ( flat.Length > PlayRadius )\r\n\t\t\tflat = flat.Normal * PlayRadius;\r\n\r\n\t\tvar hopped = false;\r\n\t\tif ( _height <= 0f && _riseSpeed <= 0f && Input.Pressed( JumpAction ) )\r\n\t\t{\r\n\t\t\t_riseSpeed = JumpSpeed;\r\n\t\t\thopped = true;\r\n\t\t}\r\n\r\n\t\tif ( _height > 0f || _riseSpeed > 0f )\r\n\t\t{\r\n\t\t\t_riseSpeed -= Gravity * Time.Delta;\r\n\t\t\t_height += _riseSpeed * Time.Delta;\r\n\t\t\tif ( _height <= 0f )\r\n\t\t\t{\r\n\t\t\t\t_height = 0f;\r\n\t\t\t\t_riseSpeed = 0f;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tvar previous = WorldPosition;\r\n\t\tWorldPosition = _start + flat + Vector3.Up * _height;\r\n\r\n\t\tDriveVisual( previous, dir, hopped );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Movement as a forward/left pair. <c>Input.AnalogMove</c> carries the movement stick and, in a\r\n\t/// project whose Input.config binds the standard movement actions, the keyboard too. The raw W/A/S/D\r\n\t/// fallback keeps the demo drivable in a project that binds movement under other names, which is the\r\n\t/// same reason the movement tip completes on either the stick or those keys.\r\n\t/// </summary>\r\n\tprivate static Vector3 ReadMove()\r\n\t{\r\n\t\tvar move = Input.AnalogMove;\r\n\t\tif ( move.Length > 0.01f )\r\n\t\t\treturn move;\r\n\r\n\t\tvar forward = ( Input.Keyboard.Down( \"w\" ) ? 1f : 0f ) - ( Input.Keyboard.Down( \"s\" ) ? 1f : 0f );\r\n\t\tvar left = ( Input.Keyboard.Down( \"a\" ) ? 1f : 0f ) - ( Input.Keyboard.Down( \"d\" ) ? 1f : 0f );\r\n\t\treturn new Vector3( forward, left, 0f );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Turn the citizen to face its travel and hand the animgraph a frame of locomotion. The legs read the\r\n\t/// distance actually covered rather than the stick, so at the play radius the clamp reads as standing\r\n\t/// still instead of running on the spot.\r\n\t/// </summary>\r\n\tprivate void DriveVisual( Vector3 previous, Vector3 wishDirection, bool hopped )\r\n\t{\r\n\t\tif ( !_visual.IsValid() )\r\n\t\t\treturn;\r\n\r\n\t\tvar travelled = ( WorldPosition - previous ).WithZ( 0f );\r\n\t\tvar velocity = ( Time.Delta > 0f ? travelled / Time.Delta : Vector3.Zero ).WithZ( _riseSpeed );\r\n\r\n\t\tif ( travelled.Length > 0.01f )\r\n\t\t{\r\n\t\t\tvar target = Rotation.LookAt( travelled.Normal, Vector3.Up );\r\n\t\t\t_facing = Rotation.Slerp( _facing, target, ( Time.Delta * TurnSpeed ).Clamp( 0f, 1f ) );\r\n\t\t}\r\n\r\n\t\t_visual.WorldRotation = _facing;\r\n\r\n\t\tvar grounded = _height <= 0f && _riseSpeed <= 0f;\r\n\t\tTipsDemoCitizen.Drive( _visual, velocity, wishDirection * MoveSpeed, grounded );\r\n\r\n\t\tif ( hopped )\r\n\t\t\tTipsDemoCitizen.TriggerJump( _visual );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Switch off the box renderer the scene authors on this object, so the citizen stands alone. Disabling\r\n\t/// the component at runtime leaves the scene file untouched: reopen it in the editor and the simple\r\n\t/// authored block is still what you see. The skinned renderer is skipped by type because it derives\r\n\t/// from <c>ModelRenderer</c> too.\r\n\t/// </summary>\r\n\tprivate void HideAuthoredBlock()\r\n\t{\r\n\t\tvar authored = Components.GetAll<ModelRenderer>( FindMode.EverythingInSelf )\r\n\t\t\t.Where( r => r is not SkinnedModelRenderer )\r\n\t\t\t.ToArray();\r\n\r\n\t\tforeach ( var renderer in authored )\r\n\t\t\trenderer.Enabled = false;\r\n\t}\r\n\r\n\t/// <summary>Put the citizen back where it started (the demo's replay key).</summary>\r\n\tpublic void ResetPawn()\r\n\t{\r\n\t\t_height = 0f;\r\n\t\t_riseSpeed = 0f;\r\n\t\tWorldPosition = _start;\r\n\r\n\t\t_facing = Rotation.FromYaw( CameraYaw + 180f );\r\n\t\tif ( _visual.IsValid() )\r\n\t\t\t_visual.WorldRotation = _facing;\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "fieldguide.tips",
            "Path": "Code/TipCatalogMerge.cs",
            "FileName": "TipCatalogMerge.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 337872,
            "Code": "using System.Collections.Generic;\r\n\r\nnamespace FieldGuide.Tips;\r\n\r\n/// <summary>\r\n/// The merged catalog as one value: the ordered tips and the label saying where each id came from, built\r\n/// together so a reader can never pair a list from one build with labels from another.\r\n/// </summary>\r\npublic sealed class TipCatalogView\r\n{\r\n\t/// <summary>The deduped, precedence-ordered tips.</summary>\r\n\tpublic IReadOnlyList<TipDefinition> Tips { get; init; } = new List<TipDefinition>();\r\n\r\n\t/// <summary>Tip id to source label (\"code\" / \"asset\" / \"draft\" / \"example\").</summary>\r\n\tpublic IReadOnlyDictionary<string, string> SourceById { get; init; } = new Dictionary<string, string>();\r\n}\r\n\r\n/// <summary>\r\n/// The pure merge rule behind <see cref=\"TipsCatalog.Active\"/>: which source wins an id collision, what order\r\n/// the survivors come out in, and when the shipped example stands in. Lifted out of the catalog so it has no\r\n/// <c>Sandbox</c> reference and the harness can assert precedence and dedupe without a running engine, which\r\n/// is the half of the catalog that a typo actually breaks.\r\n/// </summary>\r\npublic static class TipCatalogMerge\r\n{\r\n\t/// <summary>\r\n\t/// Merge the three sources into one view, highest precedence first: CODE, then ASSETS, then DRAFTS. The\r\n\t/// first tip seen for an id wins and later ones are dropped, so a draft never shadows the real tip it is\r\n\t/// a draft of. When all three come back empty, <paramref name=\"fallback\"/> is used and labelled\r\n\t/// \"example\". Null sources are treated as empty; a null tip, or one with a blank id, is skipped.\r\n\t/// </summary>\r\n\tpublic static TipCatalogView Merge(\r\n\t\tIEnumerable<TipDefinition> code,\r\n\t\tIEnumerable<TipDefinition> assets,\r\n\t\tIEnumerable<TipDefinition> drafts,\r\n\t\tIEnumerable<TipDefinition> fallback )\r\n\t{\r\n\t\tvar order = new List<TipDefinition>();\r\n\t\tvar sources = new Dictionary<string, string>( System.StringComparer.Ordinal );\r\n\r\n\t\tAdd( code, \"code\", order, sources );\r\n\t\tAdd( assets, \"asset\", order, sources );\r\n\t\tAdd( drafts, \"draft\", order, sources );\r\n\r\n\t\tif ( order.Count == 0 )\r\n\t\t\tAdd( fallback, \"example\", order, sources );\r\n\r\n\t\treturn new TipCatalogView { Tips = order, SourceById = sources };\r\n\t}\r\n\r\n\t// The source map IS the dedupe guard, and it is filled in the same pass as the list it guards. Guarding\r\n\t// inserts to one collection by querying a different, longer-lived one is how these two drift apart.\r\n\tprivate static void Add( IEnumerable<TipDefinition> source, string label,\r\n\t\tList<TipDefinition> order, Dictionary<string, string> sources )\r\n\t{\r\n\t\tif ( source is null )\r\n\t\t\treturn;\r\n\r\n\t\tforeach ( var def in source )\r\n\t\t{\r\n\t\t\tif ( def is null || string.IsNullOrEmpty( def.Id ) )\r\n\t\t\t\tcontinue;\r\n\t\t\tif ( sources.ContainsKey( def.Id ) )\r\n\t\t\t\tcontinue; // a higher-precedence source already claimed this id\r\n\t\t\torder.Add( def );\r\n\t\t\tsources[def.Id] = label;\r\n\t\t}\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// What a built catalog view was built FROM: the code list it saw, and the draft / asset revision numbers at\r\n/// the time. A read compares the stamp against the live sources; anything that moved means the view is stale\r\n/// and gets rebuilt. Comparing is three field reads with no allocation, which is what lets the coach ask for\r\n/// the catalog several times a frame without a rescan.\r\n///\r\n/// The code source is compared BY REFERENCE, not by content: registering is a whole-list swap, so a new list\r\n/// is a new catalog, and a caller mutating a list it already registered is expected to say so\r\n/// (<see cref=\"TipsCatalog.Rebuild\"/>) the same way it always was.\r\n/// </summary>\r\npublic readonly struct TipCatalogStamp\r\n{\r\n\t/// <summary>The code catalog this view merged.</summary>\r\n\tpublic object Code { get; }\r\n\r\n\t/// <summary>The draft revision this view merged.</summary>\r\n\tpublic int DraftRevision { get; }\r\n\r\n\t/// <summary>The asset revision this view merged.</summary>\r\n\tpublic int AssetRevision { get; }\r\n\r\n\tpublic TipCatalogStamp( object code, int draftRevision, int assetRevision )\r\n\t{\r\n\t\tCode = code;\r\n\t\tDraftRevision = draftRevision;\r\n\t\tAssetRevision = assetRevision;\r\n\t}\r\n\r\n\t/// <summary>True when nothing has moved since this view was built.</summary>\r\n\tpublic bool Matches( object code, int draftRevision, int assetRevision )\r\n\t\t=> ReferenceEquals( Code, code ) && DraftRevision == draftRevision && AssetRevision == assetRevision;\r\n}\r\n"
        },
        {
            "Ident": "fieldguide.tips",
            "Path": "Code/TipTriggerEval.cs",
            "FileName": "TipTriggerEval.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 337872,
            "Code": "using System;\r\n\r\nnamespace FieldGuide.Tips;\r\n\r\n/// <summary>\r\n/// The environment a <see cref=\"TipTrigger\"/> is evaluated against: the four world reads plus the active\r\n/// tip's elapsed visible time. <see cref=\"TipsCoach\"/> fills these from <see cref=\"Sandbox.Input\"/>, the\r\n/// pushed <see cref=\"TipContext\"/> and its own timer; a headless harness fills them with fakes. Keeping\r\n/// the reads behind delegates is what lets the trigger truth table be unit-tested without the engine.\r\n/// </summary>\r\npublic sealed class TipTriggerEnv\r\n{\r\n\t/// <summary>Was the named Input.config action pressed this frame? (Input.Pressed)</summary>\r\n\tpublic Func<string, bool> ActionPressed { get; init; } = static _ => false;\r\n\r\n\t/// <summary>Was the named raw key / mouse button pressed this frame? (Input.Keyboard.Pressed)</summary>\r\n\tpublic Func<string, bool> KeyPressed { get; init; } = static _ => false;\r\n\r\n\t/// <summary>Has the named string signal been latched this session? (Signal / Ever kinds)</summary>\r\n\tpublic Func<string, bool> SignalLatched { get; init; } = static _ => false;\r\n\r\n\t/// <summary>Is the named custom context flag true this frame? (TipContext.Flag)</summary>\r\n\tpublic Func<string, bool> Flag { get; init; } = static _ => false;\r\n\r\n\t/// <summary>The named custom context number this frame. (TipContext.Number)</summary>\r\n\tpublic Func<string, float> Number { get; init; } = static _ => 0f;\r\n\r\n\t/// <summary>Seconds the active tip has been visible, for the Timer kind.</summary>\r\n\tpublic float Elapsed { get; init; }\r\n\r\n\t/// <summary>The current magnitude (0..1-ish) of the given analog stick, for the AnalogAxis kind. (Input.AnalogMove / Input.AnalogLook length.)</summary>\r\n\tpublic Func<TipTriggerAnalogSource, float> AnalogMagnitude { get; init; } = static _ => 0f;\r\n}\r\n\r\n/// <summary>\r\n/// The pure, engine-free evaluation core for a declarative <see cref=\"TipTrigger\"/>: the per-kind rule and\r\n/// the AnyOf / AllOf recursion, with every world read behind a <see cref=\"TipTriggerEnv\"/> delegate. This\r\n/// holds the whole completion/relevance truth table, so it can be exercised headlessly (the coach passes\r\n/// real Input / context reads; a harness passes fakes) and stays identical between the two.\r\n/// </summary>\r\npublic static class TipTriggerEval\r\n{\r\n\t/// <summary>Evaluate a trigger against the given environment. Null trigger evaluates false.</summary>\r\n\tpublic static bool Evaluate( TipTrigger t, TipTriggerEnv env )\r\n\t{\r\n\t\tif ( t is null || env is null )\r\n\t\t\treturn false;\r\n\r\n\t\tswitch ( t.Kind )\r\n\t\t{\r\n\t\t\tcase TipTriggerKind.Always:\r\n\t\t\t\treturn true;\r\n\r\n\t\t\tcase TipTriggerKind.InputAction:\r\n\t\t\t\tforeach ( var a in t.Actions )\r\n\t\t\t\t\tif ( !string.IsNullOrEmpty( a ) && env.ActionPressed( a ) )\r\n\t\t\t\t\t\treturn true;\r\n\t\t\t\treturn false;\r\n\r\n\t\t\tcase TipTriggerKind.Key:\r\n\t\t\t\tforeach ( var k in t.Keys )\r\n\t\t\t\t\tif ( !string.IsNullOrEmpty( k ) && env.KeyPressed( k ) )\r\n\t\t\t\t\t\treturn true;\r\n\t\t\t\treturn false;\r\n\r\n\t\t\tcase TipTriggerKind.Signal:\r\n\t\t\tcase TipTriggerKind.Ever:\r\n\t\t\t\treturn !string.IsNullOrEmpty( t.Key ) && env.SignalLatched( t.Key );\r\n\r\n\t\t\tcase TipTriggerKind.Flag:\r\n\t\t\t\treturn !string.IsNullOrEmpty( t.Key ) && env.Flag( t.Key );\r\n\r\n\t\t\tcase TipTriggerKind.AtLeast:\r\n\t\t\t\treturn !string.IsNullOrEmpty( t.Key ) && env.Number( t.Key ) >= t.Threshold;\r\n\r\n\t\t\tcase TipTriggerKind.Timer:\r\n\t\t\t\treturn env.Elapsed >= t.Seconds;\r\n\r\n\t\t\tcase TipTriggerKind.AnalogAxis:\r\n\t\t\t\treturn env.AnalogMagnitude( t.AnalogSource ) >= t.Threshold;\r\n\r\n\t\t\tcase TipTriggerKind.AnyOf:\r\n\t\t\t\tforeach ( var c in t.Children )\r\n\t\t\t\t\tif ( Evaluate( c, env ) )\r\n\t\t\t\t\t\treturn true;\r\n\t\t\t\treturn false;\r\n\r\n\t\t\tcase TipTriggerKind.AllOf:\r\n\t\t\t\tif ( t.Children.Length == 0 )\r\n\t\t\t\t\treturn false;\r\n\t\t\t\tforeach ( var c in t.Children )\r\n\t\t\t\t\tif ( !Evaluate( c, env ) )\r\n\t\t\t\t\t\treturn false;\r\n\t\t\t\treturn true;\r\n\r\n\t\t\tdefault:\r\n\t\t\t\treturn false;\r\n\t\t}\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "fieldguide.tips",
            "Path": "TipTriggerObject.cs",
            "FileName": "TipTriggerObject.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 337872,
            "Code": "using System;\r\nusing Sandbox;\r\n\r\nnamespace FieldGuide.Tips;\r\n\r\n/// <summary>\r\n/// Drop this on a GameObject (an NPC, a door, a pickup) to retire a tip when the player does something to\r\n/// that object. Pick the tip id and the mode in the inspector; no code for the common cases. So \"talk to\r\n/// this NPC\" is: add this component to the NPC, set <see cref=\"TipId\"/>, set\r\n/// <see cref=\"CompleteOn\"/> = <see cref=\"Mode.Interacted\"/>, and call <see cref=\"Interacted\"/> (or the\r\n/// static <see cref=\"NotifyInteracted\"/>) from wherever your game already knows an interaction happened.\r\n///\r\n/// <see cref=\"Mode.PlayerEntered\"/> and <see cref=\"Mode.LookedAt\"/> read the <see cref=\"TipsWorld\"/>\r\n/// seams and are INERT until those seams are set (they never fire and never throw), so a game that skips\r\n/// the seams still runs. Input, Signal and Timer completion do not use this component at all; they live on\r\n/// the tip itself (declarative triggers or the coach's input pass).\r\n/// </summary>\r\n[Title( \"Tip Trigger\" )]\r\n[Category( \"Field Guide Tips\" )]\r\n[Icon( \"ads_click\" )]\r\npublic sealed class TipTriggerObject : Component\r\n{\r\n\tpublic enum Mode\r\n\t{\r\n\t\t/// <summary>Your interaction code (or the fieldguide.interaction bridge) calls <see cref=\"Interacted\"/>.</summary>\r\n\t\tInteracted,\r\n\r\n\t\t/// <summary>The local player is within <see cref=\"Radius\"/> of this object (needs <see cref=\"TipsWorld.LocalPlayerPosition\"/>).</summary>\r\n\t\tPlayerEntered,\r\n\r\n\t\t/// <summary>The aim ray hits this object within <see cref=\"Radius\"/> (needs <see cref=\"TipsWorld.AimRay\"/>).</summary>\r\n\t\tLookedAt,\r\n\r\n\t\t/// <summary>Raise a named signal instead of completing directly (fans out to every coach's Signal).</summary>\r\n\t\tSignal,\r\n\t}\r\n\r\n\t[Property] public string TipId { get; set; } = \"\";\r\n\t[Property] public Mode CompleteOn { get; set; } = Mode.Interacted;\r\n\r\n\t/// <summary>Trigger distance for PlayerEntered, and the aim-ray length for LookedAt, in world units.</summary>\r\n\t[Property] public float Radius { get; set; } = 128f;\r\n\r\n\t/// <summary>Signal name raised in <see cref=\"Mode.Signal\"/>.</summary>\r\n\t[Property] public string SignalName { get; set; } = \"\";\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif ( CompleteOn == Mode.PlayerEntered && WithinPlayerRadius() )\r\n\t\t\tFire();\r\n\t\telse if ( CompleteOn == Mode.LookedAt && AimRayHitsSelf() )\r\n\t\t\tFire();\r\n\t}\r\n\r\n\t/// <summary>Call from your interaction code when this object is interacted with. Inert unless the mode\r\n\t/// is <see cref=\"Mode.Interacted\"/>.</summary>\r\n\tpublic void Interacted()\r\n\t{\r\n\t\tif ( CompleteOn == Mode.Interacted )\r\n\t\t\tFire();\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Convenience for an interaction bridge: fire the <see cref=\"Interacted\"/> trigger on a target\r\n\t/// GameObject if it carries a <see cref=\"TipTriggerObject\"/>. Null-safe on the target and on a missing\r\n\t/// component, so a bridge can call it for every interacted object without guarding.\r\n\t/// </summary>\r\n\tpublic static void NotifyInteracted( GameObject target )\r\n\t\t=> target?.Components.Get<TipTriggerObject>( FindMode.EnabledInSelfAndDescendants )?.Interacted();\r\n\r\n\tprivate void Fire()\r\n\t{\r\n\t\tif ( CompleteOn == Mode.Signal )\r\n\t\t{\r\n\t\t\tif ( string.IsNullOrEmpty( SignalName ) )\r\n\t\t\t\treturn;\r\n\t\t\tforeach ( var coach in Scene.GetAllComponents<TipsCoach>() )\r\n\t\t\t\tcoach.Signal( SignalName );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tTipsCoach.Complete( TipId );\r\n\t}\r\n\r\n\tprivate bool WithinPlayerRadius()\r\n\t{\r\n\t\tvar seam = TipsWorld.LocalPlayerPosition; // fail-inert: null seam disables PlayerEntered\r\n\t\tif ( seam is null )\r\n\t\t\treturn false;\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\treturn WorldPosition.Distance( seam() ) <= Radius;\r\n\t\t}\r\n\t\tcatch\r\n\t\t{\r\n\t\t\treturn false;\r\n\t\t}\r\n\t}\r\n\r\n\tprivate bool AimRayHitsSelf()\r\n\t{\r\n\t\tvar seam = TipsWorld.AimRay; // fail-inert: null seam disables LookedAt\r\n\t\tif ( seam is null )\r\n\t\t\treturn false;\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar ray = seam();\r\n\t\t\tvar tr = Scene.Trace.Ray( ray.Position, ray.Position + ray.Forward * Radius ).Run();\r\n\t\t\treturn tr.Hit && tr.GameObject.IsValid()\r\n\t\t\t\t&& ( tr.GameObject == GameObject || GameObject.IsDescendant( tr.GameObject ) );\r\n\t\t}\r\n\t\tcatch\r\n\t\t{\r\n\t\t\treturn false;\r\n\t\t}\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "fieldguide.tips",
            "Path": "Code/Demo/TipsDemoCitizen.cs",
            "FileName": "TipsDemoCitizen.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 337872,
            "Code": "using System;\r\nusing Sandbox;\r\n\r\nnamespace FieldGuide.Tips;\r\n\r\n/// <summary>\r\n/// Builds and drives the demo pawn's look: a dressed stock citizen, spawned in code as a child of the pawn\r\n/// object. Everything here ships with the engine (the citizen model, its clothing, its animgraph), so the\r\n/// demo still carries zero art of its own.\r\n///\r\n/// WHY IN CODE. The pawn object is authored in <c>tips_demo.scene</c> with a plain box renderer, and the\r\n/// scene stays exactly that on disk. <see cref=\"TipsDemoPawn\"/> switches the box off at boot and builds\r\n/// this instead, so the swap costs no scene churn and the editor viewport still shows the simple authored\r\n/// block when nothing is playing.\r\n///\r\n/// WHY NOT CitizenAnimationHelper. The engine ships a helper component that wraps these same animgraph\r\n/// parameters, but it lives in a namespace a Field Guide kit is not allowed to reference (the isolation\r\n/// lint severs it). The parameter names below are the ones that helper sets, driven directly on the\r\n/// renderer, which is also what the placement and vehicle physics kits do.\r\n///\r\n/// Not part of the kit's runtime surface: delete <c>Code/Demo</c> and <c>Assets/demo</c> when you drop the\r\n/// kit into your own project.\r\n/// </summary>\r\ninternal static class TipsDemoCitizen\r\n{\r\n\tprivate const string ModelPath = \"models/citizen/citizen.vmdl\";\r\n\r\n\t/// <summary>A plain outfit from the shipped citizen clothing resources, the same two items the\r\n\t/// placement kit's demo wears. Each is null-checked, so a missing asset degrades to a barer citizen\r\n\t/// rather than a broken spawn.</summary>\r\n\tprivate static readonly string[] Outfit =\r\n\t{\r\n\t\t\"models/citizen_clothes/shirt/Jumpsuit/blue_jumpsuit.clothing\",\r\n\t\t\"models/citizen_clothes/shoes/Trainers/trainers.clothing\",\r\n\t};\r\n\r\n\t/// <summary>\r\n\t/// Spawn the citizen as a child of the pawn. <paramref name=\"footOffset\"/> is its local Z: the pawn\r\n\t/// object sits half a block above the ground because the authored box is centred on it, and the citizen\r\n\t/// model's origin is at its feet, so the visual drops by that half height to stand on the floor.\r\n\t/// Returns null if the model did not load, which leaves the caller free to keep the block.\r\n\t/// </summary>\r\n\tpublic static SkinnedModelRenderer Build( GameObject pawn, float footOffset )\r\n\t{\r\n\t\tvar go = pawn.Scene.CreateObject();\r\n\t\tgo.Name = \"Demo Citizen Visual\";\r\n\t\tgo.SetParent( pawn, false );\r\n\t\tgo.LocalPosition = Vector3.Up * footOffset;\r\n\r\n\t\tvar model = Model.Load( ModelPath );\r\n\t\tif ( model is null || model.IsError )\r\n\t\t{\r\n\t\t\tLog.Warning( $\"[tips] demo citizen model '{ModelPath}' did not load; keeping the authored block.\" );\r\n\t\t\tgo.Destroy();\r\n\t\t\treturn null;\r\n\t\t}\r\n\r\n\t\tvar renderer = go.Components.Create<SkinnedModelRenderer>();\r\n\t\trenderer.Model = model;\r\n\t\tDress( renderer );\r\n\r\n\t\t// Grounded with no move input is the citizen animgraph's rest state: a standing idle that breathes\r\n\t\t// and shifts weight, which is what the demo wants between tips.\r\n\t\trenderer.Set( \"b_grounded\", true );\r\n\r\n\t\treturn renderer;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Push a frame of locomotion at the animgraph. <paramref name=\"velocity\"/> is what the pawn actually\r\n\t/// travelled, <paramref name=\"wishVelocity\"/> is what the stick asked for; the graph uses the first for\r\n\t/// the legs and the second for arm swing in the air, which is why both go across.\r\n\t/// </summary>\r\n\tpublic static void Drive( SkinnedModelRenderer renderer, Vector3 velocity, Vector3 wishVelocity, bool grounded )\r\n\t{\r\n\t\tif ( !renderer.IsValid() )\r\n\t\t\treturn;\r\n\r\n\t\trenderer.Set( \"b_grounded\", grounded );\r\n\t\tSetMotion( renderer, \"move\", velocity );\r\n\t\tSetMotion( renderer, \"wish\", wishVelocity );\r\n\t}\r\n\r\n\t/// <summary>Fire the animgraph's one-shot hop. It self-clears, so it is set and forgotten.</summary>\r\n\tpublic static void TriggerJump( SkinnedModelRenderer renderer )\r\n\t{\r\n\t\tif ( renderer.IsValid() )\r\n\t\t\trenderer.Set( \"b_jump\", true );\r\n\t}\r\n\r\n\t/// <summary>The six parameters the citizen animgraph reads per motion channel, resolved against the\r\n\t/// renderer's own facing so a sideways walk plays the strafe blend rather than a forward one.</summary>\r\n\tprivate static void SetMotion( SkinnedModelRenderer renderer, string channel, Vector3 velocity )\r\n\t{\r\n\t\tvar rotation = renderer.WorldRotation;\r\n\t\tvar forward = rotation.Forward.Dot( velocity );\r\n\t\tvar sideward = rotation.Right.Dot( velocity );\r\n\t\tvar angle = MathF.Atan2( sideward, forward ).RadianToDegree().NormalizeDegrees();\r\n\r\n\t\trenderer.Set( $\"{channel}_direction\", angle );\r\n\t\trenderer.Set( $\"{channel}_speed\", velocity.Length );\r\n\t\trenderer.Set( $\"{channel}_groundspeed\", velocity.WithZ( 0f ).Length );\r\n\t\trenderer.Set( $\"{channel}_x\", forward );\r\n\t\trenderer.Set( $\"{channel}_y\", sideward );\r\n\t\trenderer.Set( $\"{channel}_z\", velocity.z );\r\n\t}\r\n\r\n\tprivate static void Dress( SkinnedModelRenderer renderer )\r\n\t{\r\n\t\tvar outfit = new ClothingContainer();\r\n\t\tvar any = false;\r\n\r\n\t\tforeach ( var path in Outfit )\r\n\t\t{\r\n\t\t\tvar item = ResourceLibrary.Get<Clothing>( path );\r\n\t\t\tif ( item is null )\r\n\t\t\t{\r\n\t\t\t\tLog.Warning( $\"[tips] demo clothing '{path}' did not resolve, skipping that slot.\" );\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\toutfit.Add( item );\r\n\t\t\tany = true;\r\n\t\t}\r\n\r\n\t\tif ( any )\r\n\t\t\toutfit.Apply( renderer );\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "fieldguide.tips",
            "Path": "Code/Studio/TipStudioText.cs",
            "FileName": "TipStudioText.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 337872,
            "Code": "using System.Collections.Generic;\r\n\r\nnamespace FieldGuide.Tips;\r\n\r\n/// <summary>\r\n/// The authoring checks the Tips Studio runs on a draft while you type: the things that produce a card that\r\n/// looks broken, or a tip that can never retire, and that are cheap to catch before the file is written.\r\n/// Pure (no <c>Sandbox</c> reference, no engine state), so the harness asserts the same rules the panel shows.\r\n///\r\n/// Every message is a NOTE, never a block. The Studio will happily bake a tip with warnings on it, because\r\n/// several of them are legitimate on purpose (an empty <c>AllOf</c> is the documented \"a world trigger\r\n/// retires this one\" shape).\r\n/// </summary>\r\npublic static class TipStudioText\r\n{\r\n\t/// <summary>\r\n\t/// How long a single unbroken run of prose can get before the card is at risk of the grey-block quirk:\r\n\t/// the style engine rasterizes a text run that overflows one card line as a solid filled rectangle\r\n\t/// instead of wrapped glyphs. Chips break a line into separate runs, which is why a tip full of keycaps\r\n\t/// stays safe while one long sentence does not. Roughly one line at the card's 500px width; deliberately\r\n\t/// a round number rather than a measured one, because the real threshold moves with the wording.\r\n\t/// </summary>\r\n\tpublic const int MaxRunLength = 50;\r\n\r\n\t/// <summary>The length of the longest PLAIN run in a tip line. Chips (<c>*keycap*</c>,\r\n\t/// <c>`padchip`</c>) are separate runs and never count toward it, which mirrors how the card lays out.</summary>\r\n\tpublic static int LongestRun( string text )\r\n\t{\r\n\t\tvar longest = 0;\r\n\t\tforeach ( var segment in TipSegment.Parse( text ) )\r\n\t\t{\r\n\t\t\tif ( segment.Kind != TipSegmentKind.Plain )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tvar length = segment.Text is null ? 0 : segment.Text.Trim().Length;\r\n\t\t\tif ( length > longest )\r\n\t\t\t\tlongest = length;\r\n\t\t}\r\n\r\n\t\treturn longest;\r\n\t}\r\n\r\n\t/// <summary>True when a line carries a run long enough to risk the grey-block quirk.</summary>\r\n\tpublic static bool RunTooLong( string text ) => LongestRun( text ) > MaxRunLength;\r\n\r\n\t/// <summary>The note for a too-long run, or null when the line is fine.</summary>\r\n\tpublic static string RunWarning( string text, string label )\r\n\t{\r\n\t\tvar longest = LongestRun( text );\r\n\t\tif ( longest <= MaxRunLength )\r\n\t\t\treturn null;\r\n\r\n\t\treturn $\"{label}: one run is {longest} characters. A run longer than a card line can render as a grey \" +\r\n\t\t\t$\"block. Break the sentence, or put a key chip in it.\";\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Every note for a draft, in the order the panel lists them. An empty list means nothing to flag.\r\n\t/// </summary>\r\n\tpublic static IReadOnlyList<string> Warnings( TipStudioDraft draft )\r\n\t{\r\n\t\tvar notes = new List<string>();\r\n\t\tif ( draft is null )\r\n\t\t\treturn notes;\r\n\r\n\t\tif ( string.IsNullOrWhiteSpace( draft.Id ) )\r\n\t\t\tnotes.Add( \"No id yet. The catalog keys tips by id, and the file is named after it.\" );\r\n\r\n\t\tif ( string.IsNullOrWhiteSpace( draft.Text ) )\r\n\t\t\tnotes.Add( \"No text yet. This is the line the player reads.\" );\r\n\r\n\t\tvar textNote = RunWarning( draft.Text, \"Text\" );\r\n\t\tif ( textNote is not null )\r\n\t\t\tnotes.Add( textNote );\r\n\r\n\t\tif ( !string.IsNullOrEmpty( draft.TextPad ) )\r\n\t\t{\r\n\t\t\tvar padNote = RunWarning( draft.TextPad, \"Pad text\" );\r\n\t\t\tif ( padNote is not null )\r\n\t\t\t\tnotes.Add( padNote );\r\n\t\t}\r\n\r\n\t\tAddTriggerNotes( notes, draft.Completion, \"Completion\", isCompletion: true );\r\n\t\tAddTriggerNotes( notes, draft.Relevance, \"Relevance\", isCompletion: false );\r\n\r\n\t\treturn notes;\r\n\t}\r\n\r\n\tprivate static void AddTriggerNotes( List<string> notes, TipStudioTrigger trigger, string label, bool isCompletion )\r\n\t{\r\n\t\tif ( trigger is null )\r\n\t\t\treturn;\r\n\r\n\t\tswitch ( trigger.Kind )\r\n\t\t{\r\n\t\t\tcase TipTriggerKind.InputAction when string.IsNullOrWhiteSpace( trigger.Action ):\r\n\t\t\t\tnotes.Add( $\"{label} is InputAction with no action picked, so it never fires.\" );\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase TipTriggerKind.Key when string.IsNullOrWhiteSpace( trigger.Key ):\r\n\t\t\t\tnotes.Add( $\"{label} is Key with no key name, so it never fires.\" );\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase TipTriggerKind.Signal or TipTriggerKind.Ever or TipTriggerKind.Flag or TipTriggerKind.AtLeast\r\n\t\t\t\twhen string.IsNullOrWhiteSpace( trigger.Name ):\r\n\t\t\t\tnotes.Add( $\"{label} is {TipStudioTrigger.KindName( trigger.Kind )} with no name, so it never fires.\" );\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase TipTriggerKind.Timer when trigger.Seconds <= 0f && isCompletion:\r\n\t\t\t\tnotes.Add( $\"{label} is Timer with 0 seconds, so the tip retires the moment it is readable. Set Seconds.\" );\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase TipTriggerKind.AnalogAxis when trigger.Magnitude <= 0f:\r\n\t\t\t\tnotes.Add( $\"{label} is AnalogAxis with a magnitude of 0, so a resting stick already fires it.\" );\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase TipTriggerKind.AllOf when CountChildren( trigger ) == 0 && isCompletion:\r\n\t\t\t\tnotes.Add( $\"{label} is an empty AllOf, which never fires. That is the right shape when a \" +\r\n\t\t\t\t\t\"TipTriggerObject in the scene retires this tip.\" );\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase TipTriggerKind.AnyOf when CountChildren( trigger ) == 0:\r\n\t\t\t\tnotes.Add( $\"{label} is an empty AnyOf, so it never fires.\" );\r\n\t\t\t\tbreak;\r\n\t\t}\r\n\r\n\t\tif ( trigger.Children is null )\r\n\t\t\treturn;\r\n\r\n\t\tforeach ( var child in trigger.Children )\r\n\t\t\tAddTriggerNotes( notes, child, $\"{label} child\", isCompletion );\r\n\t}\r\n\r\n\tprivate static int CountChildren( TipStudioTrigger trigger )\r\n\t\t=> trigger.Children is null ? 0 : trigger.Children.Count;\r\n}\r\n"
        }
    ]
}