Code/SboxSuperuser/Core/Effects/EffectSymbols.cs

Static symbol table for a custom text-effect language. Holds the named colors and their hex values, the default WaW palette, and a list of every built-in input, output, placeholder and function with descriptions and examples. Also provides name lookup and a Levenshtein-style "did you mean" suggester used by the checker and editor help.

namespace SboxSuperuser.Core.Effects;

/// <summary>
/// Every built-in name in the effect language. The checker, the syntax highlighter, the editor's help list and the
/// generated reference all read from here, so they can never disagree.
/// </summary>
internal static class EffectSymbols
{
	// The World at War palette (^0 to ^7), plus a few extra names.
	internal static readonly IReadOnlyDictionary<string, EffectColor> Colors = new Dictionary<string, EffectColor>( StringComparer.Ordinal )
	{
		[ "black" ] = Hex( "#000000" ),
		[ "red" ] = Hex( "#FF3333" ),
		[ "green" ] = Hex( "#00FF00" ),
		[ "yellow" ] = Hex( "#FFFF00" ),
		[ "blue" ] = Hex( "#0000FF" ),
		[ "cyan" ] = Hex( "#00FFFF" ),
		[ "pink" ] = Hex( "#FF5CFF" ),
		[ "white" ] = Hex( "#FFFFFF" ),
		[ "orange" ] = Hex( "#FF8800" ),
		[ "purple" ] = Hex( "#9933FF" ),
		[ "gray" ] = Hex( "#888888" )
	};

	/// <summary>The palette a prefix uses when it sets none: the five WaW color codes ^1 to ^5.</summary>
	internal static readonly EffectColor[] DefaultPalette = { Colors[ "red" ], Colors[ "green" ], Colors[ "yellow" ], Colors[ "blue" ], Colors[ "cyan" ] };

	internal static readonly IReadOnlyList<EffectSymbol> All = new EffectSymbol[]
	{
		new( "t", EffectSymbolKind.Input, "Time in milliseconds. Every player sees the same value at the same moment.", "floor(t / 100)" ),
		new( "i", EffectSymbolKind.Input, "Which letter this is, starting at 0.", "i == 0" ),
		new( "L", EffectSymbolKind.Input, "How many letters this part has.", "i == L - 1" ),
		new( "ch", EffectSymbolKind.Input, "This letter, as text.", "ch == \"a\"" ),
		new( "part", EffectSymbolKind.Input, "Which part is drawing: \"tag\" or \"name\".", "part == \"name\"" ),
		new( "text", EffectSymbolKind.Input, "The whole text of this part.", "len(text)" ),
		new( "palette", EffectSymbolKind.Input, "The prefix's colors as a list. Indexes wrap around.", "palette[i]" ),
		new( "seed", EffectSymbolKind.Input, "A number fixed per player, for effects that differ between players.", "hash(i, seed)" ),

		new( "color", EffectSymbolKind.Output, "This letter's color. Starts white.", "color = red" ),
		new( "alpha", EffectSymbolKind.Output, "Opacity from 0 to 1. Starts at 1.", "alpha = wave(t, 1000)" ),
		new( "char", EffectSymbolKind.Output, "Replace this letter with other text.", "char = \"o\"" ),
		new( "offset", EffectSymbolKind.Output, "Shift this letter sideways, in letter widths.", "offset = 2" ),
		new( "bold", EffectSymbolKind.Output, "Draw this letter bold.", "bold = true" ),
		new( "italic", EffectSymbolKind.Output, "Draw this letter slanted.", "italic = i % 2 == 0" ),
		new( "hidden", EffectSymbolKind.Output, "Hide this letter.", "hidden = i > 3" ),

		new( "%name%", EffectSymbolKind.Placeholder, "The player's name.", "len(%name%)" ),
		new( "%prefix%", EffectSymbolKind.Placeholder, "The prefix's tag text.", "upper(%prefix%)" ),

		new( "floor", EffectSymbolKind.Function, "Round down.", "floor(t / 100)", 1, 1 ),
		new( "ceil", EffectSymbolKind.Function, "Round up.", "ceil(L / 2)", 1, 1 ),
		new( "round", EffectSymbolKind.Function, "Round to the nearest whole number.", "round(1.6)", 1, 1 ),
		new( "abs", EffectSymbolKind.Function, "Remove the minus sign.", "abs(sin(t))", 1, 1 ),
		new( "sqrt", EffectSymbolKind.Function, "Square root.", "sqrt(i)", 1, 1 ),
		new( "pow", EffectSymbolKind.Function, "Raise to a power.", "pow(2, i)", 2, 2 ),
		new( "sin", EffectSymbolKind.Function, "Sine, in radians.", "sin(t * 0.004)", 1, 1 ),
		new( "cos", EffectSymbolKind.Function, "Cosine, in radians.", "cos(t * 0.004)", 1, 1 ),
		new( "tan", EffectSymbolKind.Function, "Tangent, in radians.", "tan(t)", 1, 1 ),
		new( "min", EffectSymbolKind.Function, "The smaller of two numbers.", "min(i, 3)", 2, 2 ),
		new( "max", EffectSymbolKind.Function, "The larger of two numbers.", "max(i, 3)", 2, 2 ),
		new( "clamp", EffectSymbolKind.Function, "Keep a number between a low and a high value.", "clamp(i, 0, 5)", 3, 3 ),
		new( "lerp", EffectSymbolKind.Function, "Blend two numbers; 0 gives the first, 1 the second.", "lerp(0, 10, 0.5)", 3, 3 ),
		new( "mod", EffectSymbolKind.Function, "Remainder that never goes negative.", "mod(i - 1, L)", 2, 2 ),
		new( "sign", EffectSymbolKind.Function, "-1, 0 or 1 depending on the sign.", "sign(sin(t))", 1, 1 ),
		new( "rgb", EffectSymbolKind.Function, "A color from red, green and blue (0-255), with optional alpha.", "rgb(255, 128, 0)", 3, 4 ),
		new( "hsv", EffectSymbolKind.Function, "A color from hue (degrees), saturation and value (0-1).", "hsv(t / 10 + i * 30, 1, 1)", 3, 3 ),
		new( "mix", EffectSymbolKind.Function, "Blend two colors; 0 gives the first, 1 the second.", "mix(red, blue, i / L)", 3, 3 ),
		new( "fade", EffectSymbolKind.Function, "A color with a different opacity.", "fade(red, 0.5)", 2, 2 ),
		new( "len", EffectSymbolKind.Function, "How long some text or a list is.", "len(palette)", 1, 1 ),
		new( "upper", EffectSymbolKind.Function, "Text in capitals.", "upper(ch)", 1, 1 ),
		new( "lower", EffectSymbolKind.Function, "Text in small letters.", "lower(ch)", 1, 1 ),
		new( "at", EffectSymbolKind.Function, "The letter at a position in some text; positions wrap around.", "at(%name%, i + 1)", 2, 2 ),
		new( "pingpong", EffectSymbolKind.Function, "Counts 0, 1 ... n-1 and back down, forever.", "pingpong(t / 75, 4)", 2, 2 ),
		new( "wave", EffectSymbolKind.Function, "A smooth 0 to 1 to 0 wave lasting the given milliseconds.", "wave(t, 1000)", 2, 2 ),
		new( "hash", EffectSymbolKind.Function, "A fixed random-looking number from 0 to 1 for the inputs.", "hash(i, seed)", 1, 2 ),
	};

