diff options
| author | godosa <godosa@godosa.eu> | 2026-10-06 23:39:36 +0200 |
|---|---|---|
| committer | godosa <godosa@godosa.eu> | 2026-10-06 23:39:36 +0200 |
| commit | 39066900773e7857faf2d02e7ed51b71d219d97d (patch) | |
| tree | ffab8e4ddd971626776c3b1f4ce1da2c186c2187 /src/Godosa.Core/Runs/Scripts | |
| download | godosa-engine-39066900773e7857faf2d02e7ed51b71d219d97d.tar.gz godosa-engine-39066900773e7857faf2d02e7ed51b71d219d97d.zip | |
godosa-engine: initial public history
Diffstat (limited to 'src/Godosa.Core/Runs/Scripts')
18 files changed, 2292 insertions, 0 deletions
diff --git a/src/Godosa.Core/Runs/Scripts/CheckpointCache.cs b/src/Godosa.Core/Runs/Scripts/CheckpointCache.cs new file mode 100644 index 0000000..1e1dd3a --- /dev/null +++ b/src/Godosa.Core/Runs/Scripts/CheckpointCache.cs @@ -0,0 +1,112 @@ +// Provenance: own design — shared run scripts (spec §5.4) +using System.Buffers.Binary; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Godosa.Core.Runs.Scripts; + +public sealed record LabelState(string Name, long Start, string File, int Line); + +public sealed record RunnerState(long Frame, ulong Digest, List<ScopeState> Scopes, List<LabelState> Labels); + +public sealed record CachedCheckpoint(RunnerState State, byte[] Game); + +public static class CheckpointKeys +{ + public const string RunnerVersion = "run-scripts 1"; + + public static IReadOnlyDictionary<int, string> Compute(ScriptProgram p, string contentVersion, ulong seed) + { + var d = new StateDigest(); + d.Add(RunnerVersion); + d.Add(contentVersion); + d.Add(seed); + d.Add((int)p.Mode); + foreach (var (k, v) in p.Vars.OrderBy(kv => kv.Key, StringComparer.Ordinal)) + d.Add($"{k}={v}"); + var keys = new Dictionary<int, string>(); + for (var i = 0; i < p.Code.Count; i++) + { + var instr = p.Code[i]; + d.Add((int)instr.Op); + d.Add(instr.Format); + d.Add(instr.At.File); + d.Add(instr.Text); + if (instr.Op == Op.Checkpoint) + keys[i] = d.Hex; + } + return keys; + } +} + +public sealed class CheckpointCache(string dir, long capBytes) +{ + private static readonly byte[] Magic = "GRS1"u8.ToArray(); + private static readonly JsonSerializerOptions Json = new() { Converters = { new JsonStringEnumConverter() } }; + + public CachedCheckpoint? Load(string key) + { + var path = Path.Combine(dir, key + ".snap"); + if (!File.Exists(path)) + return null; + byte[] bytes; + try + { + bytes = File.ReadAllBytes(path); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + return null; + } + if (bytes.Length < 8 || !bytes.AsSpan(0, 4).SequenceEqual(Magic)) + return null; + var length = BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(4)); + if (length < 0 || 8 + length > bytes.Length) + return null; + RunnerState? state; + try + { + state = JsonSerializer.Deserialize<RunnerState>(Encoding.UTF8.GetString(bytes, 8, length), Json); + } + catch (JsonException) + { + return null; // a corrupt entry is a miss + } + return state == null ? null : new CachedCheckpoint(state, bytes[(8 + length)..]); + } + + public void Save(string key, CachedCheckpoint cp) + { + Directory.CreateDirectory(dir); + var json = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(cp.State, Json)); + var path = Path.Combine(dir, key + ".snap"); + var tmp = path + ".tmp"; + using (var f = File.Create(tmp)) + { + f.Write(Magic); + Span<byte> len = stackalloc byte[4]; + BinaryPrimitives.WriteInt32LittleEndian(len, json.Length); + f.Write(len); + f.Write(json); + f.Write(cp.Game); + } + File.Move(tmp, path, overwrite: true); + Evict(path); + } + + private void Evict(string keep) + { + var files = new DirectoryInfo(dir).GetFiles("*.snap").OrderBy(f => f.LastWriteTimeUtc).ToList(); + var total = files.Sum(f => f.Length); + foreach (var f in files) + { + if (total <= capBytes) + break; + if (f.FullName == Path.GetFullPath(keep)) + continue; + total -= f.Length; + f.Delete(); + } + } +} 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; + } +} diff --git a/src/Godosa.Core/Runs/Scripts/Input.cs b/src/Godosa.Core/Runs/Scripts/Input.cs new file mode 100644 index 0000000..d549bb4 --- /dev/null +++ b/src/Godosa.Core/Runs/Scripts/Input.cs @@ -0,0 +1,24 @@ +// Provenance: own design — shared run scripts (spec §5.5) +namespace Godosa.Core.Runs.Scripts; + +public enum InputKind { Move, Down, Up, Wheel, KeyDown, KeyUp, Text } + +public enum MouseButton { Left, Right } + +/// <summary>One simulated user input event at virtual-screen coordinates.</summary> +public sealed record InputEvent(InputKind Kind, int X = 0, int Y = 0, MouseButton Button = MouseButton.Left, string Key = "", + string Text = "", int Delta = 0); + +/// <summary>An event due <see cref="Frame"/> frames after its step began.</summary> +public sealed record PlannedInput(int Frame, InputEvent Event); + +public sealed record InputPlan(IReadOnlyList<PlannedInput> Events) +{ + public int LastFrame => Events.Count == 0 ? 0 : Events.Max(e => e.Frame); +} + +/// <summary>The game's input entry: the real UI in a window, its layout + hit-testing headless.</summary> +public interface IInputRouter +{ + void Send(InputEvent e); +} diff --git a/src/Godosa.Core/Runs/Scripts/InputPlans.cs b/src/Godosa.Core/Runs/Scripts/InputPlans.cs new file mode 100644 index 0000000..9c150b3 --- /dev/null +++ b/src/Godosa.Core/Runs/Scripts/InputPlans.cs @@ -0,0 +1,111 @@ +// Provenance: own design — shared run scripts (spec §4.1) +using System.Globalization; + +namespace Godosa.Core.Runs.Scripts; + +public static class Positions +{ + /// <summary><c>x,y</c> or a locator <c>@name(args)</c> (args comma separated).</summary> + public static (int X, int Y) Resolve(string token, IRunGame game) + { + if (token.StartsWith('@')) + { + var paren = token.IndexOf('('); + var name = paren > 0 ? token[1..paren] : token[1..]; + var args = paren > 0 && token.EndsWith(')') + ? token[(paren + 1)..^1].Split(',', StringSplitOptions.TrimEntries).Where(a => a.Length > 0).ToArray() + : []; + if (!game.Locators.TryGetValue(name, out var locator)) + throw new ScriptFault($"unknown locator @{name}"); + var r = locator.Locate(args); + return r.IsOk ? (r.X, r.Y) : throw new ScriptFault($"locator {token}: {r.Error}"); + } + var parts = token.Split(','); + if (parts.Length == 2 && int.TryParse(parts[0], NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var x) + && int.TryParse(parts[1], NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var y)) + return (x, y); + throw new ScriptFault($"bad position '{token}'"); + } +} + +public static class InputPlans +{ + public static bool IsInputVerb(string verb) => verb is "click" or "rclick" or "dclick" or "drag" or "move" or "wheel" + or "key" or "hold" or "chord" or "type"; + + public static InputPlan For(string verb, IReadOnlyList<Token> args, Func<string, (int X, int Y)> position) + { + List<PlannedInput> e = []; + void Add(int frame, InputEvent ev) => e.Add(new PlannedInput(frame, ev)); + switch (verb) + { + case "click" or "rclick" or "dclick": + { + var (x, y) = position(args[0].Text); + var button = verb == "rclick" ? MouseButton.Right : MouseButton.Left; + Add(0, new InputEvent(InputKind.Move, x, y)); + Add(0, new InputEvent(InputKind.Down, x, y, button)); + Add(1, new InputEvent(InputKind.Up, x, y, button)); + if (verb == "dclick") + { + Add(2, new InputEvent(InputKind.Down, x, y, button)); + Add(3, new InputEvent(InputKind.Up, x, y, button)); + } + break; + } + case "drag": + { + var (ax, ay) = position(args[0].Text); + var (bx, by) = position(args[1].Text); + Add(0, new InputEvent(InputKind.Move, ax, ay)); + Add(0, new InputEvent(InputKind.Down, ax, ay)); + for (var step = 1; step <= 3; step++) + Add(step, new InputEvent(InputKind.Move, ax + (bx - ax) * step / 3, ay + (by - ay) * step / 3)); + Add(4, new InputEvent(InputKind.Up, bx, by)); + break; + } + case "move": + { + var (x, y) = position(args[0].Text); + Add(0, new InputEvent(InputKind.Move, x, y)); + break; + } + case "wheel": + { + var delta = int.TryParse(args[0].Text, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var d) + ? d : throw new ScriptFault($"bad wheel delta '{args[0].Text}'"); + var (x, y) = args.Count > 1 ? position(args[1].Text) : (0, 0); + if (args.Count > 1) + Add(0, new InputEvent(InputKind.Move, x, y)); + Add(0, new InputEvent(InputKind.Wheel, x, y, Delta: delta)); + break; + } + case "key": + Add(0, new InputEvent(InputKind.KeyDown, Key: args[0].Text)); + Add(1, new InputEvent(InputKind.KeyUp, Key: args[0].Text)); + break; + case "hold": + { + var frames = Durations.Frames(args[1].Text) is { } f and > 0 ? f : throw new ScriptFault($"bad hold frames '{args[1].Text}'"); + Add(0, new InputEvent(InputKind.KeyDown, Key: args[0].Text)); + Add(frames, new InputEvent(InputKind.KeyUp, Key: args[0].Text)); + break; + } + case "chord": + { + var keys = args[0].Text.Split('+', StringSplitOptions.RemoveEmptyEntries); + foreach (var k in keys) + Add(0, new InputEvent(InputKind.KeyDown, Key: k)); + foreach (var k in keys.Reverse()) + Add(1, new InputEvent(InputKind.KeyUp, Key: k)); + break; + } + case "type": + Add(0, new InputEvent(InputKind.Text, Text: args[0].Text)); + break; + default: + throw new ScriptFault($"not an input verb: {verb}"); + } + return new InputPlan(e); + } +} diff --git a/src/Godosa.Core/Runs/Scripts/LibVerbs.cs b/src/Godosa.Core/Runs/Scripts/LibVerbs.cs new file mode 100644 index 0000000..5659b0c --- /dev/null +++ b/src/Godosa.Core/Runs/Scripts/LibVerbs.cs @@ -0,0 +1,32 @@ +// Provenance: own design — shared run scripts (spec §4.1, §4.2) +namespace Godosa.Core.Runs.Scripts; + +/// <summary>A verb the lib defines. <see cref="GameHook"/> = implemented by the game's <see cref="IRunGame.Actions"/> +/// entry of the same name; otherwise the runner implements it.</summary> +public sealed record VerbSpec(string Name, VerbKind Kind, int MinArgs, int MaxArgs, bool GameHook); + +public static class LibVerbs +{ + private const VerbKind P = VerbKind.Player, D = VerbKind.Debug; + + public static IReadOnlyList<VerbSpec> All { get; } = + [ + new("click", P, 1, 1, false), new("rclick", P, 1, 1, false), new("dclick", P, 1, 1, false), + new("drag", P, 2, 2, false), new("move", P, 1, 1, false), new("wheel", P, 1, 2, false), + new("key", P, 1, 1, false), new("hold", P, 2, 2, false), new("chord", P, 1, 1, false), new("type", P, 1, 1, false), + new("wait-frames", P, 1, 1, false), new("rng", P, 2, 3, false), new("log", P, 1, 1, false), new("output", P, 1, 1, false), + new("screenshot", P, 1, 1, true), new("save", P, 1, 1, true), new("load", P, 1, 1, true), + new("walk-to", P, 1, 1, true), new("talk", P, 1, 1, true), new("answer", P, 1, 1, true), new("attack", P, 1, 2, true), + new("use", P, 1, 3, true), new("equip", P, 1, 1, true), new("drop", P, 1, 1, true), new("take", P, 1, 1, true), + new("open-panel", P, 1, 1, true), new("close-panel", P, 0, 0, true), new("travel-to", P, 1, 1, true), + new("rest", P, 1, 1, true), new("buy", P, 3, 3, true), new("sell", P, 3, 3, true), + new("cheat", D, 1, 20, true), new("option", D, 2, 2, true), new("teleport", D, 2, 2, true), + new("talk-direct", D, 1, 1, true), new("kill", D, 1, 1, true), new("give", D, 1, 2, true), new("set-state", D, 2, 2, true), + ]; + + private static readonly Dictionary<string, VerbSpec> ByName = All.ToDictionary(v => v.Name, StringComparer.Ordinal); + + public static VerbSpec? Find(string name) => ByName.GetValueOrDefault(name); + + public static readonly string[] OutputLevels = ["quiet", "checkpoints", "labels", "steps", "trace"]; +} diff --git a/src/Godosa.Core/Runs/Scripts/RunGame.cs b/src/Godosa.Core/Runs/Scripts/RunGame.cs new file mode 100644 index 0000000..a6222a6 --- /dev/null +++ b/src/Godosa.Core/Runs/Scripts/RunGame.cs @@ -0,0 +1,68 @@ +// Provenance: own design — shared run scripts (spec §6; one hook shape for §4.2 capabilities, see Actions) +namespace Godosa.Core.Runs.Scripts; + +/// <summary>A game's run-script adapter. <see cref="Actions"/> holds the lib's game-hooked verbs by their lib name +/// (<c>talk</c>, <c>walk-to</c>, <c>cheat</c>, …) and the game's own verbs. Player-kind actions return input plans only; +/// the lib injects them.</summary> +public interface IRunGame +{ + string Name { get; } + string ContentVersion { get; } + IInputRouter Input { get; } + IReadOnlyDictionary<string, IProbe> Probes { get; } + IReadOnlyDictionary<string, long> Constants { get; } + IReadOnlyDictionary<string, ILocator> Locators { get; } + IReadOnlyDictionary<string, IRunAction> Actions { get; } + IRunRng? Rng { get; } + bool SupportsSnapshot { get; } + byte[] SnapshotSave(); + /// <summary>Why the current state can't be checkpointed faithfully (e.g. state the snapshot doesn't hold), or null. + /// A refused checkpoint is not cached (warning); failure snapshots are still taken.</summary> + string? SnapshotRefusal() => null; + void SnapshotRestore(byte[] data); + /// <summary>The restore completes after this frame (a window rebuilds its game view): the runner pauses one frame + /// (<see cref="ScriptRunner.Restoring"/>), in which the host advances no game frame.</summary> + bool RestoreDeferred => false; + ulong Digest(); + string Dump(); + void Advance(); +} + +public interface IProbe +{ + int MinArgs { get; } + int MaxArgs { get; } + RunValue Eval(IReadOnlyList<string> args); +} + +public readonly record struct LocatorResult(int X, int Y, string? Error) +{ + public static LocatorResult Ok(int x, int y) => new(x, y, null); + public static LocatorResult Fail(string message) => new(0, 0, message); + public bool IsOk => Error == null; +} + +/// <summary>Finds where to aim (virtual-screen position) from game state, as a player reads the screen.</summary> +public interface ILocator +{ + LocatorResult Locate(IReadOnlyList<string> args); +} + +/// <summary>Begin returns an input plan to inject (player) and/or an immediate status (debug: usually Done); after the +/// plan is sent, Poll runs each frame until Done or Fault.</summary> +public interface IRunAction +{ + VerbKind Kind { get; } + int MinArgs { get; } + int MaxArgs { get; } + ActionStart Begin(IReadOnlyList<string> args); + StepStatus Poll(); +} + +public sealed record ActionStart(InputPlan? Plan = null, StepStatus? Status = null); + +public interface IRunRng +{ + void Seed(long seed); + void ForceNext(string kind, long value); +} diff --git a/src/Godosa.Core/Runs/Scripts/RunOutput.cs b/src/Godosa.Core/Runs/Scripts/RunOutput.cs new file mode 100644 index 0000000..4c75a6a --- /dev/null +++ b/src/Godosa.Core/Runs/Scripts/RunOutput.cs @@ -0,0 +1,21 @@ +// Provenance: own design — shared run scripts (spec §7.1) +namespace Godosa.Core.Runs.Scripts; + +/// <summary>Auto-output filter: a line of <c>level</c> prints when <c>level ≤ max(current, floor)</c>; <see cref="Always"/> +/// = log lines and failures.</summary> +public sealed class RunOutput(Action<string> write, OutputLevel floor) +{ + public void Emit(OutputLevel level, OutputLevel current, string line) + { + if (level <= (current > floor ? current : floor)) + write(line); + } + + public void Always(string line) => write(line); + + public static OutputLevel Parse(string name) => name switch + { + "quiet" => OutputLevel.Quiet, "checkpoints" => OutputLevel.Checkpoints, "labels" => OutputLevel.Labels, + "steps" => OutputLevel.Steps, "trace" => OutputLevel.Trace, _ => throw new ScriptFault($"bad output level '{name}'"), + }; +} diff --git a/src/Godosa.Core/Runs/Scripts/RunReport.cs b/src/Godosa.Core/Runs/Scripts/RunReport.cs new file mode 100644 index 0000000..f0ecf0d --- /dev/null +++ b/src/Godosa.Core/Runs/Scripts/RunReport.cs @@ -0,0 +1,58 @@ +// Provenance: own design — shared run scripts (spec §7.2, §7.3) +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Godosa.Core.Runs.Scripts; + +public sealed record CheckpointRecord(string Name, long Frame, string Digest, string Source); + +public sealed record FailureInfo(string At, IReadOnlyList<string> Chain, string Step, string? Label, string Reason, + IReadOnlyList<string> Recent, string Dump, IReadOnlyDictionary<string, string> Vars, string? Snapshot); + +public sealed record RunReport(string Script, RunMode Mode, ulong Seed, bool Deterministic, RunOutcome Outcome, long Frames, + int Steps, double WallSeconds, int CacheHits, int CacheMisses, IReadOnlyList<CheckpointRecord> Checkpoints, + IReadOnlyList<string> Warnings, FailureInfo? Failure); + +public static class ReportWriter +{ + private static readonly JsonSerializerOptions Json = new() { WriteIndented = true, Converters = { new JsonStringEnumConverter() } }; + + public static string Text(RunReport r) + { + var sb = new StringBuilder(); + sb.AppendLine($"script: {r.Script} mode: {r.Mode.ToString().ToLowerInvariant()} seed: {r.Seed} deterministic: {(r.Deterministic ? "yes" : "no")}"); + sb.AppendLine($"outcome: {r.Outcome} frames: {r.Frames} steps: {r.Steps} wall: {r.WallSeconds:0.0} s cache: {r.CacheHits} hit / {r.CacheMisses} miss"); + foreach (var c in r.Checkpoints) + sb.AppendLine($"checkpoint {c.Name} f={c.Frame} {c.Digest} ({c.Source})"); + foreach (var w in r.Warnings) + sb.AppendLine($"warning: {w}"); + if (r.Failure is { } f) + { + sb.AppendLine(); + sb.AppendLine($"FAILED at {string.Join(" → ", f.Chain.Append(f.At))}{(f.Label == null ? "" : $" [{f.Label}]")}"); + sb.AppendLine($" step: {f.Step}"); + sb.AppendLine($" reason: {f.Reason}"); + sb.AppendLine("recent steps:"); + foreach (var s in f.Recent) + sb.AppendLine($" {s}"); + sb.AppendLine("variables:"); + foreach (var (k, v) in f.Vars.OrderBy(kv => kv.Key, StringComparer.Ordinal)) + sb.AppendLine($" {k} = {v}"); + sb.AppendLine("game state:"); + sb.AppendLine(f.Dump); + if (f.Snapshot != null) + sb.AppendLine($"failure save: {f.Snapshot}"); + } + return sb.ToString(); + } + + public static void Write(string dir, RunReport report, byte[]? failSnapshot) + { + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "report.txt"), Text(report)); + File.WriteAllText(Path.Combine(dir, "report.json"), JsonSerializer.Serialize(report, Json)); + if (failSnapshot != null) + File.WriteAllBytes(Path.Combine(dir, "fail.snap"), failSnapshot); + } +} diff --git a/src/Godosa.Core/Runs/Scripts/ScriptAst.cs b/src/Godosa.Core/Runs/Scripts/ScriptAst.cs new file mode 100644 index 0000000..9fa2654 --- /dev/null +++ b/src/Godosa.Core/Runs/Scripts/ScriptAst.cs @@ -0,0 +1,25 @@ +// Provenance: own design — shared run scripts +namespace Godosa.Core.Runs.Scripts; + +public enum BlockKind { Setup, Step, Try } + +/// <summary>One parsed line (or block header) of a <c>.steps</c> file; <see cref="Text"/> = the trimmed source line.</summary> +public abstract record Node(SourceRef At, string Text); +public sealed record VerbNode(SourceRef At, string Text, string Verb, string Args) : Node(At, Text); +public sealed record WaitNode(SourceRef At, string Text, string Condition, int Timeout) : Node(At, Text); +public sealed record ExpectNode(SourceRef At, string Text, string Condition) : Node(At, Text); +public sealed record SetNode(SourceRef At, string Text, string Name, string Value) : Node(At, Text); +public sealed record IncludeNode(SourceRef At, string Text, string Path) : Node(At, Text); +public sealed record CallNode(SourceRef At, string Text, string Name, IReadOnlyList<string> Args) : Node(At, Text); +public sealed record CheckpointNode(SourceRef At, string Text, string Name) : Node(At, Text); +public sealed record BlockNode(SourceRef At, string Text, BlockKind Kind, string? Label, IReadOnlyList<Node> Body) : Node(At, Text); +public sealed record DefNode(SourceRef At, string Text, string Name, IReadOnlyList<string> Params, IReadOnlyList<Node> Body) : Node(At, Text); + +public sealed record ScriptFile(string Path, int Format, RunMode? Mode, ulong? Seed, IReadOnlyList<string> VarFiles, + IReadOnlyList<Node> Body, IReadOnlyList<ScriptError> Errors); + +public static class ScriptFormat +{ + /// <summary>Newest format version this runner knows; every older one stays supported.</summary> + public const int Latest = 1; +} diff --git a/src/Godosa.Core/Runs/Scripts/ScriptLoader.cs b/src/Godosa.Core/Runs/Scripts/ScriptLoader.cs new file mode 100644 index 0000000..d123539 --- /dev/null +++ b/src/Godosa.Core/Runs/Scripts/ScriptLoader.cs @@ -0,0 +1,218 @@ +// Provenance: own design — shared run scripts (spec §3.4, §3.7 structural checks) +namespace Godosa.Core.Runs.Scripts; + +public static class ScriptLoader +{ + private sealed class State(IScriptSource source) + { + public IScriptSource Source { get; } = source; + public List<Instr> Code { get; } = []; + public List<ScriptError> Errors { get; } = []; + public Dictionary<string, (DefNode Def, string File, int Format)> Defs { get; } = new(StringComparer.Ordinal); + public List<string> Includes { get; } = []; + public List<string> Calls { get; } = []; + public List<SourceRef> Chain { get; } = []; + public HashSet<string> Checkpoints { get; } = new(StringComparer.Ordinal); + public List<HashSet<string>> Known { get; } = []; + public Dictionary<string, string> Vars { get; } = new(StringComparer.Ordinal); + public HashSet<string> Locked { get; } = new(StringComparer.Ordinal); + public int TryDepth { get; set; } + } + + public static ScriptProgram Load(string entry, IScriptSource source, IReadOnlyDictionary<string, string> cmdVars) + { + var s = new State(source); + foreach (var (k, v) in cmdVars) + { + s.Vars[k] = v; + s.Locked.Add(k); + } + s.Known.Add([.. cmdVars.Keys]); + var text = source.Read(entry); + if (text == null) + { + s.Errors.Add(new ScriptError(new SourceRef(entry, 1), $"script not found: {entry}")); + return new ScriptProgram(entry, RunMode.Debug, null, s.Vars, s.Locked, s.Code, s.Errors); + } + var file = ScriptParser.Parse(entry, text); + s.Errors.AddRange(file.Errors); + if (file.Mode == null) + s.Errors.Add(new ScriptError(new SourceRef(entry, 1), "top-level file needs 'mode'")); + LoadVarFiles(s, file); + s.Includes.Add(entry); + for (var i = 0; i < file.Body.Count; i++) + { + if (file.Body[i] is BlockNode { Kind: BlockKind.Setup } setup && i > 0) + s.Errors.Add(new ScriptError(setup.At, "setup must be the first block")); + } + Emit(s, file.Body, entry, file.Format, top: true); + return new ScriptProgram(entry, file.Mode ?? RunMode.Debug, file.Seed, s.Vars, s.Locked, s.Code, s.Errors); + } + + private static void LoadVarFiles(State s, ScriptFile file) + { + foreach (var raw in file.VarFiles) + { + string path; + try + { + path = ScriptPaths.Resolve(file.Path, VarExpander.Expand(raw, n => s.Vars.GetValueOrDefault(n), null)); + } + catch (ScriptFault e) + { + s.Errors.Add(new ScriptError(new SourceRef(file.Path, 1), e.Message)); + continue; + } + if (s.Source.Read(path) is not { } text) + { + s.Errors.Add(new ScriptError(new SourceRef(file.Path, 1), $"vars file not found: {path}")); + continue; + } + var (values, errors) = VarsFile.Parse(path, text); + s.Errors.AddRange(errors); + foreach (var (k, v) in values) + { + if (!s.Locked.Contains(k)) + s.Vars[k] = v; + s.Known[0].Add(k); + } + } + } + + private static void Emit(State s, IReadOnlyList<Node> nodes, string file, int format, bool top) + { + foreach (var node in nodes) + { + Instr I(Op op) => new(op, node.At, node.Text, [.. s.Chain], format); + switch (node) + { + case VerbNode v: + CheckVars(s, v.At, v.Args); + s.Code.Add(I(Op.Verb) with { A = v.Verb, B = v.Args }); + break; + case WaitNode w: + CheckVars(s, w.At, w.Condition); + s.Code.Add(I(Op.Wait) with { A = w.Condition, Timeout = w.Timeout }); + break; + case ExpectNode e: + CheckVars(s, e.At, e.Condition); + s.Code.Add(I(Op.Expect) with { A = e.Condition }); + break; + case SetNode set: + CheckVars(s, set.At, set.Value); + if (s.Locked.Contains(set.Name)) + s.Errors.Add(new ScriptError(set.At, $"variable {set.Name} is set on the command line")); + s.Known[^1].Add(set.Name); + if (s.Calls.Count == 0) + s.Known[0].Add(set.Name); + s.Code.Add(I(Op.Set) with { A = set.Name, B = set.Value }); + break; + case CheckpointNode cp: + if (s.TryDepth > 0) + s.Errors.Add(new ScriptError(cp.At, "checkpoint inside try")); + if (!s.Checkpoints.Add(cp.Name)) + s.Errors.Add(new ScriptError(cp.At, $"duplicate checkpoint {cp.Name}")); + s.Code.Add(I(Op.Checkpoint) with { A = cp.Name }); + break; + case BlockNode { Kind: BlockKind.Setup } b: + if (!top) + s.Errors.Add(new ScriptError(b.At, "setup only in the top-level file")); + s.Code.Add(I(Op.SetupBegin)); + Emit(s, b.Body, file, format, false); + s.Code.Add(I(Op.SetupEnd)); + break; + case BlockNode { Kind: BlockKind.Step } b: + s.Code.Add(I(Op.LabelBegin) with { A = b.Label! }); + Emit(s, b.Body, file, format, false); + s.Code.Add(I(Op.LabelEnd) with { A = b.Label! }); + break; + case BlockNode { Kind: BlockKind.Try } b: + var begin = s.Code.Count; + s.Code.Add(I(Op.TryBegin)); + s.TryDepth++; + Emit(s, b.Body, file, format, false); + s.TryDepth--; + s.Code[begin] = s.Code[begin] with { Jump = s.Code.Count }; + s.Code.Add(I(Op.TryEnd)); + break; + case DefNode d: + if (!s.Defs.TryAdd(d.Name, (d, file, format))) + s.Errors.Add(new ScriptError(d.At, $"def {d.Name} already defined")); + break; + case CallNode c: + Call(s, c, format); + break; + case IncludeNode inc: + Include(s, inc); + break; + } + } + } + + private static void Call(State s, CallNode c, int format) + { + foreach (var arg in c.Args) + CheckVars(s, c.At, arg); + if (!s.Defs.TryGetValue(c.Name, out var def)) + { + s.Errors.Add(new ScriptError(c.At, $"unknown def {c.Name}")); + return; + } + if (def.Def.Params.Count != c.Args.Count) + { + s.Errors.Add(new ScriptError(c.At, $"def {c.Name} takes {def.Def.Params.Count} arguments, got {c.Args.Count}")); + return; + } + if (s.Calls.Contains(c.Name)) + { + s.Errors.Add(new ScriptError(c.At, $"recursive call {c.Name}")); + return; + } + var bindings = def.Def.Params.Zip(c.Args, (p, a) => new KeyValuePair<string, string>(p, a)).ToList(); + s.Code.Add(new Instr(Op.ScopeBegin, c.At, c.Text, [.. s.Chain], format) { Bindings = bindings, LocalVars = true }); + s.Calls.Add(c.Name); + s.Chain.Add(c.At); + s.Known.Add([.. def.Def.Params]); + Emit(s, def.Def.Body, def.File, def.Format, false); + s.Known.RemoveAt(s.Known.Count - 1); + s.Chain.RemoveAt(s.Chain.Count - 1); + s.Calls.RemoveAt(s.Calls.Count - 1); + s.Code.Add(new Instr(Op.ScopeEnd, c.At, c.Text, [.. s.Chain], format)); + } + + private static void Include(State s, IncludeNode inc) + { + var path = ScriptPaths.Resolve(inc.At.File, inc.Path); + if (s.Includes.Contains(path)) + { + s.Errors.Add(new ScriptError(inc.At, $"include cycle: {string.Join(" → ", s.Includes)} → {path}")); + return; + } + if (s.Source.Read(path) is not { } text) + { + s.Errors.Add(new ScriptError(inc.At, $"include not found: {path}")); + return; + } + var file = ScriptParser.Parse(path, text); + s.Errors.AddRange(file.Errors); + if (file.Mode != null) + s.Errors.Add(new ScriptError(new SourceRef(path, 2), "mode only in the top-level file")); + LoadVarFiles(s, file); + s.Code.Add(new Instr(Op.ScopeBegin, inc.At, inc.Text, [.. s.Chain], file.Format)); + s.Includes.Add(path); + s.Chain.Add(inc.At); + Emit(s, file.Body, path, file.Format, false); + s.Chain.RemoveAt(s.Chain.Count - 1); + s.Includes.RemoveAt(s.Includes.Count - 1); + s.Code.Add(new Instr(Op.ScopeEnd, inc.At, inc.Text, [.. s.Chain], file.Format)); + } + + private static void CheckVars(State s, SourceRef at, string text) + { + foreach (var name in VarExpander.Names(text)) + { + if (!s.Known.Any(k => k.Contains(name))) + s.Errors.Add(new ScriptError(at, $"unresolved variable ${{{name}}}")); + } + } +} diff --git a/src/Godosa.Core/Runs/Scripts/ScriptParser.cs b/src/Godosa.Core/Runs/Scripts/ScriptParser.cs new file mode 100644 index 0000000..f9f964f --- /dev/null +++ b/src/Godosa.Core/Runs/Scripts/ScriptParser.cs @@ -0,0 +1,231 @@ +// Provenance: own design — shared run scripts (spec §3) +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Godosa.Core.Runs.Scripts; + +/// <summary>Format v1 parser: one file → <see cref="ScriptFile"/>; every error collected (never throws).</summary> +public static partial class ScriptParser +{ + private sealed record Line(int Number, int Indent, string Text); + + [GeneratedRegex(@"^set\s+([A-Za-z_][\w.\-]*)\s*=\s*(.*)$")] + private static partial Regex SetLine(); + + [GeneratedRegex(@"^def\s+([A-Za-z_][\w\-]*)\s*\(([^)]*)\)\s*:$")] + private static partial Regex DefLine(); + + [GeneratedRegex(@"^([A-Za-z_][\w\-]*)\s*(?:\(([^)]*)\))?$")] + private static partial Regex CallTarget(); + + public static ScriptFile Parse(string path, string text) + { + var errors = new List<ScriptError>(); + var lines = new List<Line>(); + var raw = text.TrimStart('\uFEFF').Replace("\r\n", "\n").Split('\n'); + for (var i = 0; i < raw.Length; i++) + { + var at = new SourceRef(path, i + 1); + var stripped = StepLexer.StripComment(raw[i]).TrimEnd(); + if (stripped.Trim().Length == 0) + continue; + var indent = stripped.Length - stripped.TrimStart(' ', '\t').Length; + if (stripped[..indent].Contains('\t')) + { + errors.Add(new ScriptError(at, "tab in indentation")); + continue; + } + if (indent % 2 != 0) + { + errors.Add(new ScriptError(at, "indent not a multiple of 2")); + continue; + } + lines.Add(new Line(i + 1, indent / 2, stripped.Trim())); + } + + var format = 0; + RunMode? mode = null; + ulong? seed = null; + var varFiles = new List<string>(); + var pos = 0; + if (lines.Count == 0 || !lines[0].Text.StartsWith("format ", StringComparison.Ordinal)) + errors.Add(new ScriptError(new SourceRef(path, lines.Count > 0 ? lines[0].Number : 1), "first line must be 'format N'")); + else + { + var value = lines[0].Text["format ".Length..].Trim(); + if (!int.TryParse(value, CultureInfo.InvariantCulture, out format) || format < 1 || format > ScriptFormat.Latest) + { + errors.Add(new ScriptError(new SourceRef(path, lines[0].Number), $"unknown format {value} (latest {ScriptFormat.Latest})")); + format = 0; + } + pos = 1; + } + for (; pos < lines.Count && lines[pos].Indent == 0 && IsHeader(lines[pos].Text); pos++) + Header(lines[pos], path, errors, ref mode, ref seed, varFiles); + var body = Block(lines, ref pos, 0, path, errors); + return new ScriptFile(path, format, mode, seed, varFiles, body, errors); + } + + private static bool IsHeader(string text) => + text.StartsWith("mode ", StringComparison.Ordinal) || text.StartsWith("seed ", StringComparison.Ordinal) + || text.StartsWith("vars ", StringComparison.Ordinal) || text.StartsWith("format ", StringComparison.Ordinal); + + private static void Header(Line line, string path, List<ScriptError> errors, ref RunMode? mode, ref ulong? seed, List<string> varFiles) + { + var at = new SourceRef(path, line.Number); + var value = line.Text[(line.Text.IndexOf(' ') + 1)..].Trim(); + switch (line.Text[..line.Text.IndexOf(' ')]) + { + case "mode": + mode = value switch { "player" => RunMode.Player, "debug" => RunMode.Debug, _ => null }; + if (mode == null) + errors.Add(new ScriptError(at, "mode must be player or debug")); + break; + case "seed": + if (ulong.TryParse(value, CultureInfo.InvariantCulture, out var s)) + seed = s; + else + errors.Add(new ScriptError(at, $"bad seed '{value}'")); + break; + case "vars": + varFiles.Add(value); + break; + default: + errors.Add(new ScriptError(at, "format must be the first line only")); + break; + } + } + + private static List<Node> Block(List<Line> lines, ref int pos, int indent, string path, List<ScriptError> errors) + { + var nodes = new List<Node>(); + while (pos < lines.Count) + { + var line = lines[pos]; + if (line.Indent < indent) + break; + var at = new SourceRef(path, line.Number); + if (line.Indent > indent) + { + errors.Add(new ScriptError(at, "unexpected indent")); + pos++; + continue; + } + pos++; + if (indent == 0 && IsHeader(line.Text)) + { + errors.Add(new ScriptError(at, "header line after steps")); + continue; + } + var hasBody = pos < lines.Count && lines[pos].Indent == indent + 1; + if (line.Text.EndsWith(':') && (hasBody || IsBlockKeyword(line.Text))) + { + var body = hasBody ? Block(lines, ref pos, indent + 1, path, errors) : []; + if (!hasBody) + errors.Add(new ScriptError(at, "empty block")); + if (BlockHeader(line.Text, at, body, errors) is { } node) + nodes.Add(node); + continue; + } + if (Statement(line.Text, at, errors) is { } statement) + nodes.Add(statement); + } + return nodes; + } + + /// <summary>A line ending in ':' is a block header when it has an indented body or starts with a block keyword; + /// otherwise a statement (<c>log Done:</c>).</summary> + private static bool IsBlockKeyword(string text) => + text is "setup:" or "try:" || text.StartsWith("step ", StringComparison.Ordinal) || text.StartsWith("def ", StringComparison.Ordinal); + + private static Node? BlockHeader(string text, SourceRef at, List<Node> body, List<ScriptError> errors) + { + if (text == "setup:") + return new BlockNode(at, text, BlockKind.Setup, null, body); + if (text == "try:") + return new BlockNode(at, text, BlockKind.Try, null, body); + if (text.StartsWith("step ", StringComparison.Ordinal) && text.Length > "step :".Length) + return new BlockNode(at, text, BlockKind.Step, text["step ".Length..^1].Trim(), body); + if (DefLine().Match(text) is { Success: true } def) + { + var parameters = def.Groups[2].Value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + return new DefNode(at, text, def.Groups[1].Value, parameters, body); + } + errors.Add(new ScriptError(at, $"unknown block '{text}'")); + return null; + } + + private static Node? Statement(string text, SourceRef at, List<ScriptError> errors) + { + var space = text.IndexOf(' '); + var head = space < 0 ? text : text[..space]; + var rest = space < 0 ? "" : text[(space + 1)..].Trim(); + try + { + // Validate quoting once for every line (log "abc → unterminated quote). + StepLexer.Tokens(rest); + } + catch (FormatException e) + { + errors.Add(new ScriptError(at, e.Message)); + return null; + } + switch (head) + { + case "set": + if (SetLine().Match(text) is not { Success: true } set) + { + errors.Add(new ScriptError(at, "bad set (set name = value)")); + return null; + } + var value = set.Groups[2].Value.Trim(); + if (value.Length >= 2 && value[0] == '"' && value[^1] == '"') + value = StepLexer.Tokens(value)[0].Text; + return new SetNode(at, text, set.Groups[1].Value, value); + case "wait": + var last = rest.LastIndexOf(' '); + if (last < 0) + { + errors.Add(new ScriptError(at, "wait needs a condition and a timeout")); + return null; + } + var timeout = rest[(last + 1)..]; + if (Durations.Frames(timeout) is not { } frames) + { + errors.Add(new ScriptError(at, $"bad timeout '{timeout}'")); + return null; + } + return new WaitNode(at, text, rest[..last].Trim(), frames); + case "expect": + if (rest.Length == 0) + { + errors.Add(new ScriptError(at, "expect needs a condition")); + return null; + } + return new ExpectNode(at, text, rest); + case "include": + return rest.Length == 0 || rest.Contains(' ') + ? Error(errors, at, "include takes one path") + : new IncludeNode(at, text, rest); + case "checkpoint": + return rest.Length == 0 || rest.Contains(' ') + ? Error(errors, at, "checkpoint takes one name") + : new CheckpointNode(at, text, rest); + case "call": + if (CallTarget().Match(rest) is not { Success: true } call) + return Error(errors, at, "bad call (call name(args))"); + var args = call.Groups[2].Success + ? call.Groups[2].Value.Split(',', StringSplitOptions.TrimEntries).Where(a => a.Length > 0).ToArray() + : []; + return new CallNode(at, text, call.Groups[1].Value, args); + default: + return new VerbNode(at, text, head, rest); + } + } + + private static Node? Error(List<ScriptError> errors, SourceRef at, string message) + { + errors.Add(new ScriptError(at, message)); + return null; + } +} diff --git a/src/Godosa.Core/Runs/Scripts/ScriptProgram.cs b/src/Godosa.Core/Runs/Scripts/ScriptProgram.cs new file mode 100644 index 0000000..502e24c --- /dev/null +++ b/src/Godosa.Core/Runs/Scripts/ScriptProgram.cs @@ -0,0 +1,57 @@ +// Provenance: own design — shared run scripts +namespace Godosa.Core.Runs.Scripts; + +public enum Op { Verb, Wait, Expect, Set, Checkpoint, LabelBegin, LabelEnd, ScopeBegin, ScopeEnd, TryBegin, TryEnd, SetupBegin, SetupEnd } + +/// <summary>One flattened instruction. <see cref="Chain"/> = include / call sites, outermost first.</summary> +public sealed record Instr(Op Op, SourceRef At, string Text, IReadOnlyList<SourceRef> Chain, int Format) +{ + public string A { get; init; } = ""; + public string B { get; init; } = ""; + public int Timeout { get; init; } + public IReadOnlyList<KeyValuePair<string, string>> Bindings { get; init; } = []; + public bool LocalVars { get; init; } + public int Jump { get; init; } = -1; +} + +public sealed record ScriptProgram(string Entry, RunMode Mode, ulong? Seed, IReadOnlyDictionary<string, string> Vars, + IReadOnlySet<string> Locked, IReadOnlyList<Instr> Code, IReadOnlyList<ScriptError> Errors); + +public interface IScriptSource +{ + string? Read(string path); +} + +public sealed class MemoryScriptSource(IReadOnlyDictionary<string, string> files) : IScriptSource +{ + public string? Read(string path) => files.GetValueOrDefault(path); +} + +/// <summary>Files under <paramref name="root"/>; paths are '/'-separated and relative to it.</summary> +public sealed class FileScriptSource(string root) : IScriptSource +{ + public string? Read(string path) + { + var full = Path.GetFullPath(Path.Combine(root, path)); + return File.Exists(full) ? File.ReadAllText(full) : null; + } +} + +public static class ScriptPaths +{ + public static string Resolve(string fromFile, string relative) + { + var dir = fromFile.Contains('/') ? fromFile[..fromFile.LastIndexOf('/')] : ""; + var parts = new List<string>(dir.Length == 0 ? [] : dir.Split('/')); + foreach (var part in relative.Replace('\\', '/').Split('/')) + { + if (part is "" or ".") + continue; + if (part == ".." && parts.Count > 0) + parts.RemoveAt(parts.Count - 1); + else if (part != "..") + parts.Add(part); + } + return string.Join('/', parts); + } +} diff --git a/src/Godosa.Core/Runs/Scripts/ScriptRunFlags.cs b/src/Godosa.Core/Runs/Scripts/ScriptRunFlags.cs new file mode 100644 index 0000000..63a335a --- /dev/null +++ b/src/Godosa.Core/Runs/Scripts/ScriptRunFlags.cs @@ -0,0 +1,100 @@ +// Provenance: own design — shared run scripts (CLI) +using System.Globalization; + +namespace Godosa.Core.Runs.Scripts; + +/// <summary>Run-script CLI options (<c>--script</c>, <c>--var k=v</c>, <c>--output</c>, <c>--no-cache</c>, <c>--no-snapshots</c>, <c>--from</c>, +/// <c>--until</c>, <c>--nondeterministic</c>, <c>--step-cap</c>, <c>--report-dir</c>); determinism and seed come from +/// <see cref="RunFlags"/>.</summary> +public sealed class ScriptRunFlags +{ + public string? Script { get; set; } + public Dictionary<string, string> Vars { get; } = new(StringComparer.Ordinal); + public OutputLevel? Output { get; set; } + public bool NoCache { get; set; } + public bool NoSnapshots { get; set; } + public string? From { get; set; } + public string? Until { get; set; } + public bool NonDeterministic { get; set; } + public int? StepCap { get; set; } + public string? ReportDir { get; set; } + + public static bool TryTake(string[] args, ref int i, ScriptRunFlags flags) + { + string Value(ref int j) + { + if (j + 1 >= args.Length) + throw new ArgumentException($"{args[j]} needs a value"); + return args[++j]; + } + switch (args[i]) + { + case "--script": + flags.Script = Value(ref i); + return true; + case "--var": + { + var v = Value(ref i); + var eq = v.IndexOf('='); + if (eq <= 0) + throw new ArgumentException($"--var needs name=value, got '{v}'"); + flags.Vars[v[..eq]] = v[(eq + 1)..]; + return true; + } + case "--output": + { + var v = Value(ref i); + try + { + flags.Output = RunOutput.Parse(v); + } + catch (ScriptFault e) + { + throw new ArgumentException(e.Message); + } + return true; + } + case "--no-cache": + flags.NoCache = true; + return true; + case "--no-snapshots": + flags.NoSnapshots = true; + return true; + case "--from": + flags.From = Value(ref i); + return true; + case "--until": + flags.Until = Value(ref i); + return true; + case "--nondeterministic": + flags.NonDeterministic = true; + return true; + case "--step-cap": + { + var v = Value(ref i); + flags.StepCap = int.TryParse(v, CultureInfo.InvariantCulture, out var n) && n > 0 ? n : throw new ArgumentException($"bad --step-cap '{v}'"); + return true; + } + case "--report-dir": + flags.ReportDir = Value(ref i); + return true; + default: + return false; + } + } + + public ScriptRunOptions ToOptions(RunFlags run, string? cacheDir) => new() + { + Deterministic = !NonDeterministic, + Seed = run.Seed, + Output = Output ?? OutputLevel.Labels, + Floor = Output ?? OutputLevel.Quiet, + StepCap = StepCap ?? 36_000, + CacheDir = cacheDir, + UseCache = !NoCache, + Snapshots = !NoSnapshots, + From = From, + Until = Until, + ReportDir = ReportDir, + }; +} diff --git a/src/Godosa.Core/Runs/Scripts/ScriptRunner.cs b/src/Godosa.Core/Runs/Scripts/ScriptRunner.cs new file mode 100644 index 0000000..edc7b97 --- /dev/null +++ b/src/Godosa.Core/Runs/Scripts/ScriptRunner.cs @@ -0,0 +1,568 @@ +// Provenance: own design — shared run scripts (spec §5) +using System.Globalization; + +namespace Godosa.Core.Runs.Scripts; + +public sealed record ScriptRunOptions +{ + public bool Deterministic { get; init; } = true; + /// <summary>Overrides the script's <c>seed</c> header; neither → 0 and the game's rng is left alone.</summary> + public ulong? Seed { get; init; } + public OutputLevel Output { get; init; } = OutputLevel.Labels; + public OutputLevel Floor { get; init; } = OutputLevel.Quiet; + public int StepCap { get; init; } = 36_000; + public string? CacheDir { get; init; } + public bool UseCache { get; init; } = true; + /// <summary>false: checkpoints are markers — no digest (a whole-state serialization, slow in a big game), nothing + /// cached (<c>--no-snapshots</c>).</summary> + public bool Snapshots { get; init; } = true; + public string? From { get; init; } + public string? Until { get; init; } + public string? ReportDir { get; init; } + public long CacheCapBytes { get; init; } = 2L << 30; +} + +public enum RunOutcome { Passed, Failed, Invalid, Stopped } + +public sealed record RunResult(RunOutcome Outcome, int ExitCode, long Frames, string? Failure); + +public sealed class ScriptRunner : IConditionContext +{ + private sealed class Active(Instr instr, long startFrame) + { + public Instr Instr { get; } = instr; + public long StartFrame { get; } = startFrame; + public int Frames { get; set; } + public InputPlan? Plan { get; set; } + /// <summary>The <see cref="Frames"/> value a plan's frame 0 lands on (0 for the step's own plan).</summary> + public int PlanBase { get; set; } + public int Sent { get; set; } + public IRunAction? Action { get; init; } + public Cond? Wait { get; init; } + public int? WaitFrames { get; init; } + } + + private sealed record Label(string Name, long Start, SourceRef At); + + private readonly ScriptProgram _program; + private readonly IRunGame _game; + private readonly ScriptRunOptions _options; + private readonly RunOutput _out; + private readonly VarScopes _vars; + private readonly List<Label> _labels = []; + private Active? _active; + private sealed record TryFrame(int End, int ScopeDepth, int LabelCount); + private readonly List<TryFrame> _tries = []; + private int _pc; + private bool _started; + + public ScriptRunner(ScriptProgram program, IRunGame game, ScriptRunOptions options, Action<string> output) + { + (_program, _game, _options) = (program, game, options); + _out = new RunOutput(output, options.Floor); + _vars = new VarScopes(options.Output); + foreach (var (k, v) in program.Vars) + _vars.Set(k, v); + } + + public long FrameCount { get; private set; } + + /// <summary>Steps completed so far (a window host draws before the next one resolves its positions).</summary> + public int StepsDone => _steps; + + /// <summary>Effective seed: options, else the script's <c>seed</c> header, else 0.</summary> + public ulong Seed => _options.Seed ?? _program.Seed ?? 0; + public RunResult? Result { get; private set; } + public RunReport? Report { get; private set; } + + private readonly Queue<string> _recent = new(); + private readonly List<CheckpointRecord> _checkpoints = []; + private readonly List<string> _warnings = []; + private readonly System.Diagnostics.Stopwatch _wall = new(); + private int _steps, _cacheHits, _cacheMisses; + private CheckpointCache? _cache; + private IReadOnlyDictionary<int, string> _keys = new Dictionary<int, string>(); + + public RunResult Run(long cap = HeadlessLoop.DefaultCap) + { + var (_, capped) = HeadlessLoop.Run(_ => Frame(), null, cap); + if (capped && Result == null) + Fail(_active?.Instr, $"frame cap {cap}"); + return Result ?? new RunResult(RunOutcome.Passed, 0, FrameCount, null); + } + + /// <summary>A deferred checkpoint restore (<see cref="IRunGame.RestoreDeferred"/>) is under way: the host advances no + /// game frame and calls <see cref="AfterFrame"/> not at all this frame; the next <see cref="BeforeFrame"/> goes on.</summary> + public bool Restoring { get; private set; } + + public bool Frame() + { + BeforeFrame(); + if (Result != null) + return false; + if (Restoring) + return true; + Guard(_active?.Instr, _game.Advance); + if (Result == null) + AfterFrame(); + return Result == null; + } + + public void BeforeFrame() + { + if (Result != null) + return; + if (!_started) + { + var ok = false; + Guard(null, () => ok = Start()); + if (!ok || Result != null) + return; + if (Restoring = _cacheHits > 0 && _game.RestoreDeferred) + return; + } + Restoring = false; + Guard(_active?.Instr, () => + { + while (_active == null && Result == null) + { + if (_pc >= _program.Code.Count) + { + Finish(new RunResult(RunOutcome.Passed, 0, FrameCount, null), null); + return; + } + Exec(_program.Code[_pc++]); + } + }); + if (_active is { Plan: { } plan } a && Result == null) + { + Guard(a.Instr, () => + { + foreach (var e in plan.Events.Where(e => e.Frame + a.PlanBase == a.Frames)) + { + _game.Input.Send(e.Event); + a.Sent++; + _out.Emit(OutputLevel.Trace, _vars.Level, $" input f={FrameCount} {e.Event}"); + } + }); + } + } + + /// <param name="poll">false: the frame counts but the step is not polled (a window host whose next draw shows what this + /// frame changed: a click resolved now would aim on the draw from before); the next frame polls it.</param> + public void AfterFrame(bool poll = true) + { + if (Result != null || _active is not { } a) + return; + FrameCount++; + a.Frames++; + if (!poll) + return; + Guard(a.Instr, () => + { + var status = Poll(a); + if (status.Plan is { } more) + (a.Plan, a.PlanBase, a.Sent) = (more, a.Frames, 0); + if (status.State == StepState.Running && a.Frames >= _options.StepCap) + status = StepStatus.Fault($"step exceeded {_options.StepCap} frames"); + if (status.State == StepState.Fault) + throw new ScriptFault(status.Message ?? "fault"); + if (status.State == StepState.Done) + Complete(a.Instr, a.Frames); + }); + } + + private bool Start() + { + _started = true; + _wall.Start(); + var errors = ScriptValidator.Validate(_program, _game, _options.Deterministic); + if (errors.Count > 0) + { + foreach (var e in errors) + _out.Always(e.ToString()); + Finish(new RunResult(RunOutcome.Invalid, 2, 0, errors[0].ToString()), null); + return false; + } + var until = int.MaxValue; + if (_options.Until is { } stop) + { + until = _program.Code.Select((c, n) => (c, n)).FirstOrDefault(x => x.c.A == stop && x.c.Op is Op.Checkpoint or Op.LabelEnd, (null!, -1)).Item2; + if (until < 0) + return Invalid($"unknown label or checkpoint {stop}"); + } + if ((_options.Seed ?? _program.Seed) is { } seed && _game.Rng is { } rng) + rng.Seed(unchecked((long)seed)); + if (_options.Deterministic && _options.CacheDir != null && _game.SupportsSnapshot) + { + _cache = new CheckpointCache(_options.CacheDir, _options.CacheCapBytes); + _keys = CheckpointKeys.Compute(_program, _game.ContentVersion, Seed); + } + if (_options.From is { } from) + { + var index = _keys.Keys.Concat(_program.Code.Select((c, n) => (c, n)).Where(x => x.c.Op == Op.Checkpoint).Select(x => x.n)) + .Distinct().FirstOrDefault(n => _program.Code[n].A == from, -1); + if (index < 0) + return Invalid($"unknown checkpoint {from}"); + if (_cache?.Load(_keys[index]) is not { } entry) + return Invalid($"checkpoint {from} not cached"); + Resume(index, entry); + } + else if (_cache != null && _options.UseCache) + { + foreach (var index in _keys.Keys.Where(k => k < until).OrderDescending()) + { + if (_cache.Load(_keys[index]) is { } entry) + { + Resume(index, entry); + break; + } + } + } + return true; + } + + private bool Invalid(string message) + { + _out.Always(message); + Finish(new RunResult(RunOutcome.Invalid, 2, 0, message), null); + return false; + } + + private void Resume(int index, CachedCheckpoint entry) + { + _game.SnapshotRestore(entry.Game); + FrameCount = entry.State.Frame; + _vars.Restore(entry.State.Scopes); + _labels.Clear(); + _labels.AddRange(entry.State.Labels.Select(l => new Label(l.Name, l.Start, new SourceRef(l.File, l.Line)))); + _pc = index + 1; + var name = _program.Code[index].A; + _checkpoints.Add(new CheckpointRecord(name, FrameCount, entry.State.Digest.ToString("x16"), "resumed")); + _cacheHits++; + _out.Always($"resumed at {name} (cache) f={FrameCount}"); + } + + private StepStatus Poll(Active a) + { + if (a.Plan is { } plan && a.Sent < plan.Events.Count) + return StepStatus.Running; + if (a.Action != null) + return a.Action.Poll(); + if (a.WaitFrames is { } n) + return a.Frames >= n ? StepStatus.Done : StepStatus.Running; + if (a.Wait is { } cond) + { + if (cond.Test(this)) + return StepStatus.Done; + _out.Emit(OutputLevel.Trace, _vars.Level, $" wait f={FrameCount} {a.Instr.A}: {Cond.Describe(cond, this)}"); + return a.Frames >= a.Instr.Timeout + ? StepStatus.Fault($"timeout after {a.Instr.Timeout} frames: {a.Instr.A} ({Cond.Describe(cond, this)})") + : StepStatus.Running; + } + return StepStatus.Done; + } + + private void Exec(Instr i) + { + switch (i.Op) + { + case Op.Set: + _vars.Set(i.A, Expand(i.B)); + break; + case Op.Expect: + { + var cond = ConditionParser.Parse(Expand(i.A)); + if (!cond.Test(this)) + throw new ScriptFault($"expect failed: {i.A} ({Cond.Describe(cond, this)})"); + Complete(i, 0); + break; + } + case Op.Wait: + { + var cond = ConditionParser.Parse(Expand(i.A)); + if (cond.Test(this)) + Complete(i, 0); + else + _active = new Active(i, FrameCount) { Wait = cond }; + break; + } + case Op.Verb: + Verb(i); + break; + case Op.ScopeBegin: + _vars.Push(i.LocalVars, _vars.Level, i.Bindings.Select(b => new KeyValuePair<string, string>(b.Key, Expand(b.Value))).ToList()); + break; + case Op.ScopeEnd: + _vars.Pop(); + break; + case Op.LabelBegin: + _labels.Add(new Label(i.A, FrameCount, i.At)); + break; + case Op.LabelEnd: + { + var label = _labels[^1]; + _labels.RemoveAt(_labels.Count - 1); + _out.Emit(OutputLevel.Labels, _vars.Level, $"[{label.At} {label.Name}] done f={FrameCount} (+{FrameCount - label.Start})"); + if (_options.Deterministic) + _out.Emit(OutputLevel.Labels, _vars.Level, DigestLine.Format(FrameCount, FrameCount / 60.0, _game.Digest())); + if (_options.Until == label.Name) + Finish(new RunResult(RunOutcome.Stopped, 0, FrameCount, null), null); + break; + } + case Op.TryBegin: + _tries.Add(new TryFrame(i.Jump, _vars.Depth, _labels.Count)); + break; + case Op.TryEnd: + if (_tries.Count > 0) + _tries.RemoveAt(_tries.Count - 1); + break; + case Op.Checkpoint: + { + if (!_options.Snapshots) + { + _checkpoints.Add(new CheckpointRecord(i.A, FrameCount, "", "marker")); + _out.Emit(OutputLevel.Checkpoints, _vars.Level, $"checkpoint {i.A} f={FrameCount}"); + if (_options.Until == i.A) + Finish(new RunResult(RunOutcome.Stopped, 0, FrameCount, null), null); + break; + } + var digest = _game.Digest(); + var source = "replayed"; + if (_cache != null && _game.SnapshotRefusal() is { } refusal) + _warnings.Add($"checkpoint {i.A} not cached: {refusal}"); + else if (_cache != null && _keys.TryGetValue(_pc - 1, out var key)) + { + var old = _cache.Load(key); + if (old != null && old.State.Digest == digest) + source = "cached"; + else + { + if (old != null) + _warnings.Add($"checkpoint {i.A} digest differs from cache ({old.State.Digest:x16} → {digest:x16}); cache replaced"); + try + { + _cache.Save(key, new CachedCheckpoint(new RunnerState(FrameCount, digest, _vars.Snapshot(), + [.. _labels.Select(l => new LabelState(l.Name, l.Start, l.At.File, l.At.Line))]), _game.SnapshotSave())); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + _warnings.Add($"checkpoint cache not written: {e.Message}"); + } + _cacheMisses++; + } + } + _checkpoints.Add(new CheckpointRecord(i.A, FrameCount, digest.ToString("x16"), source)); + _out.Emit(OutputLevel.Checkpoints, _vars.Level, $"checkpoint {i.A} f={FrameCount}"); + if (_options.Deterministic) + _out.Emit(OutputLevel.Checkpoints, _vars.Level, DigestLine.Format(FrameCount, FrameCount / 60.0, digest)); + if (_options.Until == i.A) + Finish(new RunResult(RunOutcome.Stopped, 0, FrameCount, null), null); + break; + } + } + } + + private void Verb(Instr i) + { + var tokens = ExpandTokens(i.B); + switch (i.A) + { + case "log": + _out.Always(tokens[0].Text); + return; + case "output": + _vars.Level = RunOutput.Parse(tokens[0].Text); + return; + case "rng": + Rng(tokens); + return; + case "wait-frames": + { + var n = Durations.Frames(tokens[0].Text) ?? throw new ScriptFault($"bad frame count '{tokens[0].Text}'"); + if (n == 0) + Complete(i, 0); + else + _active = new Active(i, FrameCount) { WaitFrames = n }; + return; + } + } + if (InputPlans.IsInputVerb(i.A)) + { + _active = new Active(i, FrameCount) { Plan = InputPlans.For(i.A, tokens, t => Positions.Resolve(t, _game)) }; + return; + } + var action = _game.Actions[i.A]; + var start = action.Begin(tokens.Select(t => t.Text).ToList()); + if (start.Status is { State: StepState.Fault } fault) + throw new ScriptFault(fault.Message ?? "fault"); + if (start.Plan == null && start.Status is { State: StepState.Done }) + { + Complete(i, 0); + return; + } + _active = new Active(i, FrameCount) { Plan = start.Plan, Action = action }; + } + + private void Rng(List<Token> t) + { + var rng = _game.Rng ?? throw new ScriptFault("game has no rng hook"); + long N(string s) => long.TryParse(s, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var v) ? v : throw new ScriptFault($"bad number '{s}'"); + switch (t[0].Text) + { + case "seed" when t.Count == 2: + rng.Seed(N(t[1].Text)); + break; + case "next" when t.Count == 3: + rng.ForceNext(t[1].Text, N(t[2].Text)); + break; + default: + throw new ScriptFault("rng seed <n> | rng next <kind> <value>"); + } + } + + private void Complete(Instr i, int frames) + { + _active = null; + var label = _labels.Count > 0 ? $" {_labels[^1].Name}" : ""; + _out.Emit(OutputLevel.Steps, _vars.Level, $"[{i.At}{label}] {i.Text} done f={FrameCount} (+{frames})"); + _steps++; + _recent.Enqueue($"{i.At} {i.Text} f={FrameCount}"); + while (_recent.Count > 20) + _recent.Dequeue(); + } + + /// <summary>Quoted arguments expand inside their token (a value with quotes or spaces stays one argument); unquoted ones + /// expand, then split (a variable may hold several arguments).</summary> + private List<Token> ExpandTokens(string text) => + [.. StepLexer.Tokens(text).SelectMany(t => t.Quoted ? [new Token(Expand(t.Text), true)] : StepLexer.Tokens(Expand(t.Text)))]; + + private string Expand(string text) => VarExpander.Expand(text, _vars.Get, p => + { + var cond = ConditionParser.Parse(p); + return cond.Eval(this); + }); + + private void Guard(Instr? at, Action step) + { + try + { + step(); + } + catch (ScriptFault e) + { + Fail(at ?? CurrentOrLast(), e.Message); + } + catch (FormatException e) + { + Fail(at ?? CurrentOrLast(), e.Message); + } + catch (Exception e) when (e is not OutOfMemoryException) + { + Fail(at ?? CurrentOrLast(), $"{e.GetType().Name}: {e.Message}"); + } + } + + private Instr? CurrentOrLast() => _pc > 0 && _pc <= _program.Code.Count ? _program.Code[_pc - 1] : null; + + private void Fail(Instr? at, string reason) + { + ReleaseHeld(); + _active = null; + if (_tries.Count > 0) + { + var t = _tries[^1]; + _tries.RemoveAt(_tries.Count - 1); + _vars.PopTo(t.ScopeDepth); + _labels.RemoveRange(t.LabelCount, _labels.Count - t.LabelCount); + _out.Always($"try failed at {at?.At}: {reason}"); + _pc = t.End + 1; + return; + } + var where = at == null ? "" : $" {at.At} {at.Text}"; + byte[]? snap = null; + if (_game.SupportsSnapshot) + { + try + { + snap = _game.SnapshotSave(); + } + catch (Exception e) when (e is not OutOfMemoryException) + { + _warnings.Add($"failure save not written: {e.Message}"); + } + } + string dump; + try + { + dump = _game.Dump(); + } + catch (Exception e) when (e is not OutOfMemoryException) + { + dump = $"(dump failed: {e.Message})"; + } + var failure = new FailureInfo(at?.At.ToString() ?? "", at?.Chain.Select(c => c.ToString()).ToList() ?? [], at?.Text ?? "", + _labels.Count > 0 ? _labels[^1].Name : null, reason, [.. _recent], dump, _vars.Visible(), + snap != null && _options.ReportDir != null ? Path.Combine(_options.ReportDir, "fail.snap") : null); + _out.Always($"FAIL{where}: {reason}"); + Finish(new RunResult(RunOutcome.Failed, 1, FrameCount, reason), failure, snap); + } + + private void Finish(RunResult result, FailureInfo? failure, byte[]? failSnapshot = null) + { + Result = result; + _wall.Stop(); + Report = new RunReport(_program.Entry, _program.Mode, Seed, _options.Deterministic, result.Outcome, FrameCount, + _steps, _wall.Elapsed.TotalSeconds, _cacheHits, _cacheMisses, [.. _checkpoints], [.. _warnings], failure); + if (_options.ReportDir is not { } dir) + return; + try + { + ReportWriter.Write(dir, Report, failSnapshot); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + _warnings.Add($"report not written: {e.Message}"); + Report = Report with { Warnings = [.. _warnings] }; + _out.Always($"warning: report not written: {e.Message}"); + } + } + + /// <summary>Review focus 1: a half-sent plan leaves no button or key held — pending ups are sent now.</summary> + private void ReleaseHeld() + { + if (_active is not { Plan: { } plan } a) + return; + var sent = plan.Events.Take(a.Sent).Select(e => e.Event).ToList(); + foreach (var pending in plan.Events.Skip(a.Sent).Select(e => e.Event)) + { + var held = pending.Kind switch + { + InputKind.Up => sent.Count(e => e.Kind == InputKind.Down && e.Button == pending.Button) + > sent.Count(e => e.Kind == InputKind.Up && e.Button == pending.Button), + InputKind.KeyUp => sent.Count(e => e.Kind == InputKind.KeyDown && e.Key == pending.Key) + > sent.Count(e => e.Kind == InputKind.KeyUp && e.Key == pending.Key), + _ => false, + }; + if (held) + { + try + { + _game.Input.Send(pending); + } + catch (Exception e) when (e is not OutOfMemoryException) + { + // The game is failing anyway; the report carries the first error. + } + sent.Add(pending); + } + } + } + + bool IConditionContext.HasProbe(string name) => _game.Probes.ContainsKey(name); + + RunValue IConditionContext.Probe(string name, IReadOnlyList<string> args) => + _game.Probes.TryGetValue(name, out var p) ? p.Eval(args) : throw new ScriptFault($"unknown probe {name}"); + + long? IConditionContext.Constant(string name) => _game.Constants.TryGetValue(name, out var v) ? v : null; +} diff --git a/src/Godosa.Core/Runs/Scripts/ScriptTypes.cs b/src/Godosa.Core/Runs/Scripts/ScriptTypes.cs new file mode 100644 index 0000000..909c26f --- /dev/null +++ b/src/Godosa.Core/Runs/Scripts/ScriptTypes.cs @@ -0,0 +1,46 @@ +// Provenance: own design — shared run scripts (design notes kept privately by the author) +using System.Globalization; + +namespace Godosa.Core.Runs.Scripts; + +public enum RunMode { Player, Debug } + +/// <summary>Player = only injects user input (may read state to aim); Debug = calls the game directly.</summary> +public enum VerbKind { Player, Debug } + +public enum OutputLevel { Quiet, Checkpoints, Labels, Steps, Trace } + +public enum StepState { Running, Done, Fault } + +public sealed record SourceRef(string File, int Line) +{ + public override string ToString() => $"{File}:{Line}"; +} + +public sealed record ScriptError(SourceRef At, string Message) +{ + public override string ToString() => $"{At}: {Message}"; +} + +/// <param name="Plan">Running with more input (multi-click verbs): the runner plays it from the next frame and polls +/// again once it is sent.</param> +public readonly record struct StepStatus(StepState State, string? Message = null, InputPlan? Plan = null) +{ + public static StepStatus Running => new(StepState.Running); + public static StepStatus Input(InputPlan plan) => new(StepState.Running, null, plan); + public static StepStatus Done => new(StepState.Done); + public static StepStatus Fault(string message) => new(StepState.Fault, message); +} + +/// <summary>A step failure inside the runner (turned into a failed run, never escapes <c>ScriptRunner</c>).</summary> +public sealed class ScriptFault(string message) : Exception(message); + +/// <summary>A probe / condition value: a number or a string.</summary> +public readonly record struct RunValue(long? Number, string? Text) +{ + public static RunValue Of(long n) => new(n, null); + public static RunValue Of(string s) => new(null, s); + public static RunValue Of(bool b) => new(b ? 1 : 0, null); + public bool IsNumber => Number.HasValue; + public override string ToString() => Number?.ToString(CultureInfo.InvariantCulture) ?? Text ?? ""; +} diff --git a/src/Godosa.Core/Runs/Scripts/ScriptValidator.cs b/src/Godosa.Core/Runs/Scripts/ScriptValidator.cs new file mode 100644 index 0000000..e67c48a --- /dev/null +++ b/src/Godosa.Core/Runs/Scripts/ScriptValidator.cs @@ -0,0 +1,109 @@ +// Provenance: own design — shared run scripts (spec §3.7) +namespace Godosa.Core.Runs.Scripts; + +public static class ScriptValidator +{ + public static List<ScriptError> Validate(ScriptProgram p, IRunGame game, bool deterministic) + { + var errors = new List<ScriptError>(p.Errors); + foreach (var (name, action) in game.Actions) + { + if (LibVerbs.Find(name) is { } lib && lib.Kind != action.Kind) + errors.Add(new ScriptError(new SourceRef(p.Entry, 1), $"game verb {name} shadows a lib verb with another kind")); + else if (LibVerbs.Find(name) is { GameHook: false }) + errors.Add(new ScriptError(new SourceRef(p.Entry, 1), $"game verb {name} shadows a lib verb")); + } + var inSetup = false; + foreach (var i in p.Code) + { + switch (i.Op) + { + case Op.SetupBegin: + inSetup = true; + break; + case Op.SetupEnd: + inSetup = false; + break; + case Op.TryBegin when deterministic: + errors.Add(new ScriptError(i.At, "try needs a non-deterministic run")); + break; + case Op.Verb: + Verb(errors, i, game, p.Mode, inSetup); + break; + case Op.Wait or Op.Expect: + Condition(errors, i, game); + break; + } + } + return errors; + } + + private static void Verb(List<ScriptError> errors, Instr i, IRunGame game, RunMode mode, bool inSetup) + { + var lib = LibVerbs.Find(i.A); + var gameVerb = lib == null ? game.Actions.GetValueOrDefault(i.A) : null; + if (lib == null && gameVerb == null) + { + errors.Add(new ScriptError(i.At, $"unknown verb {i.A}")); + return; + } + if ((lib is { GameHook: true } && !game.Actions.ContainsKey(i.A)) || (lib?.Name == "rng" && game.Rng == null)) + { + errors.Add(new ScriptError(i.At, $"verb {i.A} not supported by game {game.Name}")); + return; + } + var (kind, min, max) = lib != null ? (lib.Kind, lib.MinArgs, lib.MaxArgs) : (gameVerb!.Kind, gameVerb.MinArgs, gameVerb.MaxArgs); + List<Token> tokens; + try + { + tokens = StepLexer.Tokens(i.B); + } + catch (FormatException e) + { + errors.Add(new ScriptError(i.At, e.Message)); + return; + } + if (tokens.Count < min || tokens.Count > max) + errors.Add(new ScriptError(i.At, $"verb {i.A} takes {min}..{max} arguments, got {tokens.Count}")); + if (kind == VerbKind.Debug && mode == RunMode.Player && !inSetup) + errors.Add(new ScriptError(i.At, $"debug verb {i.A} outside setup in player mode")); + if (i.A == "output" && tokens.Count == 1 && !LibVerbs.OutputLevels.Contains(tokens[0].Text)) + errors.Add(new ScriptError(i.At, "output level must be quiet|checkpoints|labels|steps|trace")); + foreach (var t in tokens.Where(t => !t.Quoted && t.Text.StartsWith('@'))) + { + var name = t.Text[1..(t.Text.IndexOf('(') is var p and > 0 ? p : t.Text.Length)]; + if (!game.Locators.ContainsKey(name)) + errors.Add(new ScriptError(i.At, $"unknown locator @{name}")); + } + } + + private static void Condition(List<ScriptError> errors, Instr i, IRunGame game) + { + if (i.A.Contains("${", StringComparison.Ordinal)) + return; // expanded at run time; checked then + Cond cond; + try + { + cond = ConditionParser.Parse(i.A); + } + catch (FormatException e) + { + errors.Add(new ScriptError(i.At, e.Message)); + return; + } + foreach (var bare in cond.Bare()) + { + if (!game.Probes.ContainsKey(bare) && !game.Constants.ContainsKey(bare)) + errors.Add(new ScriptError(i.At, $"unknown probe or constant {bare}")); + } + foreach (var (name, args) in cond.Probes()) + { + if (cond.Bare().Contains(name) && args.Count == 0) + continue; + if (!game.Probes.TryGetValue(name, out var probe)) + errors.Add(new ScriptError(i.At, $"unknown probe {name}")); + else if (args.Count < probe.MinArgs || args.Count > probe.MaxArgs) + errors.Add(new ScriptError(i.At, $"probe {name} takes {probe.MinArgs}..{probe.MaxArgs} arguments, got {args.Count}")); + } + } +} diff --git a/src/Godosa.Core/Runs/Scripts/StepLexer.cs b/src/Godosa.Core/Runs/Scripts/StepLexer.cs new file mode 100644 index 0000000..4c54544 --- /dev/null +++ b/src/Godosa.Core/Runs/Scripts/StepLexer.cs @@ -0,0 +1,104 @@ +// Provenance: own design — shared run scripts +using System.Globalization; +using System.Text; + +namespace Godosa.Core.Runs.Scripts; + +public sealed record Token(string Text, bool Quoted); + +/// <summary>Line lexing for <c>.steps</c>: <c>#</c> comments outside quotes; tokens split on spaces, <c>"…"</c> (escapes +/// <c>\"</c>, <c>\\</c>) and <c>(…)</c> groups stay one token.</summary> +public static class StepLexer +{ + public static string StripComment(string line) + { + var quoted = false; + for (var i = 0; i < line.Length; i++) + { + var c = line[i]; + if (quoted && c == '\\' && i + 1 < line.Length) + { + i++; + continue; + } + if (c == '"') + quoted = !quoted; + else if (c == '#' && !quoted) + return line[..i]; + } + return line; + } + + public static List<Token> Tokens(string text) + { + var tokens = new List<Token>(); + var i = 0; + while (i < text.Length) + { + if (text[i] == ' ') + { + i++; + continue; + } + if (text[i] == '"') + { + var sb = new StringBuilder(); + i++; + while (true) + { + if (i >= text.Length) + throw new FormatException("unterminated quote"); + var c = text[i]; + if (c == '\\' && i + 1 < text.Length) + { + sb.Append(text[i + 1]); + i += 2; + continue; + } + if (c == '"') + { + i++; + break; + } + sb.Append(c); + i++; + } + tokens.Add(new Token(sb.ToString(), true)); + continue; + } + var start = i; + var depth = 0; + while (i < text.Length && (depth > 0 || text[i] != ' ')) + { + if (text[i] == '(') + depth++; + else if (text[i] == ')' && depth > 0) + depth--; + i++; + } + tokens.Add(new Token(text[start..i], false)); + } + return tokens; + } +} + +/// <summary>Timeouts and waits in frames (1 frame = 1/60 s): <c>120</c> = 120 frames, <c>2s</c> = 120, <c>0.5s</c> = 30; +/// negative, non-whole frame counts and other units → null.</summary> +public static class Durations +{ + public const int FramesPerSecond = 60; + + public static int? Frames(string text) + { + if (text.EndsWith('s')) + { + var number = text[..^1]; + if (number.Length == 0 || !number.All(c => char.IsAsciiDigit(c) || c == '.') + || !decimal.TryParse(number, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var seconds)) + return null; + var frames = seconds * FramesPerSecond; + return frames == decimal.Truncate(frames) ? (int)frames : null; + } + return text.Length > 0 && text.All(char.IsAsciiDigit) && int.TryParse(text, CultureInfo.InvariantCulture, out var n) ? n : null; + } +} diff --git a/src/Godosa.Core/Runs/Scripts/Vars.cs b/src/Godosa.Core/Runs/Scripts/Vars.cs new file mode 100644 index 0000000..a01da62 --- /dev/null +++ b/src/Godosa.Core/Runs/Scripts/Vars.cs @@ -0,0 +1,174 @@ +// Provenance: own design — shared run scripts (spec §3.3) +using System.Text; + +namespace Godosa.Core.Runs.Scripts; + +public static class VarsFile +{ + public static (Dictionary<string, string> Values, List<ScriptError> Errors) Parse(string path, string text) + { + var values = new Dictionary<string, string>(StringComparer.Ordinal); + var errors = new List<ScriptError>(); + var lines = text.TrimStart('\uFEFF').Replace("\r\n", "\n").Split('\n'); + for (var i = 0; i < lines.Length; i++) + { + var line = StepLexer.StripComment(lines[i]).Trim(); + if (line.Length == 0) + continue; + var eq = line.IndexOf('='); + var name = eq > 0 ? line[..eq].Trim() : ""; + if (name.Length == 0 || name.Contains(' ')) + { + errors.Add(new ScriptError(new SourceRef(path, i + 1), "bad vars line (name = value)")); + continue; + } + values[name] = line[(eq + 1)..].Trim(); + } + return (values, errors); + } +} + +/// <summary><c>${name}</c> expansion, innermost first (<c>${ui.rest.${hours}}</c>); <c>${probe:name(args)}</c> = a probe +/// value; a <c>$</c> not followed by <c>{</c> is literal.</summary> +public static class VarExpander +{ + public static string Expand(string text, Func<string, string?> lookup, Func<string, RunValue>? probe) + { + var sb = new StringBuilder(); + var i = 0; + while (i < text.Length) + { + if (text[i] == '$' && i + 1 < text.Length && text[i + 1] == '{') + { + i += 2; + sb.Append(Reference(text, ref i, lookup, probe)); + continue; + } + sb.Append(text[i++]); + } + return sb.ToString(); + } + + /// <summary>Resolves the reference opened before <paramref name="i"/> (nested references in its name first); the + /// value is used as is, never scanned again.</summary> + private static string Reference(string text, ref int i, Func<string, string?> lookup, Func<string, RunValue>? probe) + { + var name = new StringBuilder(); + while (true) + { + if (i >= text.Length) + throw new ScriptFault($"unclosed ${{ in '{text}'"); + if (text[i] == '}') + { + i++; + break; + } + if (text[i] == '$' && i + 1 < text.Length && text[i + 1] == '{') + { + i += 2; + name.Append(Reference(text, ref i, lookup, probe)); + continue; + } + name.Append(text[i++]); + } + var n = name.ToString(); + if (n.Length == 0) + throw new ScriptFault("empty ${}"); + if (n.StartsWith("probe:", StringComparison.Ordinal)) + return (probe ?? throw new ScriptFault($"no probes here: ${{{n}}}"))(n["probe:".Length..]).ToString(); + return lookup(n) ?? throw new ScriptFault($"unresolved variable ${{{n}}}"); + } + + /// <summary>Plain names inside the innermost <c>${…}</c> (no nested <c>${</c>, no <c>probe:</c>).</summary> + public static IEnumerable<string> Names(string text) + { + for (var i = 0; i < text.Length - 1; i++) + { + if (text[i] != '$' || text[i + 1] != '{') + continue; + var close = text.IndexOf('}', i + 2); + if (close < 0) + yield break; + var inner = text[(i + 2)..close]; + if (inner.Length > 0 && !inner.Contains("${", StringComparison.Ordinal) && !inner.StartsWith("probe:", StringComparison.Ordinal)) + yield return inner; + } + } +} + +public sealed record ScopeState(Dictionary<string, string> Vars, bool Local, OutputLevel Level); + +/// <summary>Variable scopes: root (globals), include scopes (vars go to root, output level scoped), def-call scopes +/// (local vars). Lookup walks from the top down.</summary> +public sealed class VarScopes +{ + private readonly List<ScopeState> _stack; + + public VarScopes(OutputLevel level) => _stack = [new ScopeState(new Dictionary<string, string>(StringComparer.Ordinal), false, level)]; + + public int Depth => _stack.Count; + + public OutputLevel Level + { + get => _stack[^1].Level; + set => _stack[^1] = _stack[^1] with { Level = value }; + } + + public void Push(bool local, OutputLevel level, IEnumerable<KeyValuePair<string, string>> bindings) => + _stack.Add(new ScopeState(new Dictionary<string, string>(bindings, StringComparer.Ordinal), local, level)); + + public void Pop() + { + if (_stack.Count > 1) + _stack.RemoveAt(_stack.Count - 1); + } + + public void PopTo(int depth) + { + while (_stack.Count > Math.Max(1, depth)) + _stack.RemoveAt(_stack.Count - 1); + } + + public string? Get(string name) + { + for (var i = _stack.Count - 1; i >= 0; i--) + { + if (_stack[i].Vars.TryGetValue(name, out var v)) + return v; + } + return null; + } + + public void Set(string name, string value) + { + for (var i = _stack.Count - 1; i >= 1; i--) + { + if (_stack[i].Local) + { + _stack[i].Vars[name] = value; + return; + } + } + _stack[0].Vars[name] = value; + } + + public Dictionary<string, string> Visible() + { + var all = new Dictionary<string, string>(StringComparer.Ordinal); + foreach (var scope in _stack) + foreach (var (k, v) in scope.Vars) + all[k] = v; + return all; + } + + public List<ScopeState> Snapshot() => + [.. _stack.Select(s => s with { Vars = new Dictionary<string, string>(s.Vars, StringComparer.Ordinal) })]; + + public void Restore(IReadOnlyList<ScopeState> scopes) + { + _stack.Clear(); + _stack.AddRange(scopes.Select(s => s with { Vars = new Dictionary<string, string>(s.Vars, StringComparer.Ordinal) })); + if (_stack.Count == 0) + _stack.Add(new ScopeState(new Dictionary<string, string>(StringComparer.Ordinal), false, OutputLevel.Labels)); + } +} |
