aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/src/Godosa.Core.Tests/Runs
diff options
context:
space:
mode:
Diffstat (limited to 'src/Godosa.Core.Tests/Runs')
-rw-r--r--src/Godosa.Core.Tests/Runs/RouteLegsTests.cs116
-rw-r--r--src/Godosa.Core.Tests/Runs/RunsTests.cs149
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ActionInputTests.cs47
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/CheckpointCacheTests.cs185
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ConditionTests.cs63
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/FakeGame.cs100
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ReviewFixTests.cs157
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/RunReportTests.cs83
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ScriptLoaderTests.cs103
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ScriptParserTests.cs111
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ScriptRunFlagsTests.cs54
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerStructureTests.cs85
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerTests.cs224
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ScriptValidatorTests.cs63
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/StepLexerTests.cs58
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/VarsTests.cs84
16 files changed, 1682 insertions, 0 deletions
diff --git a/src/Godosa.Core.Tests/Runs/RouteLegsTests.cs b/src/Godosa.Core.Tests/Runs/RouteLegsTests.cs
new file mode 100644
index 0000000..31295ab
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/RouteLegsTests.cs
@@ -0,0 +1,116 @@
+using Godosa.Core.Runs;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs;
+
+public class RouteLegsTests
+{
+ // 5×5 grid, rows top to bottom; '#' blocked, 's' soft (sealed by the game's scripts, opened later).
+ private static Func<long, long, bool> Grid(params string[] rows) => (x, y) => rows[y][(int)x] is '#' or 's';
+ private static Func<long, long, bool> Soft(params string[] rows) => (x, y) => rows[y][(int)x] == 's';
+
+ [Fact]
+ public void Path_GoesRoundAWall_StartExcluded()
+ {
+ var blocked = Grid(
+ ".#...",
+ ".#...",
+ ".#...",
+ ".....",
+ ".....");
+ // From 0,0 the only way past the wall is under it: down the left column to row 3, then up-right diagonally.
+ var path = RouteLegs.Path((0, 0), (2, 0), 5, 5, blocked);
+ Assert.NotNull(path);
+ Assert.Equal((2, 0), path![^1]);
+ Assert.DoesNotContain((0L, 0L), path);
+ Assert.Equal(6, path.Count); // 0,1 0,2 1,3 2,2 2,1 2,0
+ Assert.All(path, c => Assert.False(blocked(c.X, c.Y)));
+ }
+
+ [Fact]
+ public void Path_NoWay_Null()
+ {
+ var blocked = Grid(
+ "..#..",
+ "..#..",
+ "..#..",
+ "..#..",
+ "..#..");
+ Assert.Null(RouteLegs.Path((0, 0), (4, 0), 5, 5, blocked));
+ }
+
+ // The game's router takes a leg only within 2 cells (Chebyshev): from each stop the farthest accepted path cell.
+ [Fact]
+ public void Legs_FarthestAcceptedStopEachTime()
+ {
+ var path = new List<(long X, long Y)> { (1, 0), (2, 0), (3, 0), (4, 0), (5, 0), (6, 0), (7, 0) };
+ var legs = RouteLegs.Legs((0, 0), path, (a, b) => Math.Max(Math.Abs(a.X - b.X), Math.Abs(a.Y - b.Y)) <= 2);
+ Assert.Equal([(2L, 0L), (4L, 0L), (6L, 0L), (7L, 0L)], legs);
+ }
+
+ [Fact]
+ public void Legs_RouterAcceptsNothing_NullWithTheStuckStop()
+ {
+ var path = new List<(long X, long Y)> { (1, 0), (2, 0) };
+ Assert.Null(RouteLegs.Legs((0, 0), path, (a, b) => b == (1, 0), out var stuck));
+ Assert.Equal((1, 0), stuck);
+ }
+
+ // Closed off only by a sealed (soft) cell: the seals a way would cross once open, start to goal.
+ [Fact]
+ public void Seals_ListedInOrder()
+ {
+ string[] rows =
+ [
+ "..s..",
+ "#####",
+ ".....",
+ ".....",
+ ".....",
+ ];
+ Assert.Null(RouteLegs.Path((0, 0), (4, 0), 5, 5, Grid(rows)));
+ Assert.Equal([(2L, 0L)], RouteLegs.Seals((0, 0), (4, 0), 5, 5, Grid(rows), Soft(rows)));
+ Assert.Empty(RouteLegs.Seals((0, 0), (4, 4), 5, 5, Grid(rows), Soft(rows))); // walled, not sealed
+ }
+
+ [Fact]
+ public void Seals_CappedAndNoneWhenOpenAlready()
+ {
+ string[] twice =
+ [
+ ".s.s.",
+ "#####",
+ ".....",
+ ".....",
+ ".....",
+ ];
+ Assert.Equal([(1L, 0L), (3L, 0L)], RouteLegs.Seals((0, 0), (4, 0), 5, 5, Grid(twice), Soft(twice)));
+ Assert.Equal([(1L, 0L)], RouteLegs.Seals((0, 0), (4, 0), 5, 5, Grid(twice), Soft(twice), max: 1));
+ // Open the long way round (row 2): a seal on the short way is no reason to wait.
+ string[] detour =
+ [
+ "..s..",
+ "#.#.#",
+ ".....",
+ ".....",
+ ".....",
+ ];
+ Assert.Empty(RouteLegs.Seals((0, 0), (4, 0), 5, 5, Grid(detour), Soft(detour)));
+ }
+
+ // Hops: walk to a portal, click it, arrive at its exit; BFS over portal exits.
+ [Fact]
+ public void Hops_ThroughTwoPortals()
+ {
+ // Three rooms on a line, x 0–9 / 10–19 / 20–29 (walking stays inside a room); portal A in room 1 at 5 → 12, B in
+ // room 2 at 18 → 25; a dead-end portal C in room 1 at 2 → 40.
+ static int Room(long x) => (int)(x / 10);
+ bool Reaches(long from, long to) => Room(from) == Room(to);
+ var portals = new[] { ("A", 5L, 12L), ("B", 18L, 25L), ("C", 2L, 40L) };
+ var hops = RouteLegs.Hops(1L, 27L, portals, p => p.Item2, p => p.Item3, Reaches);
+ Assert.NotNull(hops);
+ Assert.Equal(["A", "B"], hops!.Select(p => p.Item1));
+ Assert.Null(RouteLegs.Hops(1L, 35L, portals, p => p.Item2, p => p.Item3, Reaches));
+ Assert.Empty(RouteLegs.Hops(1L, 7L, portals, p => p.Item2, p => p.Item3, Reaches)!);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/RunsTests.cs b/src/Godosa.Core.Tests/Runs/RunsTests.cs
new file mode 100644
index 0000000..1b6d6d4
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/RunsTests.cs
@@ -0,0 +1,149 @@
+using Godosa.Core.Runs;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs;
+
+public class RunsTests
+{
+ private static RunFlags Parse(params string[] args)
+ {
+ var flags = new RunFlags();
+ for (var i = 0; i < args.Length; i++)
+ Assert.True(RunFlags.TryTake(args, ref i, ref flags), args[i]);
+ return flags;
+ }
+
+ [Fact]
+ public void Flags_DeterministicWithAndWithoutSeed()
+ {
+ Assert.Equal(new RunFlags(Deterministic: true, Seed: 7), Parse("--deterministic", "7"));
+ Assert.Equal(new RunFlags(Deterministic: true), Parse("--deterministic"));
+ Assert.Equal(1UL, Parse("--deterministic").SeedOr(1));
+ Assert.Equal(7UL, Parse("--deterministic", "7").SeedOr(1));
+ }
+
+ [Fact]
+ public void Flags_SeedNotTakenWhenNextIsAnOption()
+ {
+ string[] args = ["--deterministic", "--mute"];
+ var flags = new RunFlags();
+ var i = 0;
+ Assert.True(RunFlags.TryTake(args, ref i, ref flags));
+ Assert.Equal(0, i);
+ i = 1;
+ Assert.False(RunFlags.TryTake(args, ref i, ref flags));
+ }
+
+ [Fact]
+ public void Flags_DigestEveryAndHeadless()
+ {
+ Assert.Equal(new RunFlags(Digest: true), Parse("--digest"));
+ Assert.Equal(new RunFlags(Digest: true, DigestEvery: 60), Parse("--digest", "every=60"));
+ Assert.Equal(new RunFlags(Deterministic: true, Headless: true), Parse("--headless"));
+ Assert.Equal(new RunFlags(Deterministic: true, Seed: 3, Headless: true), Parse("--headless", "--deterministic", "3"));
+ }
+
+ [Fact]
+ public void Flags_BadDigestEvery_Throws() =>
+ Assert.Throws<ArgumentException>(() => Parse("--digest", "every=0"));
+
+ [Fact]
+ public void Clock_DeterministicFixedStep()
+ {
+ var clock = new FrameClock(true);
+ Assert.Equal(1.0 / 60, clock.Next(0.5));
+ Assert.Equal(1.0 / 60, clock.Next(0.001));
+ Assert.Equal(2, clock.Frame);
+ Assert.Equal(2.0 / 60, clock.Seconds, 12);
+ }
+
+ [Fact]
+ public void Clock_WallClamped()
+ {
+ var clock = new FrameClock(false);
+ Assert.Equal(0.1, clock.Next(0.5));
+ Assert.Equal(0.02, clock.Next(0.02));
+ Assert.Equal(0, clock.Next(-1));
+ Assert.Equal(3, clock.Frame);
+ }
+
+ [Fact]
+ public void Clock_Held_NoTimeNoFrame()
+ {
+ var clock = new FrameClock(true) { Held = true };
+ Assert.Equal(0, clock.Next(1.0 / 60));
+ Assert.Equal(0, clock.Frame);
+ Assert.Equal(0, clock.Seconds);
+ clock.Held = false;
+ clock.Next(0);
+ Assert.Equal(1, clock.Frame);
+ }
+
+ // Published FNV-1a 64 vectors (isthe.com/chongo/tech/comp/fnv).
+ [Theory]
+ [InlineData("", 0xcbf29ce484222325UL)]
+ [InlineData("a", 0xaf63dc4c8601ec8cUL)]
+ [InlineData("foobar", 0x85944171f73967e8UL)]
+ public void Digest_FnvVectors(string text, ulong expected)
+ {
+ var d = new StateDigest();
+ d.Add(text);
+ Assert.Equal(expected, d.Value);
+ Assert.Equal(expected, StateDigest.Of(System.Text.Encoding.UTF8.GetBytes(text)));
+ }
+
+ // Hand-checked: FNV-1a 64 of the little-endian bytes 01 00 00 00 and of 1.5 as an IEEE double.
+ [Fact]
+ public void Digest_NumbersLittleEndian()
+ {
+ var u = new StateDigest();
+ u.Add(1u);
+ Assert.Equal(0xad2aca7747985764UL, u.Value);
+ var d = new StateDigest();
+ d.Add(1.5);
+ Assert.Equal(0xaa95e93229a27c80UL, d.Value);
+ Assert.Equal("aa95e93229a27c80", d.Hex);
+ }
+
+ [Fact]
+ public void Line_Format() =>
+ Assert.Equal("digest 120 3.5 000000000000beef", DigestLine.Format(120, 3.5, 0xbeef));
+
+ [Fact]
+ public void Line_FormatRoundsGameTime() =>
+ Assert.Equal("digest 1 0.017 0000000000000001", DigestLine.Format(1, 1.0 / 60, 1));
+
+ [Fact]
+ public void Line_Due()
+ {
+ Assert.True(DigestLine.Due(60, 60));
+ Assert.False(DigestLine.Due(59, 60));
+ Assert.False(DigestLine.Due(0, 60));
+ Assert.False(DigestLine.Due(60, 0));
+ }
+
+ // SplitMix64 seeded 0: outputs e220a8397b1dcdaf, 6e789e6aa1b965f4 (published reference sequence).
+ [Fact]
+ public void Seeds_SplitMixReference()
+ {
+ Assert.Equal(0xe220a8397b1dcdafUL, SeedStreams.Of(0, 0));
+ Assert.Equal(0x6e789e6aa1b965f4UL, SeedStreams.Of(0, 1));
+ Assert.Equal(0x63cbe1e459320dd7UL, SeedStreams.Of(7, 0));
+ }
+
+ [Fact]
+ public void Loop_StopsWhenFrameReturnsFalse()
+ {
+ var seen = new List<long>();
+ var (frames, capped) = HeadlessLoop.Run(n => { seen.Add(n); return n < 3; }, null);
+ Assert.Equal([1L, 2, 3], seen);
+ Assert.Equal(3, frames);
+ Assert.False(capped);
+ }
+
+ [Fact]
+ public void Loop_FramesLimit() => Assert.Equal((5L, false), HeadlessLoop.Run(_ => true, 5));
+
+ [Fact]
+ public void Loop_Cap() => Assert.Equal((10L, true), HeadlessLoop.Run(_ => true, null, cap: 10));
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ActionInputTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ActionInputTests.cs
new file mode 100644
index 0000000..1b53910
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ActionInputTests.cs
@@ -0,0 +1,47 @@
+using Godosa.Core.Runs;
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+using static Godosa.Core.Tests.Runs.Scripts.ScriptRunnerTests;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+/// <summary>Multi-click verbs (walk-path, fight, travel-to): a poll may hand the runner more input; the plan plays from
+/// the next frame (its frame 0), the action is not polled until it is sent.</summary>
+public sealed class ActionInputTests
+{
+ [Fact]
+ public void PollInput_PlaysFromTheNextFrame_ThenPollsAgain()
+ {
+ var g = new FakeGame();
+ var polls = new List<int>();
+ var follow = new InputPlan([new(0, new InputEvent(InputKind.KeyDown, Key: "B")), new(2, new InputEvent(InputKind.KeyUp, Key: "B"))]);
+ g.ActionTable["walk"] = new FakeAction(VerbKind.Player,
+ _ => new ActionStart(new InputPlan([new(0, new InputEvent(InputKind.KeyDown, Key: "A"))])),
+ n =>
+ {
+ polls.Add(n);
+ return n == 1 ? StepStatus.Input(follow) : StepStatus.Done;
+ });
+ var (r, output) = Run("format 1\nmode player\nwalk\nlog done", g);
+
+ Assert.True(r.Outcome == RunOutcome.Passed, string.Join("\n", output));
+ Assert.Equal(["A", "B", "B"], g.Sent.Select(s => s.Event.Key));
+ var a = g.Sent[0].Frame;
+ Assert.Equal(a + 1, g.Sent[1].Frame); // polled after frame a → B on the next frame
+ Assert.Equal(a + 3, g.Sent[2].Frame); // its frame 2
+ Assert.Equal([1, 2], polls); // no poll while the follow-up plan is unsent
+ Assert.Contains("done", output);
+ }
+
+ [Fact]
+ public void PollInput_HalfSent_ReleasedOnFailure()
+ {
+ var g = new FakeGame();
+ var follow = new InputPlan([new(0, new InputEvent(InputKind.KeyDown, Key: "B")), new(50, new InputEvent(InputKind.KeyUp, Key: "B"))]);
+ g.ActionTable["walk"] = new FakeAction(VerbKind.Player, _ => new ActionStart(), n => StepStatus.Input(follow));
+ var (r, _) = Run("format 1\nmode player\nwalk", g, new ScriptRunOptions { StepCap = 10 });
+
+ Assert.Equal(RunOutcome.Failed, r.Outcome);
+ Assert.Equal(InputKind.KeyUp, g.Sent[^1].Event.Kind); // the held key is let go
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/CheckpointCacheTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/CheckpointCacheTests.cs
new file mode 100644
index 0000000..a84cf94
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/CheckpointCacheTests.cs
@@ -0,0 +1,185 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public sealed class CheckpointCacheTests : IDisposable
+{
+ private readonly string _dir = Path.Combine(AppContext.BaseDirectory, "rs-tmp", Guid.NewGuid().ToString("N"));
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_dir))
+ Directory.Delete(_dir, recursive: true);
+ }
+
+ private (ScriptRunner Runner, RunResult Result, List<string> Output) Run(string main, FakeGame g, ScriptRunOptions? o = null, Dictionary<string, string>? files = null)
+ {
+ var all = new Dictionary<string, string>(files ?? []) { ["main.steps"] = main };
+ var output = new List<string>();
+ var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(all), new Dictionary<string, string>()), g,
+ o ?? new ScriptRunOptions { CacheDir = _dir }, output.Add);
+ return (runner, runner.Run(10_000), output);
+ }
+
+ private const string Script = "format 1\nmode player\nkey A\ncheckpoint one\nkey B\ncheckpoint two\nkey C";
+
+ // --no-snapshots: checkpoints are markers only — no digest (a whole-world serialization: seconds late in a big game,
+ // the window froze), nothing cached; a later run resumes from nothing.
+ [Fact]
+ public void NoSnapshots_CheckpointsNeitherDigestNorCache()
+ {
+ var g = new FakeGame();
+ var (r, res, output) = Run(Script, g, new ScriptRunOptions { CacheDir = _dir, Snapshots = false });
+ Assert.Equal(RunOutcome.Passed, res.Outcome);
+ Assert.Equal(0, g.Digests);
+ Assert.Equal(["marker", "marker"], r.Report!.Checkpoints.Select(c => c.Source));
+ Assert.Contains("checkpoint two f=4", output);
+ Assert.False(Directory.Exists(_dir) && Directory.EnumerateFiles(_dir, "*", SearchOption.AllDirectories).Any());
+ var (_, stopped, _) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, Snapshots = false, Until = "one" });
+ Assert.Equal((RunOutcome.Stopped, 2L), (stopped.Outcome, stopped.Frames));
+ }
+
+ [Fact]
+ public void SecondRun_ResumesAtTheLastCachedCheckpoint()
+ {
+ var first = new FakeGame();
+ first.OnAdvance = f => first.State += 1;
+ var (r1, _, _) = Run(Script, first);
+ Assert.Equal(6, r1.Report!.Frames);
+ Assert.Equal(["replayed", "replayed"], r1.Report.Checkpoints.Select(c => c.Source));
+
+ var second = new FakeGame();
+ second.OnAdvance = f => second.State += 1;
+ var (r2, res, output) = Run(Script, second);
+ Assert.Equal(RunOutcome.Passed, res.Outcome);
+ Assert.Contains("resumed at two (cache) f=4", output);
+ Assert.Equal(["C"], second.Sent.Where(s => s.Event.Kind == InputKind.KeyDown).Select(s => s.Event.Key));
+ Assert.Equal((6L, 6), (res.Frames, second.State)); // restored frame 4 / state 4, key C = 2 frames → 6 / 6
+ Assert.Equal(1, r2.Report!.CacheHits);
+ }
+
+ // A window restore rebuilds the game view after the frame: the runner pauses (Restoring) for that frame, no game frame
+ // passes, and the run ends as an immediate restore would.
+ [Fact]
+ public void DeferredRestore_PausesOneFrame_SameResult()
+ {
+ var first = new FakeGame();
+ first.OnAdvance = f => first.State += 1;
+ Run(Script, first);
+
+ var second = new FakeGame { RestoreDeferred = true };
+ second.OnAdvance = f => second.State += 1;
+ var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = Script }),
+ new Dictionary<string, string>()), second, new ScriptRunOptions { CacheDir = _dir }, _ => { });
+ runner.BeforeFrame();
+ Assert.True(runner.Restoring);
+ Assert.Empty(second.Sent);
+
+ var third = new FakeGame { RestoreDeferred = true };
+ third.OnAdvance = f => third.State += 1;
+ var (r3, res, _) = Run(Script, third);
+ Assert.False(r3.Restoring);
+ Assert.Equal((6L, 6), (res.Frames, third.State)); // the paused frame advanced nothing
+ }
+
+ [Fact]
+ public void ChangedStepBeforeACheckpoint_InvalidatesIt_LaterStepsDoNot()
+ {
+ Run(Script, new FakeGame());
+ var (r, _, output) = Run(Script.Replace("key B", "key X"), new FakeGame());
+ Assert.Contains("resumed at one (cache) f=2", output);
+ var (r3, _, output3) = Run(Script.Replace("key C", "key Y"), new FakeGame());
+ Assert.Contains("resumed at two (cache) f=4", output3);
+ }
+
+ [Fact]
+ public void CommentEdits_KeepTheCache()
+ {
+ Run(Script, new FakeGame());
+ var (_, _, output) = Run("# new comment\n" + Script.Replace("key A", "key A # aim"), new FakeGame());
+ Assert.Contains("resumed at two (cache) f=4", output);
+ }
+
+ [Fact]
+ public void SeedContentOrVars_InvalidateEverything()
+ {
+ Run(Script, new FakeGame());
+ var (_, _, output) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, Seed = 5 });
+ Assert.DoesNotContain(output, l => l.StartsWith("resumed"));
+ var (_, _, output2) = Run(Script, new FakeGame { ContentVersion = "c2" });
+ Assert.DoesNotContain(output2, l => l.StartsWith("resumed"));
+ }
+
+ [Fact]
+ public void NoCache_ReplaysAndWarnsOnDigestMismatch()
+ {
+ Run(Script, new FakeGame());
+ var drift = new FakeGame { State = 100 };
+ var (r, _, output) = Run(Script, drift, new ScriptRunOptions { CacheDir = _dir, UseCache = false });
+ Assert.DoesNotContain(output, l => l.StartsWith("resumed"));
+ Assert.Contains(r.Report!.Warnings, w => w.StartsWith("checkpoint one digest differs from cache"));
+ // Cache replaced: a cached run now resumes from the new state.
+ var after = new FakeGame();
+ Run(Script, after);
+ Assert.Equal(100, after.State);
+ }
+
+ [Fact]
+ public void From_RequiresACachedCheckpoint_Until_StopsEarly()
+ {
+ var (_, missing, out1) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, From = "two" });
+ Assert.Equal((RunOutcome.Invalid, 2), (missing.Outcome, missing.ExitCode));
+ Assert.Contains("checkpoint two not cached", out1);
+ var (_, unknown, out2) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, From = "nope" });
+ Assert.Contains("unknown checkpoint nope", out2);
+ var g = new FakeGame();
+ var (_, stopped, _) = Run(Script, g, new ScriptRunOptions { CacheDir = _dir, Until = "one" });
+ Assert.Equal((RunOutcome.Stopped, 0, 2L), (stopped.Outcome, stopped.ExitCode, stopped.Frames));
+ var (_, from, outFrom) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, From = "one" });
+ Assert.Equal(RunOutcome.Passed, from.Outcome);
+ Assert.Contains("resumed at one (cache) f=2", outFrom);
+ }
+
+ // Review focus 3: a checkpoint inside an include + def call restores vars, output level and labels.
+ [Fact]
+ public void Resume_InsideIncludeAndCall_RestoresScopes()
+ {
+ var files = new Dictionary<string, string>
+ {
+ ["ch.steps"] = "format 1\noutput steps\ndef d(who):\n set inner = ${who}-x\n checkpoint deep\n log \"${who} ${inner} ${top}\"\nstep s:\n call d(gil)\n key Q",
+ };
+ const string main = "format 1\nmode player\nset top = T\ninclude ch.steps\nkey Z";
+ var (_, _, replay) = Run(main, new FakeGame(), files: files);
+ var (_, _, resumed) = Run(main, new FakeGame(), files: files);
+ Assert.Contains("resumed at deep (cache) f=0", resumed);
+ string[] Tail(List<string> o) => [.. o.Where(l => !l.StartsWith("resumed") && !l.StartsWith("checkpoint") && !l.StartsWith("digest"))];
+ Assert.Equal(Tail(replay), Tail(resumed));
+ Assert.Contains("gil gil-x T", resumed);
+ Assert.Contains(resumed, l => l.StartsWith("[ch.steps:9 s] key Q done")); // output level steps + label restored
+ }
+
+ [Fact]
+ public void Eviction_KeepsTotalUnderTheCap()
+ {
+ var cache = new CheckpointCache(_dir, capBytes: 300);
+ var state = new RunnerState(0, 0, [], []);
+ for (var i = 0; i < 5; i++)
+ {
+ cache.Save($"k{i}", new CachedCheckpoint(state, new byte[100]));
+ File.SetLastWriteTimeUtc(Path.Combine(_dir, $"k{i}.snap"), new DateTime(2026, 1, 1, 0, 0, i, DateTimeKind.Utc));
+ }
+ var left = Directory.GetFiles(_dir, "*.snap").Select(Path.GetFileNameWithoutExtension).Order().ToList();
+ Assert.True(Directory.GetFiles(_dir, "*.snap").Sum(f => new FileInfo(f).Length) <= 300);
+ Assert.Contains("k4", left);
+ Assert.DoesNotContain("k0", left);
+ }
+
+ [Fact]
+ public void NoSnapshotSupport_NoCache()
+ {
+ var (r, _, _) = Run(Script, new FakeGame { SupportsSnapshot = false });
+ Assert.False(Directory.Exists(_dir) && Directory.GetFiles(_dir).Length > 0);
+ Assert.Equal(0, r.Report!.CacheMisses);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ConditionTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ConditionTests.cs
new file mode 100644
index 0000000..d14ea6a
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ConditionTests.cs
@@ -0,0 +1,63 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class ConditionTests
+{
+ private sealed class Ctx : IConditionContext
+ {
+ public Dictionary<string, RunValue> Values { get; } = new()
+ {
+ ["quest(1)"] = RunValue.Of(2), ["map"] = RunValue.Of("tarant"), ["dialog-open"] = RunValue.Of(true),
+ ["global(gold)"] = RunValue.Of(500), ["alive"] = RunValue.Of(false),
+ };
+
+ public bool HasProbe(string name) => name is "quest" or "map" or "dialog-open" or "global" or "alive";
+
+ public RunValue Probe(string name, IReadOnlyList<string> args) =>
+ Values[args.Count == 0 ? name : $"{name}({string.Join(",", args)})"];
+
+ public long? Constant(string name) => name switch { "accepted" => 2, "completed" => 4, _ => null };
+ }
+
+ private static bool Test(string text) => ConditionParser.Parse(text).Test(new Ctx());
+
+ [Theory]
+ [InlineData("quest(1) >= accepted", true)]
+ [InlineData("quest(1) = completed", false)]
+ [InlineData("map = \"tarant\"", true)]
+ [InlineData("map != \"ashbury\"", true)]
+ [InlineData("dialog-open", true)]
+ [InlineData("alive", false)]
+ [InlineData("not alive", true)]
+ [InlineData("dialog-open and global(gold) > 499", true)]
+ [InlineData("alive or global(gold) < 100", false)]
+ [InlineData("not (alive or dialog-open)", false)]
+ [InlineData("alive or dialog-open and quest(1) = 2", true)] // and binds tighter than or
+ [InlineData("global(gold) <= 500", true)]
+ public void Evaluates(string text, bool expected) => Assert.Equal(expected, Test(text));
+
+ [Fact]
+ public void MismatchedTypes_Fault() =>
+ Assert.Equal("cannot compare tarant with 3", Assert.Throws<ScriptFault>(() => Test("map > 3")).Message);
+
+ [Theory]
+ [InlineData("quest(1) >=")]
+ [InlineData("(alive")]
+ [InlineData("alive alive")]
+ [InlineData("\"open")]
+ public void BadSyntax_Throws(string text) => Assert.Throws<FormatException>(() => ConditionParser.Parse(text));
+
+ [Fact]
+ public void ListsProbesAndBareNames()
+ {
+ var c = ConditionParser.Parse("quest(1) >= accepted and not dialog-open");
+ Assert.Equal(["quest(1)", "accepted", "dialog-open"], c.Probes().Select(p => p.Args.Count == 0 ? p.Name : $"{p.Name}({string.Join(",", p.Args)})"));
+ Assert.Equal(["accepted", "dialog-open"], c.Bare());
+ }
+
+ [Fact]
+ public void Describe_ListsProbeValues() =>
+ Assert.Equal("quest(1)=2 map=tarant", Cond.Describe(ConditionParser.Parse("quest(1) > 3 and map = \"x\""), new Ctx()));
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/FakeGame.cs b/src/Godosa.Core.Tests/Runs/Scripts/FakeGame.cs
new file mode 100644
index 0000000..8318148
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/FakeGame.cs
@@ -0,0 +1,100 @@
+using Godosa.Core.Runs.Scripts;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+/// <summary>Scriptable game for runner tests: probes are a dictionary, actions are lambdas, input is recorded with the
+/// frame it arrived on.</summary>
+public sealed class FakeGame : IRunGame, IInputRouter
+{
+ public long Frame { get; private set; }
+ public List<(long Frame, InputEvent Event)> Sent { get; } = [];
+ public Dictionary<string, Func<IReadOnlyList<string>, RunValue>> ProbeFns { get; } = new();
+ public Dictionary<string, IRunAction> ActionTable { get; } = new();
+ public Dictionary<string, (int X, int Y)> Objects { get; } = new();
+ public Dictionary<string, long> ConstantTable { get; } = new() { ["accepted"] = 2 };
+ public Action<long>? OnAdvance { get; set; }
+ public int State { get; set; }
+ public FakeRng? RngImpl { get; set; } = new();
+
+ public string Name => "fake";
+ public string ContentVersion { get; set; } = "c1";
+ public IInputRouter Input => this;
+ public IReadOnlyDictionary<string, IProbe> Probes => ProbeFns.ToDictionary(p => p.Key, p => (IProbe)new FnProbe(p.Value));
+ public IReadOnlyDictionary<string, long> Constants => ConstantTable;
+ public IReadOnlyDictionary<string, ILocator> Locators => new Dictionary<string, ILocator> { ["obj"] = new ObjLocator(this) };
+ public IReadOnlyDictionary<string, IRunAction> Actions => ActionTable;
+ public IRunRng? Rng => RngImpl;
+ public bool SupportsSnapshot { get; set; } = true;
+ public bool RestoreDeferred { get; set; }
+
+ public Action<InputEvent>? OnSend { get; set; }
+ public bool RestoreThrows { get; set; }
+ public string? Refusal { get; set; }
+ public string? SnapshotRefusal() => Refusal;
+
+ public void Send(InputEvent e)
+ {
+ OnSend?.Invoke(e);
+ Sent.Add((Frame, e));
+ }
+
+ public byte[] SnapshotSave() => BitConverter.GetBytes(State).Concat(BitConverter.GetBytes(Frame)).ToArray();
+
+ public void SnapshotRestore(byte[] data)
+ {
+ if (RestoreThrows)
+ throw new IOException("restore broke");
+ State = BitConverter.ToInt32(data, 0);
+ Frame = BitConverter.ToInt64(data, 4);
+ }
+
+ public int Digests { get; private set; }
+ public ulong Digest() { Digests++; return (ulong)State * 1_000_003UL + (ulong)Frame; }
+ public string Dump() => $"state={State} frame={Frame}";
+
+ public void Advance()
+ {
+ Frame++;
+ OnAdvance?.Invoke(Frame);
+ }
+
+ private sealed class FnProbe(Func<IReadOnlyList<string>, RunValue> fn) : IProbe
+ {
+ public int MinArgs => 0;
+ public int MaxArgs => 4;
+ public RunValue Eval(IReadOnlyList<string> args) => fn(args);
+ }
+
+ private sealed class ObjLocator(FakeGame g) : ILocator
+ {
+ public LocatorResult Locate(IReadOnlyList<string> args) =>
+ g.Objects.TryGetValue(args[0], out var p) ? LocatorResult.Ok(p.X, p.Y) : LocatorResult.Fail($"no object {args[0]} on screen");
+ }
+}
+
+public sealed class FakeRng : IRunRng
+{
+ public List<string> Calls { get; } = [];
+ public void Seed(long seed) => Calls.Add($"seed {seed}");
+ public void ForceNext(string kind, long value) => Calls.Add($"next {kind} {value}");
+}
+
+/// <summary>An action: optional input plan, then Poll() answers from a function of the frames polled so far.</summary>
+public sealed class FakeAction(VerbKind kind, Func<IReadOnlyList<string>, ActionStart> begin, Func<int, StepStatus>? poll = null,
+ int min = 0, int max = 4) : IRunAction
+{
+ private int _polls;
+ public VerbKind Kind => kind;
+ public int MinArgs => min;
+ public int MaxArgs => max;
+ public List<IReadOnlyList<string>> Begun { get; } = [];
+
+ public ActionStart Begin(IReadOnlyList<string> args)
+ {
+ Begun.Add(args);
+ _polls = 0;
+ return begin(args);
+ }
+
+ public StepStatus Poll() => (poll ?? (_ => StepStatus.Done))(++_polls);
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ReviewFixTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ReviewFixTests.cs
new file mode 100644
index 0000000..1b7257b
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ReviewFixTests.cs
@@ -0,0 +1,157 @@
+using Godosa.Core.Runs;
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+using static Godosa.Core.Tests.Runs.Scripts.ScriptRunnerTests;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+/// <summary>Final-review findings (lib core): each test failed before its fix.</summary>
+public sealed class ReviewFixTests : IDisposable
+{
+ private readonly string _dir = Path.Combine(AppContext.BaseDirectory, "rs-tmp", Guid.NewGuid().ToString("N"));
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_dir))
+ Directory.Delete(_dir, recursive: true);
+ }
+
+ private const string Script = "format 1\nmode player\nkey A\ncheckpoint one\nkey B\ncheckpoint two\nkey C";
+
+ [Fact]
+ public void NoArgVerb_AndEmptySet_Run()
+ {
+ var g = new FakeGame();
+ g.ActionTable["close-panel"] = new FakeAction(VerbKind.Player, _ => new ActionStart(Status: StepStatus.Done), min: 0, max: 0);
+ var (r, output) = Run("format 1\nmode player\nset x =\nclose-panel\nlog \"[${x}]\"", g);
+ Assert.Equal(RunOutcome.Passed, r.Outcome);
+ Assert.Contains("[]", output);
+ }
+
+ // A probe value (dialog text) with quotes inside a quoted argument stays text: the line is split into tokens first.
+ [Fact]
+ public void QuotedArgument_ValueWithQuotes_StaysOneToken()
+ {
+ var (r, output) = Run("format 1\nmode player\nset x = \"\\\"Hi,\\\" he said\"\nlog \"R: ${x}\"", new FakeGame());
+ Assert.Equal(RunOutcome.Passed, r.Outcome);
+ Assert.Contains("R: \"Hi,\" he said", output);
+ }
+
+ [Fact]
+ public void SubstitutedValues_AreNotRescanned()
+ {
+ var values = new Dictionary<string, string> { ["d"] = "$", ["e"] = "{x}", ["raw"] = "${y}" };
+ Assert.Equal("${x}", VarExpander.Expand("${d}${e}", n => values.GetValueOrDefault(n), null));
+ Assert.Equal("say ${y}!", VarExpander.Expand("say ${raw}!", n => values.GetValueOrDefault(n), null));
+ }
+
+ [Theory]
+ [InlineData("log Done:", "log", "Done:")]
+ [InlineData("type name:", "type", "name:")]
+ public void LineEndingInColon_IsAVerbUnlessABlockKeyword(string line, string verb, string args)
+ {
+ var f = ScriptParser.Parse("main.steps", $"format 1\nmode debug\n{line}");
+ Assert.Empty(f.Errors);
+ var v = Assert.IsType<VerbNode>(f.Body.Single());
+ Assert.Equal((verb, args), (v.Verb, v.Args));
+ }
+
+ [Fact]
+ public void ThrowingInputRouter_FailsTheRun()
+ {
+ var g = new FakeGame { OnSend = _ => throw new InvalidOperationException("router down") };
+ var (r, output) = Run("format 1\nmode player\nkey R", g);
+ Assert.Equal(RunOutcome.Failed, r.Outcome);
+ Assert.Contains(output, l => l == "FAIL main.steps:3 key R: InvalidOperationException: router down");
+ }
+
+ [Fact]
+ public void CorruptCacheEntry_IsAMiss()
+ {
+ Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir });
+ foreach (var f in Directory.GetFiles(_dir, "*.snap"))
+ File.WriteAllBytes(f, [.. "GRS1"u8, 3, 0, 0, 0, (byte)'{', (byte)'x', (byte)'!']);
+ var (r, output) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir });
+ Assert.Equal(RunOutcome.Passed, r.Outcome);
+ Assert.DoesNotContain(output, l => l.StartsWith("resumed"));
+ }
+
+ // A game state the snapshot can't hold (an open dialog) → checkpoint not cached, run unharmed.
+ [Fact]
+ public void RefusedSnapshot_CheckpointNotCached()
+ {
+ var g = new FakeGame { Refusal = "dialog open" };
+ var output = new List<string>();
+ var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string>
+ {
+ ["main.steps"] = Script,
+ }), new Dictionary<string, string>()), g, new ScriptRunOptions { CacheDir = _dir }, output.Add);
+ Assert.Equal(RunOutcome.Passed, runner.Run(1000).Outcome);
+ Assert.Equal(["checkpoint one not cached: dialog open", "checkpoint two not cached: dialog open"], runner.Report!.Warnings);
+ Assert.False(Directory.Exists(_dir) && Directory.GetFiles(_dir, "*.snap", SearchOption.AllDirectories).Length > 0);
+ }
+
+ [Fact]
+ public void FailingRestore_FailsTheRun()
+ {
+ Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir });
+ var (r, output) = Run(Script, new FakeGame { RestoreThrows = true }, new ScriptRunOptions { CacheDir = _dir });
+ Assert.Equal((RunOutcome.Failed, 1), (r.Outcome, r.ExitCode));
+ Assert.Contains(output, l => l.Contains("IOException: restore broke"));
+ }
+
+ [Fact]
+ public void UnwritableCacheAndReportDirs_WarnInsteadOfThrowing()
+ {
+ Directory.CreateDirectory(_dir);
+ var file = Path.Combine(_dir, "file");
+ File.WriteAllText(file, "x");
+ var g = new FakeGame();
+ g.ProbeFns["no"] = _ => RunValue.Of(false);
+ var output = new List<string>();
+ var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string>
+ {
+ ["main.steps"] = "format 1\nmode player\ncheckpoint one\nexpect no",
+ }), new Dictionary<string, string>()), g, new ScriptRunOptions { CacheDir = Path.Combine(file, "cache"), ReportDir = Path.Combine(file, "report") }, output.Add);
+ var r = runner.Run(1000);
+ Assert.Equal(RunOutcome.Failed, r.Outcome);
+ Assert.Contains(runner.Report!.Warnings, w => w.StartsWith("checkpoint cache not written:"));
+ Assert.Contains(output, l => l.StartsWith("warning: report not written:"));
+ }
+
+ [Fact]
+ public void Until_UnknownName_Invalid()
+ {
+ var (r, output) = Run(Script, new FakeGame(), new ScriptRunOptions { Until = "nowhere" });
+ Assert.Equal((RunOutcome.Invalid, 2), (r.Outcome, r.ExitCode));
+ Assert.Contains("unknown label or checkpoint nowhere", output);
+ }
+
+ [Fact]
+ public void Until_ResumesOnlyBeforeIt()
+ {
+ Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir }); // caches one and two
+ var output = new List<string>();
+ var r = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = Script }),
+ new Dictionary<string, string>()), new FakeGame(), new ScriptRunOptions { CacheDir = _dir, Until = "two" }, output.Add).Run(1000);
+ Assert.Contains("resumed at one (cache) f=2", output);
+ Assert.Equal((RunOutcome.Stopped, 4L), (r.Outcome, r.Frames));
+ }
+
+ [Fact]
+ public void ScriptSeedHeader_SeedsTheGame_CommandLineWins()
+ {
+ var g = new FakeGame();
+ var output = new List<string>();
+ var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = "format 1\nmode player\nseed 7\nkey A" }),
+ new Dictionary<string, string>()), g, new ScriptRunOptions(), output.Add);
+ runner.Run(1000);
+ Assert.Equal(["seed 7"], g.RngImpl!.Calls);
+ Assert.Equal(7UL, runner.Report!.Seed);
+ var g2 = new FakeGame();
+ var runner2 = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = "format 1\nmode player\nseed 7\nkey A" }),
+ new Dictionary<string, string>()), g2, new ScriptRunOptions { Seed = 9 }, output.Add);
+ runner2.Run(1000);
+ Assert.Equal(["seed 9"], g2.RngImpl!.Calls);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/RunReportTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/RunReportTests.cs
new file mode 100644
index 0000000..00ee4b0
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/RunReportTests.cs
@@ -0,0 +1,83 @@
+using System.Text.Json;
+using Godosa.Core.Runs;
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public sealed class RunReportTests : IDisposable
+{
+ private readonly string _dir = Path.Combine(AppContext.BaseDirectory, "rs-tmp", Guid.NewGuid().ToString("N"));
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_dir))
+ Directory.Delete(_dir, recursive: true);
+ }
+
+ private static (ScriptRunner Runner, RunResult Result, List<string> Output) Run(string main, FakeGame g, ScriptRunOptions o, Dictionary<string, string>? files = null)
+ {
+ var all = new Dictionary<string, string>(files ?? []) { ["main.steps"] = main };
+ var output = new List<string>();
+ var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(all), new Dictionary<string, string>()), g, o, output.Add);
+ return (runner, runner.Run(10_000), output);
+ }
+
+ [Fact]
+ public void Failure_ReportHoldsChainStepReasonRecentDumpVars()
+ {
+ var g = new FakeGame { State = 7 };
+ g.ProbeFns["quest"] = _ => RunValue.Of(1);
+ var (runner, _, _) = Run("format 1\nmode player\nset who = Gilbert\nkey A\ninclude act2.steps", g, new ScriptRunOptions { ReportDir = _dir },
+ new() { ["act2.steps"] = "format 1\nstep talk-gilbert:\n key B\n expect quest(5) >= accepted" });
+ var f = runner.Report!.Failure!;
+ Assert.Equal("act2.steps:4", f.At);
+ Assert.Equal(["main.steps:5"], f.Chain);
+ Assert.Equal(("expect quest(5) >= accepted", "talk-gilbert"), (f.Step, f.Label));
+ Assert.Equal("expect failed: quest(5) >= accepted (quest(5)=1)", f.Reason);
+ Assert.Equal(["main.steps:4 key A f=2", "act2.steps:3 key B f=4"], f.Recent);
+ Assert.Equal("state=7 frame=4", f.Dump);
+ Assert.Equal("Gilbert", f.Vars["who"]);
+ Assert.True(File.Exists(Path.Combine(_dir, "report.txt")));
+ Assert.True(File.Exists(Path.Combine(_dir, "fail.snap")));
+ var json = JsonDocument.Parse(File.ReadAllText(Path.Combine(_dir, "report.json")));
+ Assert.Equal("Failed", json.RootElement.GetProperty("Outcome").GetString());
+ var text = File.ReadAllText(Path.Combine(_dir, "report.txt"));
+ Assert.Contains("main.steps:5 → act2.steps:4", text);
+ Assert.Contains("state=7 frame=4", text);
+ }
+
+ [Fact]
+ public void Recent_KeepsLast20()
+ {
+ var g = new FakeGame();
+ g.ProbeFns["no"] = _ => RunValue.Of(false);
+ var steps = string.Join("\n", Enumerable.Range(1, 25).Select(n => $"key K{n}"));
+ var (runner, _, _) = Run($"format 1\nmode player\n{steps}\nexpect no", g, new ScriptRunOptions());
+ var recent = runner.Report!.Failure!.Recent;
+ Assert.Equal(20, recent.Count);
+ Assert.StartsWith("main.steps:8 key K6", recent[0]);
+ }
+
+ [Fact]
+ public void Summary_StepsFramesCheckpointsDigests()
+ {
+ var g = new FakeGame { State = 3 };
+ var (runner, r, output) = Run("format 1\nmode player\nkey A\ncheckpoint one\nstep s:\n key B", g, new ScriptRunOptions { Seed = 9 });
+ var rep = runner.Report!;
+ Assert.Equal((RunOutcome.Passed, 4L, 2, 9UL), (rep.Outcome, rep.Frames, rep.Steps, rep.Seed));
+ // Digest = State * 1_000_003 + Frame (FakeGame), at frame 2: 3 * 1000003 + 2 = 3000011.
+ Assert.Equal([new CheckpointRecord("one", 2, 3000011UL.ToString("x16"), "replayed")], rep.Checkpoints);
+ Assert.Contains(DigestLine.Format(2, 2 / 60.0, 3000011UL), output);
+ Assert.Contains(DigestLine.Format(4, 4 / 60.0, 3000013UL), output); // after the labelled step
+ }
+
+ [Fact]
+ public void NonDeterministic_NoDigestLines_FlagInReport()
+ {
+ var (runner, _, output) = Run("format 1\nmode player\ncheckpoint one", new FakeGame(), new ScriptRunOptions { Deterministic = false });
+ Assert.DoesNotContain(output, l => l.StartsWith("digest "));
+ Assert.False(runner.Report!.Deterministic);
+ Assert.Contains("deterministic: no", ReportWriter.Text(runner.Report));
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptLoaderTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptLoaderTests.cs
new file mode 100644
index 0000000..f28f330
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptLoaderTests.cs
@@ -0,0 +1,103 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class ScriptLoaderTests
+{
+ private static ScriptProgram Load(Dictionary<string, string> files, Dictionary<string, string>? vars = null) =>
+ ScriptLoader.Load("main.steps", new MemoryScriptSource(files), vars ?? []);
+
+ private static string Errors(ScriptProgram p) => string.Join(" | ", p.Errors.Select(e => e.ToString()));
+
+ private static string Ops(ScriptProgram p) => string.Join(" ", p.Code.Select(i => i.Op switch
+ {
+ Op.Verb => $"{i.A}", Op.ScopeBegin => i.LocalVars ? "{L" : "{", Op.ScopeEnd => "}", Op.LabelBegin => $"[{i.A}",
+ Op.LabelEnd => "]", Op.TryBegin => "try(", Op.TryEnd => ")", Op.SetupBegin => "setup(", Op.SetupEnd => ")",
+ _ => i.Op.ToString().ToLowerInvariant(),
+ }));
+
+ [Fact]
+ public void Flattens_IncludesDefsBlocks()
+ {
+ var p = Load(new()
+ {
+ ["main.steps"] = "format 1\nmode player\nsetup:\n cheat gold\ndef rest(h):\n key R\nstep a:\n include ch/one.steps\n call rest(8)\ncheckpoint done",
+ ["ch/one.steps"] = "format 1\nclick 1,1\ninclude ../common.steps",
+ ["common.steps"] = "format 1\ntry:\n key Q",
+ });
+ Assert.Equal("", Errors(p));
+ Assert.Equal("setup( cheat ) [a { click { try( key ) } } {L key } ] checkpoint", Ops(p));
+ var tryBegin = p.Code.Single(i => i.Op == Op.TryBegin);
+ Assert.Equal(Op.TryEnd, p.Code[tryBegin.Jump].Op);
+ var q = p.Code.Single(i => i.Op == Op.Verb && i.A == "key" && i.B == "Q");
+ Assert.Equal("common.steps:3", q.At.ToString());
+ Assert.Equal(["main.steps:8", "ch/one.steps:3"], q.Chain.Select(c => c.ToString()));
+ var bind = p.Code.Single(i => i.Op == Op.ScopeBegin && i.LocalVars);
+ Assert.Equal([new KeyValuePair<string, string>("h", "8")], bind.Bindings);
+ }
+
+ [Fact]
+ public void VarsFiles_PathExpandedWithCommandLine_FileDoesNotOverrideIt()
+ {
+ var p = Load(new()
+ {
+ ["main.steps"] = "format 1\nmode player\nvars ui/${res}.vars\nclick ${ui.ok}",
+ ["ui/hd.vars"] = "ui.ok = 10,20\nres = sd",
+ }, new() { ["res"] = "hd" });
+ Assert.Equal("", Errors(p));
+ Assert.Equal(("10,20", "hd"), (p.Vars["ui.ok"], p.Vars["res"]));
+ Assert.Contains("res", p.Locked);
+ }
+
+ [Theory]
+ [InlineData("format 1\nclick 1,1", "main.steps:1: top-level file needs 'mode'")]
+ [InlineData("format 1\nmode player\ninclude x.steps", "main.steps:3: include not found: x.steps")]
+ [InlineData("format 1\nmode player\ncall nope()", "main.steps:3: unknown def nope")]
+ [InlineData("format 1\nmode player\ndef d(a):\n key R\ncall d()", "main.steps:5: def d takes 1 arguments, got 0")]
+ [InlineData("format 1\nmode player\ndef d():\n call d()\ncall d()", "main.steps:4: recursive call d")]
+ [InlineData("format 1\nmode player\ndef d():\n key R\ndef d():\n key Q", "main.steps:5: def d already defined")]
+ [InlineData("format 1\nmode player\ncheckpoint a\ncheckpoint a", "main.steps:4: duplicate checkpoint a")]
+ [InlineData("format 1\nmode player\nclick ${nowhere}", "main.steps:3: unresolved variable ${nowhere}")]
+ [InlineData("format 1\nmode player\nkey R\nsetup:\n cheat x", "main.steps:4: setup must be the first block")]
+ [InlineData("format 1\nmode player\ntry:\n checkpoint c", "main.steps:4: checkpoint inside try")]
+ public void Reports(string main, string error) => Assert.Contains(error, Errors(Load(new() { ["main.steps"] = main })));
+
+ [Fact]
+ public void IncludeCycle_NamesThePath() =>
+ Assert.Contains("b.steps:2: include cycle: main.steps → a.steps → b.steps → a.steps", Errors(Load(new()
+ {
+ ["main.steps"] = "format 1\nmode debug\ninclude a.steps",
+ ["a.steps"] = "format 1\ninclude b.steps",
+ ["b.steps"] = "format 1\ninclude a.steps",
+ })));
+
+ [Fact]
+ public void IncludedFile_ModeOrSetup_Rejected()
+ {
+ var p = Load(new()
+ {
+ ["main.steps"] = "format 1\nmode debug\ninclude a.steps",
+ ["a.steps"] = "format 1\nmode player\nsetup:\n cheat x",
+ });
+ Assert.Contains("a.steps:2: mode only in the top-level file", Errors(p));
+ Assert.Contains("a.steps:3: setup only in the top-level file", Errors(p));
+ }
+
+ [Fact]
+ public void LockedVariable_SetIsAnError() =>
+ Assert.Contains("main.steps:3: variable res is set on the command line",
+ Errors(Load(new() { ["main.steps"] = "format 1\nmode debug\nset res = x" }, new() { ["res"] = "hd" })));
+
+ [Fact]
+ public void DefParamsAndEarlierSets_AreKnownVariables() =>
+ Assert.Equal("", Errors(Load(new()
+ {
+ ["main.steps"] = "format 1\nmode debug\nset n = 3\ndef d(a):\n key ${a}\n wait-frames ${n}\ncall d(R)",
+ })));
+
+ [Fact]
+ public void IncludedFileKeepsItsOwnFormat() =>
+ Assert.Equal(1, Load(new() { ["main.steps"] = "format 1\nmode debug\ninclude a.steps", ["a.steps"] = "format 1\nkey R" })
+ .Code.Single(i => i.Op == Op.Verb).Format);
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptParserTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptParserTests.cs
new file mode 100644
index 0000000..352ff03
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptParserTests.cs
@@ -0,0 +1,111 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class ScriptParserTests
+{
+ private static ScriptFile Parse(string text) => ScriptParser.Parse("main.steps", text);
+
+ private static string Errors(ScriptFile f) => string.Join(" | ", f.Errors.Select(e => e.ToString()));
+
+ [Fact]
+ public void Header_AndStatements()
+ {
+ var f = Parse("""
+ # comment
+ format 1
+ mode player
+ seed 7
+ vars ui/${res}.vars
+ click 10,20
+ wait dialog-open 2s
+ expect quest(1) >= accepted
+ set x = "a b"
+ include ch/one.steps
+ call rest(8, inn)
+ checkpoint done
+ """);
+ Assert.Equal("", Errors(f));
+ Assert.Equal((1, RunMode.Player, 7UL), (f.Format, f.Mode, f.Seed!.Value));
+ Assert.Equal(["ui/${res}.vars"], f.VarFiles);
+ var v = Assert.IsType<VerbNode>(f.Body[0]);
+ Assert.Equal(("click", "10,20", 6), (v.Verb, v.Args, v.At.Line));
+ var w = Assert.IsType<WaitNode>(f.Body[1]);
+ Assert.Equal(("dialog-open", 120), (w.Condition, w.Timeout));
+ Assert.Equal("quest(1) >= accepted", Assert.IsType<ExpectNode>(f.Body[2]).Condition);
+ var s = Assert.IsType<SetNode>(f.Body[3]);
+ Assert.Equal(("x", "a b"), (s.Name, s.Value));
+ Assert.Equal("ch/one.steps", Assert.IsType<IncludeNode>(f.Body[4]).Path);
+ var c = Assert.IsType<CallNode>(f.Body[5]);
+ Assert.Equal("rest", c.Name);
+ Assert.Equal(["8", "inn"], c.Args);
+ Assert.Equal("done", Assert.IsType<CheckpointNode>(f.Body[6]).Name);
+ }
+
+ [Fact]
+ public void Blocks_NestByIndent()
+ {
+ var f = Parse("""
+ format 1
+ mode debug
+ setup:
+ cheat gold 5
+ step talk-virgil:
+ click 1,2
+ try:
+ key R
+ def rest(hours):
+ key R
+ wait-frames ${hours}
+ key Q
+ """);
+ Assert.Equal("", Errors(f));
+ var setup = Assert.IsType<BlockNode>(f.Body[0]);
+ Assert.Equal((BlockKind.Setup, 1), (setup.Kind, setup.Body.Count));
+ var step = Assert.IsType<BlockNode>(f.Body[1]);
+ Assert.Equal((BlockKind.Step, "talk-virgil", 2), (step.Kind, step.Label, step.Body.Count));
+ Assert.Equal(BlockKind.Try, Assert.IsType<BlockNode>(step.Body[1]).Kind);
+ var def = Assert.IsType<DefNode>(f.Body[2]);
+ Assert.Equal(("rest", 2), (def.Name, def.Body.Count));
+ Assert.Equal(["hours"], def.Params);
+ Assert.Equal("Q", Assert.IsType<VerbNode>(f.Body[3]).Args);
+ }
+
+ [Theory]
+ [InlineData("mode player\nclick 1,1", "main.steps:1: first line must be 'format N'")]
+ [InlineData("format 9\nmode player", "main.steps:1: unknown format 9 (latest 1)")]
+ [InlineData("format 1\nmode sneaky", "main.steps:2: mode must be player or debug")]
+ [InlineData("format 1\nmode debug\nclick 1,1\nseed 3", "main.steps:4: header line after steps")]
+ [InlineData("format 1\nmode debug\nwait dialog-open 0.01s", "main.steps:3: bad timeout '0.01s'")]
+ [InlineData("format 1\nmode debug\nwait 2s", "main.steps:3: wait needs a condition and a timeout")]
+ [InlineData("format 1\nmode debug\nstep a:\n click 1,1", "main.steps:4: indent not a multiple of 2")]
+ [InlineData("format 1\nmode debug\nstep a:\n\tclick 1,1", "main.steps:4: tab in indentation")]
+ [InlineData("format 1\nmode debug\nstep a:\nclick 1,1", "main.steps:3: empty block")]
+ [InlineData("format 1\nmode debug\nloop:\n click 1,1", "main.steps:3: unknown block 'loop:'")]
+ [InlineData("format 1\nmode debug\nclick 1,1\n key R", "main.steps:4: unexpected indent")]
+ [InlineData("format 1\nmode debug\nlog \"abc", "main.steps:3: unterminated quote")]
+ [InlineData("format 1\nmode debug\nset 9x = 1", "main.steps:3: bad set (set name = value)")]
+ public void Errors_NameTheLine(string text, string error) => Assert.Contains(error, Errors(Parse(text)));
+
+ // Review focus 5: CRLF, BOM and trailing spaces parse as LF.
+ [Fact]
+ public void CrlfBomTrailingSpaces_SameAsLf()
+ {
+ var lf = Parse("format 1\nmode debug\nstep a:\n click 1,2\n");
+ var crlf = Parse("\uFEFFformat 1 \r\nmode debug\r\nstep a: \r\n click 1,2 \r\n");
+ Assert.Equal("", Errors(crlf));
+ var a = Assert.IsType<BlockNode>(lf.Body[0]);
+ var b = Assert.IsType<BlockNode>(crlf.Body[0]);
+ Assert.Equal(a.Text, b.Text);
+ Assert.Equal(((VerbNode)a.Body[0]).Text, ((VerbNode)b.Body[0]).Text);
+ Assert.Equal(((VerbNode)a.Body[0]).At, ((VerbNode)b.Body[0]).At);
+ }
+
+ [Fact]
+ public void ReportsEveryError_NotOnlyTheFirst()
+ {
+ var f = Parse("format 1\nmode debug\nwait x 2m\nset = 1\nloop:\n key R");
+ Assert.Equal(3, f.Errors.Count);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunFlagsTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunFlagsTests.cs
new file mode 100644
index 0000000..6f3be4e
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunFlagsTests.cs
@@ -0,0 +1,54 @@
+using Godosa.Core.Runs;
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class ScriptRunFlagsTests
+{
+ private static ScriptRunFlags Parse(params string[] args)
+ {
+ var f = new ScriptRunFlags();
+ for (var i = 0; i < args.Length; i++)
+ Assert.True(ScriptRunFlags.TryTake(args, ref i, f), args[i]);
+ return f;
+ }
+
+ [Fact]
+ public void ParsesEveryOption()
+ {
+ var f = Parse("--script", "main.steps", "--var", "res=1920x1080", "--var", "who=gil", "--output", "trace",
+ "--no-cache", "--no-snapshots", "--from", "one", "--until", "two", "--step-cap", "500", "--report-dir", "out/runs/a");
+ Assert.True(f.NoSnapshots);
+ Assert.False(f.ToOptions(new RunFlags(), null).Snapshots);
+ Assert.Equal("main.steps", f.Script);
+ Assert.Equal(new Dictionary<string, string> { ["res"] = "1920x1080", ["who"] = "gil" }, f.Vars);
+ Assert.Equal((OutputLevel.Trace, true, "one", "two", 500, "out/runs/a"), (f.Output!.Value, f.NoCache, f.From, f.Until, f.StepCap!.Value, f.ReportDir));
+ }
+
+ [Fact]
+ public void NotOurs_ReturnsFalse_IndexUnchanged()
+ {
+ var i = 0;
+ Assert.False(ScriptRunFlags.TryTake(["--mute"], ref i, new ScriptRunFlags()));
+ Assert.Equal(0, i);
+ }
+
+ [Theory]
+ [InlineData("--var", "novalue")]
+ [InlineData("--output", "loud")]
+ [InlineData("--step-cap", "x")]
+ public void BadValues_Throw(string option, string value)
+ {
+ var i = 0;
+ Assert.Throws<ArgumentException>(() => ScriptRunFlags.TryTake([option, value], ref i, new ScriptRunFlags()));
+ }
+
+ [Fact]
+ public void ToOptions_DeterministicSeedOutput()
+ {
+ var o = Parse("--output", "steps", "--no-cache").ToOptions(new RunFlags(Deterministic: true, Seed: 7), "out/runs/cache");
+ Assert.Equal((true, (ulong?)7UL, OutputLevel.Steps, OutputLevel.Steps, false, "out/runs/cache"), (o.Deterministic, o.Seed, o.Output, o.Floor, o.UseCache, o.CacheDir));
+ Assert.False(Parse("--nondeterministic").ToOptions(new RunFlags(), null).Deterministic);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerStructureTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerStructureTests.cs
new file mode 100644
index 0000000..9129456
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerStructureTests.cs
@@ -0,0 +1,85 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+using static Godosa.Core.Tests.Runs.Scripts.ScriptRunnerTests;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class ScriptRunnerStructureTests
+{
+ [Fact]
+ public void Output_DefaultLabels_StepsLevelShowsEveryStep()
+ {
+ var (_, labels) = Run("format 1\nmode player\nstep a:\n key R\n log \"hi\"\nkey Q", new FakeGame());
+ Assert.Equal(["hi", "[main.steps:3 a] done f=2 (+2)", "digest 2 0.033 0000000000000002"], labels);
+ var (_, steps) = Run("format 1\nmode player\noutput steps\nstep a:\n key R\nkey Q", new FakeGame());
+ Assert.Equal(["[main.steps:5 a] key R done f=2 (+2)", "[main.steps:4 a] done f=2 (+2)", "digest 2 0.033 0000000000000002", "[main.steps:6] key Q done f=4 (+2)"], steps);
+ }
+
+ [Fact]
+ public void Output_ScopedToIncludedFile()
+ {
+ var (_, output) = Run("format 1\nmode player\ninclude loud.steps\nkey Q", new FakeGame(),
+ files: new() { ["loud.steps"] = "format 1\noutput steps\nkey R" });
+ Assert.Equal(["[loud.steps:3] key R done f=2 (+2)"], output); // key Q at labels level: silent
+ }
+
+ [Fact]
+ public void Output_FloorCannotBeLowered()
+ {
+ var (_, output) = Run("format 1\nmode player\noutput quiet\nkey R", new FakeGame(), new ScriptRunOptions { Floor = OutputLevel.Steps });
+ Assert.Equal(["[main.steps:4] key R done f=2 (+2)"], output);
+ }
+
+ [Fact]
+ public void DefCall_BindsParams_ParamUnknownOutside()
+ {
+ var g = new FakeGame();
+ var a = new FakeAction(VerbKind.Debug, _ => new ActionStart(Status: StepStatus.Done), min: 1, max: 3);
+ g.ActionTable["cheat"] = a;
+ var (ok, _) = Run("format 1\nmode debug\ndef d(x):\n set local = in\n cheat ${x} ${local}\ncall d(gold)", g);
+ Assert.Equal(RunOutcome.Passed, ok.Outcome);
+ Assert.Equal(["gold", "in"], a.Begun[0]);
+ var (bad, output) = Run("format 1\nmode debug\ndef d(x):\n cheat ${x}\ncall d(gold)\ncheat ${x}", g);
+ Assert.Equal(RunOutcome.Invalid, bad.Outcome);
+ Assert.Contains(output, l => l == "main.steps:6: unresolved variable ${x}");
+ }
+
+ [Fact]
+ public void Try_FaultInsideContinuesAfterTheBlock_NonDeterministic()
+ {
+ var g = new FakeGame();
+ g.ProbeFns["never"] = _ => RunValue.Of(false);
+ var (r, output) = Run("format 1\nmode player\ntry:\n wait never 3\n key A\nkey B", g, new ScriptRunOptions { Deterministic = false });
+ Assert.Equal(RunOutcome.Passed, r.Outcome);
+ Assert.Contains(output, l => l == "try failed at main.steps:4: timeout after 3 frames: never (never=0)");
+ Assert.DoesNotContain(g.Sent, s => s.Event.Key == "A");
+ Assert.Contains(g.Sent, s => s.Event.Key == "B");
+ }
+
+ // Review focus 1: a fault while a button is down releases it.
+ [Fact]
+ public void Fault_ReleasesHeldButtonAndKey()
+ {
+ var g = new FakeGame();
+ g.ProbeFns["never"] = _ => RunValue.Of(false);
+ g.ActionTable["walk-to"] = new FakeAction(VerbKind.Player,
+ _ => new ActionStart(new InputPlan([new PlannedInput(0, new InputEvent(InputKind.KeyDown, Key: "Shift")), new PlannedInput(0, new InputEvent(InputKind.Down, 9, 9)),
+ new PlannedInput(5, new InputEvent(InputKind.Up, 9, 9)), new PlannedInput(5, new InputEvent(InputKind.KeyUp, Key: "Shift"))])),
+ polls => StepStatus.Running, 1, 1);
+ g.OnAdvance = f => { if (f == 2) throw new InvalidOperationException("crash"); };
+ var (r, _) = Run("format 1\nmode player\nwalk-to 9,9", g);
+ Assert.Equal(RunOutcome.Failed, r.Outcome);
+ Assert.Contains(g.Sent, s => s.Event == new InputEvent(InputKind.Up, 9, 9));
+ Assert.Contains(g.Sent, s => s.Event == new InputEvent(InputKind.KeyUp, Key: "Shift"));
+ }
+
+ [Fact]
+ public void Setup_RunsFirstAtFrameZero()
+ {
+ var g = new FakeGame();
+ var order = new List<string>();
+ g.ActionTable["cheat"] = new FakeAction(VerbKind.Debug, a => { order.Add($"cheat@{g.Frame}"); return new ActionStart(Status: StepStatus.Done); }, min: 1, max: 3);
+ Run("format 1\nmode player\nsetup:\n cheat stats max\nkey R", g);
+ Assert.Equal(["cheat@0"], order);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerTests.cs
new file mode 100644
index 0000000..0738f7b
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerTests.cs
@@ -0,0 +1,224 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class ScriptRunnerTests
+{
+ internal static (RunResult Result, List<string> Output) Run(string main, FakeGame game, ScriptRunOptions? options = null,
+ Dictionary<string, string>? files = null, Dictionary<string, string>? vars = null)
+ {
+ var all = new Dictionary<string, string>(files ?? []) { ["main.steps"] = main };
+ var program = ScriptLoader.Load("main.steps", new MemoryScriptSource(all), vars ?? []);
+ var output = new List<string>();
+ var runner = new ScriptRunner(program, game, options ?? new ScriptRunOptions(), output.Add);
+ return (runner.Run(cap: 10_000), output);
+ }
+
+ [Fact]
+ public void Click_DownAtStepFrameUpNextFrame()
+ {
+ var g = new FakeGame();
+ var (r, _) = Run("format 1\nmode player\nclick 100,200\nkey R", g);
+ Assert.Equal((RunOutcome.Passed, 0), (r.Outcome, r.ExitCode));
+ // Hand-derived: frame 0 (game.Frame before first Advance) move+down, frame 1 up; key starts on frame 2: down, frame 3 up.
+ Assert.Equal(
+ [
+ (0L, new InputEvent(InputKind.Move, 100, 200)), (0L, new InputEvent(InputKind.Down, 100, 200)),
+ (1L, new InputEvent(InputKind.Up, 100, 200)),
+ (2L, new InputEvent(InputKind.KeyDown, Key: "R")), (3L, new InputEvent(InputKind.KeyUp, Key: "R")),
+ ], g.Sent);
+ Assert.Equal(4, r.Frames);
+ }
+
+ // A window host ends its batch of game frames when a step completes, so the next step resolves on a fresh draw.
+ [Fact]
+ public void StepsDone_CountsCompletedSteps()
+ {
+ var program = ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = "format 1\nmode player\nclick 100,200\nkey R" }), new Dictionary<string, string>());
+ var runner = new ScriptRunner(program, new FakeGame(), new ScriptRunOptions(), _ => { });
+ Assert.Equal(0, runner.StepsDone);
+ runner.Run(cap: 10_000);
+ Assert.Equal(2, runner.StepsDone);
+ }
+
+ // A window host skips the poll of a frame drawn later (a batch frame that closed a panel: a click resolved then
+ // would aim on the draw from before): the frame counts, the step is polled on the next one.
+ [Fact]
+ public void AfterFrame_NoPoll_CountsTheFrameOnly()
+ {
+ var program = ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = "format 1\nmode player\nwait-frames 1" }), new Dictionary<string, string>());
+ var runner = new ScriptRunner(program, new FakeGame(), new ScriptRunOptions(), _ => { });
+ runner.BeforeFrame();
+ runner.AfterFrame(poll: false);
+ Assert.Equal((1, 0), (runner.FrameCount, runner.StepsDone));
+ runner.BeforeFrame();
+ runner.AfterFrame();
+ Assert.Equal((2, 1), (runner.FrameCount, runner.StepsDone));
+ }
+
+ [Fact]
+ public void Locator_AimsTheClick_OffScreenFaults()
+ {
+ var g = new FakeGame();
+ g.Objects["Virgil"] = (640, 360);
+ var (ok, _) = Run("format 1\nmode player\nclick @obj(Virgil)", g);
+ Assert.Equal(RunOutcome.Passed, ok.Outcome);
+ Assert.Contains((0L, new InputEvent(InputKind.Down, 640, 360)), g.Sent);
+ var (bad, output) = Run("format 1\nmode player\nclick @obj(Bates)", new FakeGame());
+ Assert.Equal((RunOutcome.Failed, 1), (bad.Outcome, bad.ExitCode));
+ Assert.Contains(output, l => l == "FAIL main.steps:3 click @obj(Bates): locator @obj(Bates): no object Bates on screen");
+ }
+
+ [Fact]
+ public void Wait_HoldsUntilConditionOrTimesOut()
+ {
+ var g = new FakeGame();
+ g.ProbeFns["ready"] = _ => RunValue.Of(g.Frame >= 5);
+ var (r, _) = Run("format 1\nmode player\nwait ready 10\nkey A", g);
+ Assert.Equal(RunOutcome.Passed, r.Outcome);
+ Assert.Equal(5L, g.Sent[0].Frame); // condition true after the 5th advance → key starts at frame 5
+
+ var g2 = new FakeGame();
+ g2.ProbeFns["ready"] = _ => RunValue.Of(false);
+ g2.ProbeFns["quest"] = _ => RunValue.Of(1);
+ var (t, output) = Run("format 1\nmode player\nwait ready and quest(3) > 1 4", g2);
+ Assert.Equal(RunOutcome.Failed, t.Outcome);
+ Assert.Equal(4, t.Frames);
+ Assert.Contains(output, l => l == "FAIL main.steps:3 wait ready and quest(3) > 1 4: timeout after 4 frames: ready and quest(3) > 1 (ready=0 quest(3)=1)");
+ }
+
+ [Fact]
+ public void Wait_AlreadyTrue_TakesNoFrame()
+ {
+ var g = new FakeGame();
+ g.ProbeFns["ready"] = _ => RunValue.Of(true);
+ var (r, _) = Run("format 1\nmode player\nwait ready 10\nkey A", g);
+ Assert.Equal(0L, g.Sent[0].Frame);
+ Assert.Equal(2, r.Frames);
+ }
+
+ [Fact]
+ public void Expect_FailsWithValues()
+ {
+ var g = new FakeGame();
+ g.ProbeFns["quest"] = _ => RunValue.Of(1);
+ var (r, output) = Run("format 1\nmode player\nexpect quest(1) >= accepted", g);
+ Assert.Equal(RunOutcome.Failed, r.Outcome);
+ Assert.Contains(output, l => l == "FAIL main.steps:3 expect quest(1) >= accepted: expect failed: quest(1) >= accepted (quest(1)=1)");
+ }
+
+ [Fact]
+ public void Action_PlanThenPollUntilDone()
+ {
+ var g = new FakeGame();
+ var talk = new FakeAction(VerbKind.Player, args => new ActionStart(new InputPlan([new PlannedInput(0, new InputEvent(InputKind.Down, 5, 6)), new PlannedInput(1, new InputEvent(InputKind.Up, 5, 6))])),
+ polls => polls >= 3 ? StepStatus.Done : StepStatus.Running, 1, 1);
+ g.ActionTable["talk"] = talk;
+ var (r, _) = Run("format 1\nmode player\ntalk @obj(x)\nkey Z", g);
+ Assert.Equal(RunOutcome.Passed, r.Outcome);
+ Assert.Equal(["@obj(x)"], talk.Begun.Single());
+ // Down sent f0, Up f1; polls run after advances to 2, 3, 4 (Done on the 3rd) → key starts at frame 4.
+ Assert.Equal(4L, g.Sent.Single(s => s.Event.Kind == InputKind.KeyDown).Frame);
+ }
+
+ [Fact]
+ public void Action_ArgsGetVariablesExpanded_LocatorsLeftToTheGame()
+ {
+ var g = new FakeGame();
+ var a = new FakeAction(VerbKind.Debug, _ => new ActionStart(Status: StepStatus.Done), min: 1, max: 3);
+ g.ActionTable["cheat"] = a;
+ Run("format 1\nmode debug\nset n = 50\ncheat gold ${n}", g);
+ Assert.Equal(["gold", "50"], a.Begun.Single());
+ }
+
+ [Fact]
+ public void Action_FaultStatusFailsTheRun()
+ {
+ var g = new FakeGame();
+ g.ActionTable["walk-to"] = new FakeAction(VerbKind.Player, _ => new ActionStart(), polls => polls == 2 ? StepStatus.Fault("stuck at 3,4") : StepStatus.Running, 1, 1);
+ var (r, output) = Run("format 1\nmode player\nwalk-to 3,4", g);
+ Assert.Equal((RunOutcome.Failed, 2L), (r.Outcome, r.Frames));
+ Assert.Contains(output, l => l == "FAIL main.steps:3 walk-to 3,4: stuck at 3,4");
+ }
+
+ // Review focus 2: a hook that never finishes hits the step cap.
+ [Fact]
+ public void StepCap_StopsAHangingAction()
+ {
+ var g = new FakeGame();
+ g.ActionTable["walk-to"] = new FakeAction(VerbKind.Player, _ => new ActionStart(), _ => StepStatus.Running, 1, 1);
+ var (r, output) = Run("format 1\nmode player\nwalk-to 3,4", g, new ScriptRunOptions { StepCap = 50 });
+ Assert.Equal((RunOutcome.Failed, 50L), (r.Outcome, r.Frames));
+ Assert.Contains(output, l => l == "FAIL main.steps:3 walk-to 3,4: step exceeded 50 frames");
+ }
+
+ [Fact]
+ public void GameHookException_IsAFailureNotACrash()
+ {
+ var g = new FakeGame();
+ g.ActionTable["talk"] = new FakeAction(VerbKind.Player, _ => throw new InvalidOperationException("boom"), min: 1, max: 1);
+ var (r, output) = Run("format 1\nmode player\ntalk x", g);
+ Assert.Equal(RunOutcome.Failed, r.Outcome);
+ Assert.Contains(output, l => l == "FAIL main.steps:3 talk x: InvalidOperationException: boom");
+ }
+
+ [Fact]
+ public void InvalidScript_Exit2_NoFrames()
+ {
+ var g = new FakeGame();
+ var (r, output) = Run("format 1\nmode player\ndance", g);
+ Assert.Equal((RunOutcome.Invalid, 2, 0L), (r.Outcome, r.ExitCode, r.Frames));
+ Assert.Contains(output, l => l == "main.steps:3: unknown verb dance");
+ Assert.Equal(0, g.Frame);
+ }
+
+ [Fact]
+ public void Rng_CallsTheGameHook_NoFrame()
+ {
+ var g = new FakeGame();
+ var (r, _) = Run("format 1\nmode player\nrng seed 9\nrng next hit 1", g);
+ Assert.Equal((RunOutcome.Passed, 0L), (r.Outcome, r.Frames));
+ Assert.Equal(["seed 9", "next hit 1"], g.RngImpl!.Calls);
+ }
+
+ [Fact]
+ public void WaitFrames_CountsFrames()
+ {
+ var g = new FakeGame();
+ var (r, _) = Run("format 1\nmode player\nwait-frames 7\nwait-frames 0\nwait-frames 0.5s", g);
+ Assert.Equal(37, r.Frames);
+ }
+
+ [Theory]
+ [InlineData("dclick 1,2", "Move@0 Down@0 Up@1 Down@2 Up@3")]
+ [InlineData("rclick 1,2", "Move@0 Down(R)@0 Up(R)@1")]
+ [InlineData("drag 0,0 30,60", "Move(0,0)@0 Down@0 Move(10,20)@1 Move(20,40)@2 Move(30,60)@3 Up@4")]
+ [InlineData("hold W 3", "KeyDown(W)@0 KeyUp(W)@3")]
+ [InlineData("chord Ctrl+S", "KeyDown(Ctrl)@0 KeyDown(S)@0 KeyUp(S)@1 KeyUp(Ctrl)@1")]
+ [InlineData("type \"hi there\"", "Text(hi there)@0")]
+ [InlineData("wheel -2 5,5", "Move@0 Wheel(-2)@0")]
+ public void InputPlans_Shapes(string line, string expected)
+ {
+ var g = new FakeGame();
+ Run($"format 1\nmode player\n{line}", g);
+ string Show((long Frame, InputEvent E) s) => s.E.Kind switch
+ {
+ InputKind.Move when line.StartsWith("drag") => $"Move({s.E.X},{s.E.Y})@{s.Frame}",
+ InputKind.Down or InputKind.Up when s.E.Button == MouseButton.Right => $"{s.E.Kind}(R)@{s.Frame}",
+ InputKind.KeyDown or InputKind.KeyUp => $"{s.E.Kind}({s.E.Key})@{s.Frame}",
+ InputKind.Text => $"Text({s.E.Text})@{s.Frame}",
+ InputKind.Wheel => $"Wheel({s.E.Delta})@{s.Frame}",
+ _ => $"{s.E.Kind}@{s.Frame}",
+ };
+ Assert.Equal(expected, string.Join(" ", g.Sent.Select(Show)));
+ }
+
+ [Fact]
+ public void BadPosition_Faults()
+ {
+ var (r, output) = Run("format 1\nmode player\nclick 10;20", new FakeGame());
+ Assert.Equal(RunOutcome.Failed, r.Outcome);
+ Assert.Contains(output, l => l == "FAIL main.steps:3 click 10;20: bad position '10;20'");
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptValidatorTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptValidatorTests.cs
new file mode 100644
index 0000000..98dcc73
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptValidatorTests.cs
@@ -0,0 +1,63 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class ScriptValidatorTests
+{
+ private static FakeGame Game()
+ {
+ var g = new FakeGame();
+ g.ProbeFns["quest"] = _ => RunValue.Of(0);
+ g.ProbeFns["dialog-open"] = _ => RunValue.Of(false);
+ g.ActionTable["talk"] = new FakeAction(VerbKind.Player, _ => new ActionStart(), min: 1, max: 1);
+ g.ActionTable["cheat"] = new FakeAction(VerbKind.Debug, _ => new ActionStart(Status: StepStatus.Done), min: 1, max: 20);
+ return g;
+ }
+
+ private static string Validate(string main, bool deterministic = true, FakeGame? game = null) =>
+ string.Join(" | ", ScriptValidator.Validate(
+ ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = main }), new Dictionary<string, string>()),
+ game ?? Game(), deterministic).Select(e => e.ToString()));
+
+ [Fact]
+ public void ValidPlayerScript_NoErrors() =>
+ Assert.Equal("", Validate("format 1\nmode player\nsetup:\n cheat gold 5\ntalk @obj(Virgil)\nwait dialog-open 2s\nexpect quest(1) >= accepted\nclick 1,2\nkey R\nlog \"hi\""));
+
+ [Theory]
+ [InlineData("format 1\nmode player\ndance", "main.steps:3: unknown verb dance")]
+ [InlineData("format 1\nmode player\nwalk-to 3,4", "main.steps:3: verb walk-to not supported by game fake")]
+ [InlineData("format 1\nmode player\nclick", "main.steps:3: verb click takes 1..1 arguments, got 0")]
+ [InlineData("format 1\nmode player\ncheat gold 5", "main.steps:3: debug verb cheat outside setup in player mode")]
+ [InlineData("format 1\nmode player\nwait nope 10", "main.steps:3: unknown probe or constant nope")]
+ [InlineData("format 1\nmode player\nexpect quest(1) >=", "main.steps:3: condition ends early")]
+ [InlineData("format 1\nmode player\ntalk @npc(Virgil)", "main.steps:3: unknown locator @npc")]
+ [InlineData("format 1\nmode player\ntry:\n key R", "main.steps:3: try needs a non-deterministic run")]
+ [InlineData("format 1\nmode player\noutput loud", "main.steps:3: output level must be quiet|checkpoints|labels|steps|trace")]
+ public void Errors(string main, string error) => Assert.Contains(error, Validate(main));
+
+ [Fact]
+ public void DebugMode_DebugVerbAnywhere() => Assert.Equal("", Validate("format 1\nmode debug\ncheat gold 5\nkey R"));
+
+ [Fact]
+ public void Try_AllowedNonDeterministic() => Assert.Equal("", Validate("format 1\nmode player\ntry:\n key R", deterministic: false));
+
+ [Fact]
+ public void Rng_NeedsTheGameHook()
+ {
+ var g = Game();
+ g.RngImpl = null;
+ Assert.Contains("main.steps:3: verb rng not supported by game fake", Validate("format 1\nmode player\nrng seed 4", game: g));
+ }
+
+ [Fact]
+ public void GameVerb_ShadowingALibVerbWithAnotherKind_Rejected()
+ {
+ var g = Game();
+ g.ActionTable["click"] = new FakeAction(VerbKind.Debug, _ => new ActionStart());
+ Assert.Contains("game verb click shadows a lib verb", Validate("format 1\nmode debug\nkey R", game: g));
+ }
+
+ [Fact]
+ public void LoaderErrorsComeThrough() => Assert.Contains("main.steps:3: unknown def x", Validate("format 1\nmode debug\ncall x()"));
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/StepLexerTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/StepLexerTests.cs
new file mode 100644
index 0000000..0f787dc
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/StepLexerTests.cs
@@ -0,0 +1,58 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class StepLexerTests
+{
+ [Theory]
+ [InlineData("click 10,20 # aim", "click 10,20 ")]
+ [InlineData("log \"a # b\" # c", "log \"a # b\" ")]
+ [InlineData("# only", "")]
+ [InlineData("type \"say \\\"#1\\\"\"", "type \"say \\\"#1\\\"\"")]
+ public void StripComment_OutsideQuotesOnly(string line, string expected) =>
+ Assert.Equal(expected, StepLexer.StripComment(line));
+
+ [Fact]
+ public void Tokens_SplitOnSpaceQuotedStaysWhole()
+ {
+ var t = StepLexer.Tokens("use @inv(Lockpicks) on \"Big Door\"");
+ Assert.Equal(["use", "@inv(Lockpicks)", "on", "Big Door"], t.Select(x => x.Text));
+ Assert.Equal([false, false, false, true], t.Select(x => x.Quoted));
+ }
+
+ [Fact]
+ public void Tokens_EscapesInQuotes()
+ {
+ var t = StepLexer.Tokens("type \"a \\\"b\\\" \\\\ c\"");
+ Assert.Equal("a \"b\" \\ c", t[1].Text);
+ }
+
+ [Fact]
+ public void Tokens_ParenthesesKeepSpacesInOneToken()
+ {
+ // locator args may contain spaces: @npc(Gilbert Bates)
+ var t = StepLexer.Tokens("talk @npc(Gilbert Bates)");
+ Assert.Equal(["talk", "@npc(Gilbert Bates)"], t.Select(x => x.Text));
+ }
+
+ [Fact]
+ public void Tokens_UnterminatedQuote_Throws() =>
+ Assert.Equal("unterminated quote", Assert.Throws<FormatException>(() => StepLexer.Tokens("log \"abc")).Message);
+
+ [Theory]
+ [InlineData("120", 120)]
+ [InlineData("2s", 120)]
+ [InlineData("0.5s", 30)]
+ [InlineData("0", 0)]
+ [InlineData("1.25s", 75)]
+ public void Durations_FramesAndSeconds(string text, int frames) => Assert.Equal(frames, Durations.Frames(text));
+
+ [Theory]
+ [InlineData("0.01s")] // 0.6 frames: not whole
+ [InlineData("-5")]
+ [InlineData("2m")]
+ [InlineData("")]
+ [InlineData("1e3")]
+ public void Durations_Invalid_Null(string text) => Assert.Null(Durations.Frames(text));
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/VarsTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/VarsTests.cs
new file mode 100644
index 0000000..c6d3286
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/VarsTests.cs
@@ -0,0 +1,84 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class VarsTests
+{
+ private static readonly Dictionary<string, string> Values = new()
+ {
+ ["res"] = "1920x1080", ["hours"] = "8", ["ui.rest.8"] = "400,300", ["a"] = "b", ["b"] = "deep",
+ };
+
+ private static string Expand(string text) => VarExpander.Expand(text, n => Values.GetValueOrDefault(n),
+ p => p == "global(gold)" ? RunValue.Of(1250) : throw new ScriptFault($"no probe {p}"));
+
+ [Theory]
+ [InlineData("ui/${res}.vars", "ui/1920x1080.vars")]
+ [InlineData("click ${ui.rest.${hours}}", "click 400,300")]
+ [InlineData("${${a}}", "deep")]
+ [InlineData("gold ${probe:global(gold)}", "gold 1250")]
+ [InlineData("cost $5 and $", "cost $5 and $")] // review focus 4: lone $ is literal
+ [InlineData("no vars", "no vars")]
+ public void Expand_Values(string text, string expected) => Assert.Equal(expected, Expand(text));
+
+ [Theory]
+ [InlineData("${missing}", "unresolved variable ${missing}")]
+ [InlineData("x ${a", "unclosed ${ in 'x ${a'")]
+ [InlineData("${}", "empty ${}")]
+ public void Expand_Errors_AreFaults(string text, string message) =>
+ Assert.Equal(message, Assert.Throws<ScriptFault>(() => Expand(text)).Message);
+
+ [Fact]
+ public void Names_InnermostPlainOnly() =>
+ Assert.Equal(["hours", "res"], VarExpander.Names("${ui.${hours}} ${res} ${probe:quest(1)}").Order());
+
+ [Fact]
+ public void VarsFile_ParsesAndReportsBadLines()
+ {
+ var (values, errors) = VarsFile.Parse("ui/hd.vars", "# hd\nui.answer1 = 412,520\n\nui.rest.8=400,300\nbroken line\n");
+ Assert.Equal("412,520", values["ui.answer1"]);
+ Assert.Equal("400,300", values["ui.rest.8"]);
+ Assert.Equal(["ui/hd.vars:5: bad vars line (name = value)"], errors.Select(e => e.ToString()));
+ }
+
+ [Fact]
+ public void Scopes_SetGoesToLocalOrRoot()
+ {
+ var s = new VarScopes(OutputLevel.Labels);
+ s.Set("g", "1");
+ s.Push(local: false, OutputLevel.Labels, []); // include
+ s.Set("inc", "2"); // global: root
+ s.Push(local: true, OutputLevel.Labels, [new("p", "x")]); // def call
+ s.Set("loc", "3");
+ Assert.Equal(("1", "2", "3", "x"), (s.Get("g"), s.Get("inc"), s.Get("loc"), s.Get("p")));
+ s.Pop();
+ Assert.Null(s.Get("loc"));
+ Assert.Null(s.Get("p"));
+ s.Pop();
+ Assert.Equal("2", s.Get("inc"));
+ }
+
+ [Fact]
+ public void Scopes_OutputLevelRestoredOnPop()
+ {
+ var s = new VarScopes(OutputLevel.Labels);
+ s.Push(local: false, s.Level, []);
+ s.Level = OutputLevel.Trace;
+ s.Pop();
+ Assert.Equal(OutputLevel.Labels, s.Level);
+ }
+
+ [Fact]
+ public void Scopes_SnapshotRestore_RoundTrips()
+ {
+ var s = new VarScopes(OutputLevel.Labels);
+ s.Set("g", "1");
+ s.Push(local: true, OutputLevel.Steps, [new("p", "x")]);
+ var copy = new VarScopes(OutputLevel.Quiet);
+ copy.Restore(s.Snapshot());
+ Assert.Equal(("1", "x", OutputLevel.Steps), (copy.Get("g"), copy.Get("p"), copy.Level));
+ s.Set("p", "changed");
+ Assert.Equal("x", copy.Get("p")); // deep copy
+ }
+}