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.4.0.0 - TBD
### Added
- Waits: add a `:wait` line to pause an alias for a set number of seconds before the next line, for example `:wait 2`. Decimals work too, like `:wait 1.5`
- A `:wait` can take its duration from a variable or parameter, such as `:wait {0}`, and is capped at 60 seconds
- The alias editor warns you when a `:wait` has an invalid duration

## v1.3.0.0 - 2026-06-30
### Added
- Your own variables: create variables in the new Variables window (open it with `/silkstring variables`), give each one a value, and use it in any alias with `{name}`, just like the built-in ones
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,16 @@ A condition compares values with `==`, `!=`, `<`, `>`, `<=`, `>=`, and you can c

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.

You can also pause between lines with `:wait`, followed by a number of seconds:

```
/ac Raise
:wait 2
/say Up you get!
```

The wait accepts decimals (`:wait 1.5`) and can take its duration from a variable or parameter (`:wait {0}`). Waits are capped at 60 seconds, and a `:wait` inside a condition only pauses when that branch actually runs. Like the other control lines, `:wait` is never sent to chat, and the editor warns you if a duration is invalid.

### User Variables

Alongside the built-in variables, you can define your own. Open the Variables window with `/silkstring variables`, type a name, and give it a value. Names can use letters, numbers, and underscores, and cannot reuse a built-in variable name.
Expand Down
21 changes: 15 additions & 6 deletions Silkstring/Services/AliasValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ public static List<string> FindCycle(AliasEntry target, IEnumerable<AliasEntry>
break;
}
}

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

Expand All @@ -59,16 +58,27 @@ public static List<string> FindCycle(AliasEntry target, IEnumerable<AliasEntry>
return null;
}

public static string? ValidateWaits(AliasEntry alias)
{
foreach (var command in alias.Output)
{
var (kind, expression) = BlockInterpreter.Classify(command.Command);
if (kind != BlockKind.Wait) continue;
var (value, _) = BlockInterpreter.ParseSet(expression);
if (string.IsNullOrEmpty(value)) return ":wait needs a duration";
if (expression.Contains('{')) continue;
if (!BlockInterpreter.TryParseDuration(expression, out _)) return $"Invalid :wait duration: {expression}";
}
return null;
}

private static Dictionary<string, AliasEntry> BuildTriggerLookup(IEnumerable<AliasEntry> allAliases)
{
var lookup = new Dictionary<string, AliasEntry>(StringComparer.OrdinalIgnoreCase);
foreach (var alias in allAliases)
{
var triggers = alias.Name.Split('|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
foreach (var trigger in triggers)
{
lookup.TryAdd(trigger, alias);
}
foreach (var trigger in triggers) lookup.TryAdd(trigger, alias);
}
return lookup;
}
Expand Down Expand Up @@ -113,7 +123,6 @@ private static List<string> Dfs(
if (result.Count > 0) return result;
}
}

path.Remove(trigger);
visited.Add(trigger);
return new List<string>();
Expand Down
13 changes: 13 additions & 0 deletions Silkstring/Services/CommandHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,19 @@ await _framework.RunOnFrameworkThread(() =>
continue;
}

if (kind == BlockKind.Wait)
{
if (blocks.Active)
{
var resolved = await _framework.RunOnFrameworkThread(() => _resolver.Resolve(expression, args));
if (BlockInterpreter.TryParseDuration(resolved, out var ms))
await Task.Delay(ms, cancellationToken);
else
Log.Warning("Invalid :wait duration: {Duration}", resolved);
}
continue;
}

if (!blocks.Active) continue;

var cmd = await _framework.RunOnFrameworkThread(() => _resolver.Resolve(line, args));
Expand Down
14 changes: 13 additions & 1 deletion Silkstring/Services/Conditions/BlockInterpreter.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;

namespace Silkstring.Services.Conditions;

Expand All @@ -9,7 +10,8 @@ internal enum BlockKind
If,
Else,
EndIf,
Set
Set,
Wait
}

internal sealed class BlockInterpreter
Expand All @@ -23,6 +25,7 @@ public static (BlockKind Kind, string Expression) Classify(string line)
if (line.Equals(":else", StringComparison.OrdinalIgnoreCase)) return (BlockKind.Else, "");
if (line.Equals(":endif", StringComparison.OrdinalIgnoreCase)) return (BlockKind.EndIf, "");
if (line.StartsWith(":set ", StringComparison.OrdinalIgnoreCase)) return (BlockKind.Set, line[5..]);
if (line.StartsWith(":wait ", StringComparison.OrdinalIgnoreCase)) return (BlockKind.Wait, line[6..]);
return (BlockKind.Command, line);
}

Expand All @@ -33,6 +36,15 @@ public static (string Name, string Value) ParseSet(string expression)
return space < 0 ? (trimmed, "") : (trimmed[..space], trimmed[(space + 1)..].Trim());
}

public static bool TryParseDuration(string text, out int milliseconds)
{
milliseconds = 0;
if (!double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds) || seconds < 0)
return false;
milliseconds = (int)Math.Min(seconds * 1000, 60000);
return true;
}

