Utility class for the Tips Studio authoring UI. It validates draft tip content and produces human-readable warning notes about problems like missing id/text, trigger misconfiguration, and overly long plain text runs that can cause a rendering quirk.
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;
}