Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## v1.2.0.0 - 2026-06-29
### Added
- Conditionals: run commands only when a condition is true, using `:if` / `:else` / `:endif` blocks. Everything in a block runs only when its condition holds, with an optional `:else` for the false case, and blocks can be nested
- Conditions compare values with `==`, `!=`, `<`, `>`, `<=`, `>=` and combine with `&&` (and) and `||` (or). Either side can be a variable, a parameter, or plain text, for example `:if {hpp} < 50 && {incombat}` or `:if {0} == on`
- The alias editor warns you when a conditional block is left open or a condition cannot be understood

## v1.1.0.0 - 2026-06-27
### Added
- New variables: your HP and MP (including percentages), whether you are in combat, whether you have a target, and your Gil and MGP
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,30 @@ You can also pull ranges of arguments. Range ends are exclusive, the same as C#

If you reference an argument that was not supplied, it is left as written (e.g. `{3}` stays `{3}`).

### Conditionals

Aliases can run commands only when a condition is true, using `:if` / `:else` / `:endif` blocks:

```
:if {hpp} < 50
/ac Cure
:else
/say all good
:endif
```

Everything between `:if` and `:endif` runs only when the condition holds; the optional `:else` block runs when it does not. A block can contain multiple commands, and blocks can be nested.

A condition compares values with `==`, `!=`, `<`, `>`, `<=`, `>=`, and you can combine comparisons with `&&` (and) and `||` (or). Either side can be a variable, a parameter, or plain text, so conditions can react to game state or to what you typed:

```
:if {incombat} && {hpp} < 50
:if {job} == WHM || {job} == SCH
:if {0} == on
```

Text comparisons are case-insensitive, and numbers compare as numbers. The `:if`, `:else`, and `:endif` lines are never sent to chat, and the alias editor warns you if a block is left open or a condition cannot be understood.

### Folders