public void EnterIf(bool conditionMet)
{
var parentActive = Active;
Expand Down
2 changes: 1 addition & 1 deletion Silkstring/UI/Panels/AliasEditPanel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ private void RefreshCycleCheck()
if (_selectedAlias == null) return;
_detectedCycle = AliasValidator.FindCycle(_selectedAlias, _configuration.GetAliases());
var defined = new HashSet<string>(_configuration.UserVariables.Select(v => v.Name), StringComparer.OrdinalIgnoreCase);
_blockError = AliasValidator.ValidateBlocks(_selectedAlias) ?? AliasValidator.ValidateSets(_selectedAlias, defined);
_blockError = AliasValidator.ValidateBlocks(_selectedAlias) ?? AliasValidator.ValidateSets(_selectedAlias, defined) ?? AliasValidator.ValidateWaits(_selectedAlias);
}

private void SyncMultilineBuffer(AliasEntry alias)
Expand Down
14 changes: 13 additions & 1 deletion Silkstring/Windows/HelpWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ private void DrawCommandsHelp()
ImGui.TextColored(HeadingColor, "Conditionals");
ImGui.Separator();
ImGui.Spacing();
ImGui.TextWrapped("Run commands only when a condition is true with :if / :else / :endif blocks. See the Conditionals tab.");
ImGui.TextWrapped("Run commands only when a condition is true with :if / :else / :endif blocks, and pause with :wait. See the Conditionals tab.");
ImGui.Spacing();

ImGui.TextColored(HeadingColor, "Macros");
Expand Down Expand Up @@ -364,5 +364,17 @@ private void DrawConditionalsHelp()
ImGui.TextDisabled(":if {0} == on");
ImGui.Unindent();
ImGui.Spacing();

ImGui.TextColored(HeadingColor, "Waits");
ImGui.Separator();
ImGui.Spacing();
ImGui.TextWrapped("Pause between lines with :wait, followed by a number of seconds. Decimals work, and the duration can come from a variable or parameter. Waits are capped at 60 seconds, and a :wait inside a condition only pauses when that branch runs. Like the other control lines, it is never sent to chat.");
ImGui.Spacing();
ImGui.Indent();
ImGui.TextDisabled("/ac Raise");
ImGui.TextDisabled(":wait 2");
ImGui.TextDisabled("/say Up you get!");
ImGui.Unindent();
ImGui.Spacing();
}
}
9 changes: 9 additions & 0 deletions Tests/AliasValidatorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,13 @@ public void NonSlashLineIsNotADependency()
[Fact] public void SetNoName() => Assert.NotNull(AliasValidator.ValidateSets(Alias("a", ":set "), Defined("foo")));
[Fact] public void SetOneBadAmongGood() => Assert.NotNull(AliasValidator.ValidateSets(Alias("a", ":set foo a", ":set bar b"), Defined("foo")));
[Fact] public void SetNonSetLinesIgnored() => Assert.Null(AliasValidator.ValidateSets(Alias("a", "/say hi"), Defined()));

[Fact] public void WaitValid() => Assert.Null(AliasValidator.ValidateWaits(Alias("a", ":wait 2")));
[Fact] public void WaitDecimal() => Assert.Null(AliasValidator.ValidateWaits(Alias("a", ":wait 1.5")));
[Fact] public void WaitToken() => Assert.Null(AliasValidator.ValidateWaits(Alias("a", ":wait {0}")));
[Fact] public void WaitOverCapIsValid() => Assert.Null(AliasValidator.ValidateWaits(Alias("a", ":wait 120")));
[Fact] public void WaitInvalid() => Assert.NotNull(AliasValidator.ValidateWaits(Alias("a", ":wait potato")));
[Fact] public void WaitNegative() => Assert.NotNull(AliasValidator.ValidateWaits(Alias("a", ":wait -1")));
[Fact] public void WaitEmpty() => Assert.NotNull(AliasValidator.ValidateWaits(Alias("a", ":wait ")));
[Fact] public void WaitNonWaitLinesIgnored() => Assert.Null(AliasValidator.ValidateWaits(Alias("a", "/say hi")));
}
18 changes: 18 additions & 0 deletions Tests/BlockInterpreterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ public class BlockInterpreterTests
[InlineData(":set foo bar", "Set", "foo bar")]
[InlineData(":SET foo bar", "Set", "foo bar")]
[InlineData(":set", "Command", ":set")]
[InlineData(":wait 2", "Wait", "2")]
[InlineData(":WAIT 1.5", "Wait", "1.5")]
[InlineData(":wait", "Command", ":wait")]
public void Classifies(string line, string kind, string expression)
{
var (k, e) = BlockInterpreter.Classify(line);
Expand All @@ -32,6 +35,21 @@ public void ParsesSet(string expression, string name, string value)
Assert.Equal(value, v);
}

[Theory]
[InlineData("2", true, 2000)]
[InlineData("1.5", true, 1500)]
[InlineData("0", true, 0)]
[InlineData("120", true, 60000)]
[InlineData("-1", false, 0)]
[InlineData("abc", false, 0)]
[InlineData("{0}", false, 0)]
[InlineData("", false, 0)]
public void ParsesDuration(string text, bool expected, int milliseconds)
{
Assert.Equal(expected, BlockInterpreter.TryParseDuration(text, out var ms));
Assert.Equal(milliseconds, ms);
}

[Fact]
public void EmptyIsActive() => Assert.True(new BlockInterpreter().Active);

Expand Down
Loading