	private static readonly Dictionary<string, EffectSymbol> ByName = All.ToDictionary( symbol => symbol.Name, StringComparer.Ordinal );

	internal static EffectSymbol Find( string name ) => ByName.GetValueOrDefault( name );

	internal static bool IsColor( string name ) => Colors.ContainsKey( name );

	/// <summary>The closest candidate within two typos (a swapped pair counts as one), for "did you mean" hints.</summary>
	internal static string Suggest( string name, IEnumerable<string> candidates )
	{
		string best = null;
		var bestDistance = 3;
		foreach ( var candidate in candidates )
		{
			var distance = Distance( name, candidate );
			if ( distance >= bestDistance ) continue;
			best = candidate;
			bestDistance = distance;
		}
		return best;
	}

	internal static IEnumerable<string> FunctionNames => All.Where( symbol => symbol.Kind == EffectSymbolKind.Function ).Select( symbol => symbol.Name );

	// Optimal string alignment distance, ignoring case.
	private static int Distance( string a, string b )
	{
		var table = new int[ a.Length + 1, b.Length + 1 ];
		for ( var i = 0; i <= a.Length; i++ ) table[ i, 0 ] = i;
		for ( var j = 0; j <= b.Length; j++ ) table[ 0, j ] = j;
		for ( var i = 1; i <= a.Length; i++ )
		{
			for ( var j = 1; j <= b.Length; j++ )
			{
				var cost = Same( a[ i - 1 ], b[ j - 1 ] ) ? 0 : 1;
				table[ i, j ] = Math.Min( Math.Min( table[ i - 1, j ] + 1, table[ i, j - 1 ] + 1 ), table[ i - 1, j - 1 ] + cost );
				if ( i > 1 && j > 1 && Same( a[ i - 1 ], b[ j - 2 ] ) && Same( a[ i - 2 ], b[ j - 1 ] ) )
					table[ i, j ] = Math.Min( table[ i, j ], table[ i - 2, j - 2 ] + 1 );
			}
		}
		return table[ a.Length, b.Length ];
	}

	private static bool Same( char a, char b ) => char.ToLowerInvariant( a ) == char.ToLowerInvariant( b );

	private static EffectColor Hex( string hex ) => EffectColor.TryParseHex( hex, out var color ) ? color : EffectColor.White;
}