Create a folder with the **New Folder** button, then drag aliases into or out of it. Folders can be collapsed, renamed, and deleted from their right-click menu.
Expand Down
6 changes: 6 additions & 0 deletions Silkstring.sln
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ VisualStudioVersion = 16.0.29709.97
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Silkstring", "Silkstring\Silkstring.csproj", "{13C812E9-0D42-4B95-8646-40EEBF30636F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{BC8C19B7-F609-4B27-A05B-03D50E7E46BB}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|x64 = Debug|x64
Expand All @@ -19,6 +21,10 @@ Global
{4FEC9558-EB25-419F-B86E-51B8CFDA32B7}.Debug|x64.Build.0 = Debug|x64
{4FEC9558-EB25-419F-B86E-51B8CFDA32B7}.Release|x64.ActiveCfg = Release|x64
{4FEC9558-EB25-419F-B86E-51B8CFDA32B7}.Release|x64.Build.0 = Release|x64
{BC8C19B7-F609-4B27-A05B-03D50E7E46BB}.Debug|x64.ActiveCfg = Debug|Any CPU
{BC8C19B7-F609-4B27-A05B-03D50E7E46BB}.Debug|x64.Build.0 = Debug|Any CPU
{BC8C19B7-F609-4B27-A05B-03D50E7E46BB}.Release|x64.ActiveCfg = Release|Any CPU
{BC8C19B7-F609-4B27-A05B-03D50E7E46BB}.Release|x64.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
32 changes: 32 additions & 0 deletions Silkstring/Services/AliasValidator.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using Silkstring.Models;
using Silkstring.Services.Conditions;

namespace Silkstring.Services;

Expand All @@ -14,6 +15,37 @@ public static List<string> FindCycle(AliasEntry target, IEnumerable<AliasEntry>
return Dfs(target, lookup, visited, path);
}

public static string? ValidateBlocks(AliasEntry Alias)
{
var elseSeen = new Stack<bool>();

foreach (var command in Alias.Output)
{
var (kind, expression) = BlockInterpreter.Classify((command.Command.Trim()));

switch (kind)
{
case BlockKind.If:
try { new Parser(Tokenizer.Tokenize(expression)).Parse(); }
catch (ConditionException ex) { return $"Invalid condition: {ex.Message}"; }
elseSeen.Push(false);
break;
case BlockKind.Else:
if (elseSeen.Count == 0) return ":else without a matching :if";
if (elseSeen.Peek()) return "Duplicate :else in a block";
elseSeen.Pop();
elseSeen.Push(true);
break;
case BlockKind.EndIf:
if (elseSeen.Count == 0) return ":endif without a matching :if";
elseSeen.Pop();
break;
}
}

return elseSeen.Count > 0 ? "Unclosed :if (missing :endif)" : null;
}

private static Dictionary<string, AliasEntry> BuildTriggerLookup(IEnumerable<AliasEntry> allAliases)
{
var lookup = new Dictionary<string, AliasEntry>(StringComparer.OrdinalIgnoreCase);
Expand Down
46 changes: 40 additions & 6 deletions Silkstring/Services/CommandHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,39 +5,73 @@
using Dalamud.Plugin.Services;
using ECommons.Automation;
using Serilog;
using Silkstring.Services.Conditions;

namespace Silkstring.Services;

public class CommandHandler
{
private readonly IFramework _framework;
private readonly CommandResolver _resolver;
private readonly ConditionEvaluator _conditions;

public CommandHandler(CommandResolver resolver, IFramework framework)
{
_resolver = resolver;
_framework = framework;
_conditions = new ConditionEvaluator(_resolver.Resolve);
}

public async Task ExecuteAsync(IReadOnlyList<string> commands, IReadOnlyList<string> args, int delayMs = 100, CancellationToken cancellationToken = default, Func<string, bool>? shouldSkip = null)
{
for (var i = 0; i < commands.Count; i++)
var blocks = new BlockInterpreter();
var sent = false;

foreach (var line in commands)
{
var cmd = commands[i];
cmd = await _framework.RunOnFrameworkThread(() => _resolver.Resolve(cmd, args));
var (kind, expression) = BlockInterpreter.Classify(line);

if (kind == BlockKind.If)
{
var met = blocks.Active && await _framework.RunOnFrameworkThread(() => EvaluateSafe(expression, args));
blocks.EnterIf(met);
continue;
}

if (kind == BlockKind.Else) { blocks.Else(); continue; }
if (kind == BlockKind.EndIf) { blocks.EndIf(); continue; }

if (!blocks.Active) continue;

var cmd = await _framework.RunOnFrameworkThread(() => _resolver.Resolve(line, args));
if (shouldSkip != null && cmd.StartsWith("/"))
{
var parts = cmd.TrimStart('/').Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0) continue;
var commandName = parts[0];
if (shouldSkip(commandName))
if (shouldSkip(parts[0]))
{
Log.Warning("Skipping recursive command: {Command}", cmd);
continue;
}
}

if (sent) await Task.Delay(delayMs, cancellationToken);
await _framework.RunOnFrameworkThread(() => Chat.SendMessage(cmd));
if (i < commands.Count - 1) await Task.Delay(delayMs, cancellationToken);
sent = true;

}
}

private bool EvaluateSafe(string expression, IReadOnlyList<string> args)
{
try
{
return _conditions.Evaluate(expression, args);
}
catch (ConditionException ex)
{
Log.Warning(ex, "Invalid condition: {Expression}", expression);
return false;
}
}
}
51 changes: 51 additions & 0 deletions Silkstring/Services/Conditions/BlockInterpreter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using System;
using System.Collections.Generic;

namespace Silkstring.Services.Conditions;

internal enum BlockKind
{
Command,
If,
Else,
EndIf
}

internal sealed class BlockInterpreter
{
private readonly Stack<Block> _stack = new();
public bool Active => _stack.Count == 0 || _stack.Peek().Active;

public static (BlockKind Kind, string Expression) Classify(string line)
{
if (line.StartsWith(":if ", StringComparison.OrdinalIgnoreCase)) return (BlockKind.If, line[4..]);
if (line.Equals(":else", StringComparison.OrdinalIgnoreCase)) return (BlockKind.Else, "");
if (line.Equals(":endif", StringComparison.OrdinalIgnoreCase)) return (BlockKind.EndIf, "");
return (BlockKind.Command, line);
}

public void EnterIf(bool conditionMet)
{
var parentActive = Active;
_stack.Push(new Block { ParentActive = parentActive, ConditionTrue = conditionMet, Active = parentActive && conditionMet });
}

public void Else()
{
if (_stack.Count == 0) return;
var block = _stack.Peek();
block.Active = block.ParentActive && !block.ConditionTrue;
}

public void EndIf()
{
if (_stack.Count > 0) _stack.Pop();
}

private sealed class Block
{
public bool ParentActive;
public bool ConditionTrue;
public bool Active;
}
}
44 changes: 44 additions & 0 deletions Silkstring/Services/Conditions/ConditionEvaluator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
using System.Globalization;
namespace Silkstring.Services.Conditions;

public sealed class ConditionEvaluator
{
private readonly Func<string, IReadOnlyList<string>, string> _resolve;

public ConditionEvaluator(Func<string, IReadOnlyList<string>, string> resolve) => _resolve = resolve;

public bool Evaluate(string expression, IReadOnlyList<string> args)
{
var ast = new Parser(Tokenizer.Tokenize(expression)).Parse();
return Eval(ast, args);
}

private bool Eval(ConditionNode node, IReadOnlyList<string> args) => node switch
{
OrNode o => Eval(o.L, args) || Eval(o.R, args),
AndNode a => Eval(a.L, args) && Eval(a.R, args),
CmpNode c => Compare(_resolve(c.L, args), c.Op, _resolve(c.R, args)),
BareNode b => string.Equals(_resolve(b.Value, args), "true", StringComparison.OrdinalIgnoreCase),
_ => false
};

private static bool Compare(string left, string op, string right)
{
var numeric = double.TryParse(left, NumberStyles.Any, CultureInfo.InvariantCulture, out var ln)
& double.TryParse(right, NumberStyles.Any, CultureInfo.InvariantCulture, out var rn);
return op switch
{
"==" => numeric ? ln == rn : string.Equals(left, right, StringComparison.OrdinalIgnoreCase),
"!=" => numeric ? ln != rn : !string.Equals(left, right, StringComparison.OrdinalIgnoreCase),
"<" => numeric ? ln < rn : string.Compare(left, right, StringComparison.OrdinalIgnoreCase) < 0,
">" => numeric ? ln > rn : string.Compare(left, right, StringComparison.OrdinalIgnoreCase) > 0,
"<=" => numeric ? ln <= rn : string.Compare(left, right, StringComparison.OrdinalIgnoreCase) <= 0,
">=" => numeric ? ln >= rn : string.Compare(left, right, StringComparison.OrdinalIgnoreCase) >= 0,
_ => false

};
}

}
8 changes: 8 additions & 0 deletions Silkstring/Services/Conditions/ConditionException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using System;

namespace Silkstring.Services.Conditions;

public sealed class ConditionException : Exception
{
public ConditionException(string message) : base(message) { }
}
6 changes: 6 additions & 0 deletions Silkstring/Services/Conditions/ConditionNode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
internal abstract record ConditionNode;

internal sealed record OrNode(ConditionNode L, ConditionNode R) : ConditionNode;
internal sealed record AndNode(ConditionNode L, ConditionNode R) : ConditionNode;
internal sealed record CmpNode(string L, string Op, string R) : ConditionNode;
internal sealed record BareNode(string Value) : ConditionNode;
64 changes: 64 additions & 0 deletions Silkstring/Services/Conditions/Parser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using System.Collections.Generic;

namespace Silkstring.Services.Conditions;

internal sealed class Parser
{
private readonly List<Token> _tokens;
private int _pos;

public Parser(List<Token> tokens) => _tokens = tokens;

private Token? Peek => _pos < _tokens.Count ? _tokens[_pos] : null;
private void Next() => _pos++;
private bool IsOp(string s) => Peek is { Kind: TokenKind.Op } t && t.Text == s;
private static bool IsComparison(string s) => s is "==" or "!=" or "<" or ">" or "<=" or ">=";

public ConditionNode Parse()
{
var n = ParseOr();
if (Peek is { } leftover) throw new ConditionException($"Unexpected '{leftover.Text}'");
return n;
}

private ConditionNode ParseOr()
{
var n = ParseAnd();
while (IsOp("||")) { Next(); n = new OrNode(n, ParseAnd()); }
return n;
}

private ConditionNode ParseAnd()
{
var n = ParseCmp();
while (IsOp("&&")) { Next(); n = new AndNode(n, ParseCmp()); }
return n;
}

private ConditionNode ParseCmp()
{
if (Peek is { Kind: TokenKind.LParen })
{
Next();
var n = ParseOr();
if (Peek is not { Kind: TokenKind.RParen }) throw new ConditionException("Expected ')'");
Next();
return n;
}

var left = ExpectOperand();
if (Peek is { Kind: TokenKind.Op } op && IsComparison(op.Text))
{
Next();
return new CmpNode(left, op.Text, ExpectOperand());
}
return new BareNode(left);
}

private string ExpectOperand()
{
if (Peek is not { Kind: TokenKind.Operand } t) throw new ConditionException("Expected a value");
Next();
return t.Text;
}
}
Loading
Loading