diff options
Diffstat (limited to 'src/Godosa.Core/Runs/Scripts/Condition.cs')
| -rw-r--r-- | src/Godosa.Core/Runs/Scripts/Condition.cs | 234 |
1 files changed, 234 insertions, 0 deletions
diff --git a/src/Godosa.Core/Runs/Scripts/Condition.cs b/src/Godosa.Core/Runs/Scripts/Condition.cs new file mode 100644 index 0000000..346ee3b --- /dev/null +++ b/src/Godosa.Core/Runs/Scripts/Condition.cs @@ -0,0 +1,234 @@ +// Provenance: own design — shared run scripts (spec §3.6) +using System.Globalization; +using System.Text; + +namespace Godosa.Core.Runs.Scripts; + +public interface IConditionContext +{ + bool HasProbe(string name); + RunValue Probe(string name, IReadOnlyList<string> args); + long? Constant(string name); +} + +/// <summary>Condition AST: or / and / not / comparison / atom. A bare identifier is a probe without arguments when the +/// game has one by that name, else a game constant.</summary> +public abstract record Cond +{ + public abstract RunValue Eval(IConditionContext c); + + public bool Test(IConditionContext c) => Truthy(Eval(c)); + + public abstract IEnumerable<(string Name, IReadOnlyList<string> Args)> Probes(); + + public abstract IEnumerable<string> Bare(); + + public static string Describe(Cond cond, IConditionContext c) => + string.Join(" ", cond.Probes().Where(p => c.HasProbe(p.Name)).Distinct().Select(p => + { + var label = p.Args.Count == 0 ? p.Name : $"{p.Name}({string.Join(",", p.Args)})"; + return $"{label}={c.Probe(p.Name, p.Args)}"; + })); + + internal static bool Truthy(RunValue v) => v.IsNumber ? v.Number != 0 : !string.IsNullOrEmpty(v.Text); +} + +public sealed record OrCond(Cond A, Cond B) : Cond +{ + public override RunValue Eval(IConditionContext c) => RunValue.Of(A.Test(c) || B.Test(c)); + public override IEnumerable<(string, IReadOnlyList<string>)> Probes() => A.Probes().Concat(B.Probes()); + public override IEnumerable<string> Bare() => A.Bare().Concat(B.Bare()); +} + +public sealed record AndCond(Cond A, Cond B) : Cond +{ + public override RunValue Eval(IConditionContext c) => RunValue.Of(A.Test(c) && B.Test(c)); + public override IEnumerable<(string, IReadOnlyList<string>)> Probes() => A.Probes().Concat(B.Probes()); + public override IEnumerable<string> Bare() => A.Bare().Concat(B.Bare()); +} + +public sealed record NotCond(Cond A) : Cond +{ + public override RunValue Eval(IConditionContext c) => RunValue.Of(!A.Test(c)); + public override IEnumerable<(string, IReadOnlyList<string>)> Probes() => A.Probes(); + public override IEnumerable<string> Bare() => A.Bare(); +} + +public sealed record CompareCond(Cond A, string Op, Cond B) : Cond +{ + public override RunValue Eval(IConditionContext c) + { + var (a, b) = (A.Eval(c), B.Eval(c)); + if (a.IsNumber != b.IsNumber) + throw new ScriptFault($"cannot compare {a} with {b}"); + var order = a.IsNumber ? a.Number!.Value.CompareTo(b.Number!.Value) : string.CompareOrdinal(a.Text, b.Text); + if (!a.IsNumber && Op is "<" or "<=" or ">" or ">=") + throw new ScriptFault($"cannot order strings {a} and {b}"); + return RunValue.Of(Op switch + { + "=" => order == 0, "!=" => order != 0, "<" => order < 0, "<=" => order <= 0, ">" => order > 0, _ => order >= 0, + }); + } + + public override IEnumerable<(string, IReadOnlyList<string>)> Probes() => A.Probes().Concat(B.Probes()); + public override IEnumerable<string> Bare() => A.Bare().Concat(B.Bare()); +} + +public sealed record LiteralCond(RunValue Value) : Cond +{ + public override RunValue Eval(IConditionContext c) => Value; + public override IEnumerable<(string, IReadOnlyList<string>)> Probes() => []; + public override IEnumerable<string> Bare() => []; +} + +/// <summary>A probe call <c>name(args)</c>, or a bare <c>name</c> (probe if the game has it, else a constant).</summary> +public sealed record NameCond(string Name, IReadOnlyList<string> Args, bool HasParens) : Cond +{ + public override RunValue Eval(IConditionContext c) + { + if (HasParens || c.HasProbe(Name)) + return c.Probe(Name, Args); + return c.Constant(Name) is { } n ? RunValue.Of(n) : throw new ScriptFault($"unknown probe or constant {Name}"); + } + + public override IEnumerable<(string, IReadOnlyList<string>)> Probes() => [(Name, Args)]; + public override IEnumerable<string> Bare() => HasParens ? [] : [Name]; +} + +public static class ConditionParser +{ + public static Cond Parse(string text) + { + var tokens = Lex(text); + var pos = 0; + var cond = Or(tokens, ref pos); + if (pos != tokens.Count) + throw new FormatException($"unexpected '{tokens[pos]}' in condition '{text}'"); + return cond; + } + + private static Cond Or(List<string> t, ref int pos) + { + var a = And(t, ref pos); + while (pos < t.Count && t[pos] == "or") + { + pos++; + a = new OrCond(a, And(t, ref pos)); + } + return a; + } + + private static Cond And(List<string> t, ref int pos) + { + var a = Not(t, ref pos); + while (pos < t.Count && t[pos] == "and") + { + pos++; + a = new AndCond(a, Not(t, ref pos)); + } + return a; + } + + private static Cond Not(List<string> t, ref int pos) + { + if (pos < t.Count && t[pos] == "not") + { + pos++; + return new NotCond(Not(t, ref pos)); + } + return Compare(t, ref pos); + } + + private static Cond Compare(List<string> t, ref int pos) + { + var a = Atom(t, ref pos); + if (pos < t.Count && t[pos] is "=" or "!=" or "<" or "<=" or ">" or ">=") + { + var op = t[pos++]; + return new CompareCond(a, op, Atom(t, ref pos)); + } + return a; + } + + private static Cond Atom(List<string> t, ref int pos) + { + if (pos >= t.Count) + throw new FormatException("condition ends early"); + var tok = t[pos++]; + if (tok == "(") + { + var inner = Or(t, ref pos); + if (pos >= t.Count || t[pos] != ")") + throw new FormatException("missing ')'"); + pos++; + return inner; + } + if (tok.StartsWith('"')) + return new LiteralCond(RunValue.Of(tok[1..^1])); + if (long.TryParse(tok, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var n)) + return new LiteralCond(RunValue.Of(n)); + if (!char.IsLetter(tok[0]) && tok[0] != '_') + throw new FormatException($"unexpected '{tok}'"); + var paren = tok.IndexOf('('); + if (paren < 0) + return new NameCond(tok, [], false); + var args = tok[(paren + 1)..^1].Split(',', StringSplitOptions.TrimEntries).Where(a => a.Length > 0).ToArray(); + return new NameCond(tok[..paren], args, true); + } + + private static List<string> Lex(string text) + { + var tokens = new List<string>(); + var i = 0; + while (i < text.Length) + { + var c = text[i]; + if (c == ' ') + { + i++; + continue; + } + if (c is '(' or ')') + { + tokens.Add(c.ToString()); + i++; + continue; + } + if (c == '"') + { + var end = text.IndexOf('"', i + 1); + if (end < 0) + throw new FormatException("unterminated quote"); + tokens.Add(text[i..(end + 1)]); + i = end + 1; + continue; + } + if (c is '=' or '!' or '<' or '>') + { + var two = i + 1 < text.Length && text[i + 1] == '='; + var op = text.Substring(i, two ? 2 : 1); + if (op == "!") + throw new FormatException("use != or not"); + tokens.Add(op); + i += op.Length; + continue; + } + var sb = new StringBuilder(); + var depth = 0; + while (i < text.Length && (depth > 0 || (text[i] != ' ' && text[i] != ')' && !(text[i] is '=' or '!' or '<' or '>')))) + { + if (text[i] == '(' && sb.Length == 0) + break; + if (text[i] == '(') + depth++; + else if (text[i] == ')') + depth--; + sb.Append(text[i++]); + } + if (sb.Length == 0) + throw new FormatException($"unexpected '{text[i]}'"); + tokens.Add(sb.ToString()); + } + return tokens; + } +} |
