From 39066900773e7857faf2d02e7ed51b71d219d97d Mon Sep 17 00:00:00 2001 From: godosa Date: Tue, 6 Oct 2026 23:39:36 +0200 Subject: godosa-engine: initial public history --- src/Godosa.Core/Runs/Scripts/CheckpointCache.cs | 112 +++++ src/Godosa.Core/Runs/Scripts/Condition.cs | 234 ++++++++++ src/Godosa.Core/Runs/Scripts/Input.cs | 24 + src/Godosa.Core/Runs/Scripts/InputPlans.cs | 111 +++++ src/Godosa.Core/Runs/Scripts/LibVerbs.cs | 32 ++ src/Godosa.Core/Runs/Scripts/RunGame.cs | 68 +++ src/Godosa.Core/Runs/Scripts/RunOutput.cs | 21 + src/Godosa.Core/Runs/Scripts/RunReport.cs | 58 +++ src/Godosa.Core/Runs/Scripts/ScriptAst.cs | 25 ++ src/Godosa.Core/Runs/Scripts/ScriptLoader.cs | 218 +++++++++ src/Godosa.Core/Runs/Scripts/ScriptParser.cs | 231 ++++++++++ src/Godosa.Core/Runs/Scripts/ScriptProgram.cs | 57 +++ src/Godosa.Core/Runs/Scripts/ScriptRunFlags.cs | 100 +++++ src/Godosa.Core/Runs/Scripts/ScriptRunner.cs | 568 ++++++++++++++++++++++++ src/Godosa.Core/Runs/Scripts/ScriptTypes.cs | 46 ++ src/Godosa.Core/Runs/Scripts/ScriptValidator.cs | 109 +++++ src/Godosa.Core/Runs/Scripts/StepLexer.cs | 104 +++++ src/Godosa.Core/Runs/Scripts/Vars.cs | 174 ++++++++ 18 files changed, 2292 insertions(+) create mode 100644 src/Godosa.Core/Runs/Scripts/CheckpointCache.cs create mode 100644 src/Godosa.Core/Runs/Scripts/Condition.cs create mode 100644 src/Godosa.Core/Runs/Scripts/Input.cs create mode 100644 src/Godosa.Core/Runs/Scripts/InputPlans.cs create mode 100644 src/Godosa.Core/Runs/Scripts/LibVerbs.cs create mode 100644 src/Godosa.Core/Runs/Scripts/RunGame.cs create mode 100644 src/Godosa.Core/Runs/Scripts/RunOutput.cs create mode 100644 src/Godosa.Core/Runs/Scripts/RunReport.cs create mode 100644 src/Godosa.Core/Runs/Scripts/ScriptAst.cs create mode 100644 src/Godosa.Core/Runs/Scripts/ScriptLoader.cs create mode 100644 src/Godosa.Core/Runs/Scripts/ScriptParser.cs create mode 100644 src/Godosa.Core/Runs/Scripts/ScriptProgram.cs create mode 100644 src/Godosa.Core/Runs/Scripts/ScriptRunFlags.cs create mode 100644 src/Godosa.Core/Runs/Scripts/ScriptRunner.cs create mode 100644 src/Godosa.Core/Runs/Scripts/ScriptTypes.cs create mode 100644 src/Godosa.Core/Runs/Scripts/ScriptValidator.cs create mode 100644 src/Godosa.Core/Runs/Scripts/StepLexer.cs create mode 100644 src/Godosa.Core/Runs/Scripts/Vars.cs (limited to 'src/Godosa.Core/Runs/Scripts') 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 Scopes, List Labels); + +public sealed record CachedCheckpoint(RunnerState State, byte[] Game); + +public static class CheckpointKeys +{ + public const string RunnerVersion = "run-scripts 1"; + + public static IReadOnlyDictionary 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(); + 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(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 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 args); + long? Constant(string name); +} + +/// 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. +public abstract record Cond +{ + public abstract RunValue Eval(IConditionContext c); + + public bool Test(IConditionContext c) => Truthy(Eval(c)); + + public abstract IEnumerable<(string Name, IReadOnlyList Args)> Probes(); + + public abstract IEnumerable 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)> Probes() => A.Probes().Concat(B.Probes()); + public override IEnumerable 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)> Probes() => A.Probes().Concat(B.Probes()); + public override IEnumerable 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)> Probes() => A.Probes(); + public override IEnumerable 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)> Probes() => A.Probes().Concat(B.Probes()); + public override IEnumerable 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)> Probes() => []; + public override IEnumerable Bare() => []; +} + +/// A probe call name(args), or a bare name (probe if the game has it, else a constant). +public sealed record NameCond(string Name, IReadOnlyList 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)> Probes() => [(Name, Args)]; + public override IEnumerable 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 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 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 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 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 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 Lex(string text) + { + var tokens = new List(); + 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 } + +/// One simulated user input event at virtual-screen coordinates. +public sealed record InputEvent(InputKind Kind, int X = 0, int Y = 0, MouseButton Button = MouseButton.Left, string Key = "", + string Text = "", int Delta = 0); + +/// An event due frames after its step began. +public sealed record PlannedInput(int Frame, InputEvent Event); + +public sealed record InputPlan(IReadOnlyList Events) +{ + public int LastFrame => Events.Count == 0 ? 0 : Events.Max(e => e.Frame); +} + +/// The game's input entry: the real UI in a window, its layout + hit-testing headless. +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 +{ + /// x,y or a locator @name(args) (args comma separated). + 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 args, Func position) + { + List 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; + +/// A verb the lib defines. = implemented by the game's +/// entry of the same name; otherwise the runner implements it. +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 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 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; + +/// A game's run-script adapter. holds the lib's game-hooked verbs by their lib name +/// (talk, walk-to, cheat, …) and the game's own verbs. Player-kind actions return input plans only; +/// the lib injects them. +public interface IRunGame +{ + string Name { get; } + string ContentVersion { get; } + IInputRouter Input { get; } + IReadOnlyDictionary Probes { get; } + IReadOnlyDictionary Constants { get; } + IReadOnlyDictionary Locators { get; } + IReadOnlyDictionary Actions { get; } + IRunRng? Rng { get; } + bool SupportsSnapshot { get; } + byte[] SnapshotSave(); + /// 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. + string? SnapshotRefusal() => null; + void SnapshotRestore(byte[] data); + /// The restore completes after this frame (a window rebuilds its game view): the runner pauses one frame + /// (), in which the host advances no game frame. + bool RestoreDeferred => false; + ulong Digest(); + string Dump(); + void Advance(); +} + +public interface IProbe +{ + int MinArgs { get; } + int MaxArgs { get; } + RunValue Eval(IReadOnlyList 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; +} + +/// Finds where to aim (virtual-screen position) from game state, as a player reads the screen. +public interface ILocator +{ + LocatorResult Locate(IReadOnlyList args); +} + +/// 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. +public interface IRunAction +{ + VerbKind Kind { get; } + int MinArgs { get; } + int MaxArgs { get; } + ActionStart Begin(IReadOnlyList 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; + +/// Auto-output filter: a line of level prints when level ≤ max(current, floor); +/// = log lines and failures. +public sealed class RunOutput(Action 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 Chain, string Step, string? Label, string Reason, + IReadOnlyList Recent, string Dump, IReadOnlyDictionary 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 Checkpoints, + IReadOnlyList 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 } + +/// One parsed line (or block header) of a .steps file; = the trimmed source line. +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 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 Body) : Node(At, Text); +public sealed record DefNode(SourceRef At, string Text, string Name, IReadOnlyList Params, IReadOnlyList Body) : Node(At, Text); + +public sealed record ScriptFile(string Path, int Format, RunMode? Mode, ulong? Seed, IReadOnlyList VarFiles, + IReadOnlyList Body, IReadOnlyList Errors); + +public static class ScriptFormat +{ + /// Newest format version this runner knows; every older one stays supported. + 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 Code { get; } = []; + public List Errors { get; } = []; + public Dictionary Defs { get; } = new(StringComparer.Ordinal); + public List Includes { get; } = []; + public List Calls { get; } = []; + public List Chain { get; } = []; + public HashSet Checkpoints { get; } = new(StringComparer.Ordinal); + public List> Known { get; } = []; + public Dictionary Vars { get; } = new(StringComparer.Ordinal); + public HashSet Locked { get; } = new(StringComparer.Ordinal); + public int TryDepth { get; set; } + } + + public static ScriptProgram Load(string entry, IScriptSource source, IReadOnlyDictionary 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 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(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; + +/// Format v1 parser: one file → ; every error collected (never throws). +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(); + var lines = new List(); + 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(); + 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 errors, ref RunMode? mode, ref ulong? seed, List 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 Block(List lines, ref int pos, int indent, string path, List errors) + { + var nodes = new List(); + 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; + } + + /// A line ending in ':' is a block header when it has an indented body or starts with a block keyword; + /// otherwise a statement (log Done:). + 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 body, List 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 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 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 } + +/// One flattened instruction. = include / call sites, outermost first. +public sealed record Instr(Op Op, SourceRef At, string Text, IReadOnlyList Chain, int Format) +{ + public string A { get; init; } = ""; + public string B { get; init; } = ""; + public int Timeout { get; init; } + public IReadOnlyList> 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 Vars, + IReadOnlySet Locked, IReadOnlyList Code, IReadOnlyList Errors); + +public interface IScriptSource +{ + string? Read(string path); +} + +public sealed class MemoryScriptSource(IReadOnlyDictionary files) : IScriptSource +{ + public string? Read(string path) => files.GetValueOrDefault(path); +} + +/// Files under ; paths are '/'-separated and relative to it. +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(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; + +/// Run-script CLI options (--script, --var k=v, --output, --no-cache, --no-snapshots, --from, +/// --until, --nondeterministic, --step-cap, --report-dir); determinism and seed come from +/// . +public sealed class ScriptRunFlags +{ + public string? Script { get; set; } + public Dictionary 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; + /// Overrides the script's seed header; neither → 0 and the game's rng is left alone. + 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; + /// false: checkpoints are markers — no digest (a whole-state serialization, slow in a big game), nothing + /// cached (--no-snapshots). + 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; } + /// The value a plan's frame 0 lands on (0 for the step's own plan). + 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