diff --git a/MultiversalDiplomacy/CommandLine/ReplOptions.cs b/MultiversalDiplomacy/CommandLine/ReplOptions.cs index 4d29e8a..c5e65a4 100644 --- a/MultiversalDiplomacy/CommandLine/ReplOptions.cs +++ b/MultiversalDiplomacy/CommandLine/ReplOptions.cs @@ -69,13 +69,21 @@ public class ReplOptions outputWriter?.Flush(); // Delegate all other command parsing to the handler. - handler = handler.HandleInput(input); + var result = handler.HandleInput(input); - // Quit if the handler ends processing, otherwise prompt for the next command. - if (handler is null) + // Report errors if they occured. + if (!result.Success) + { + Console.WriteLine($"Error: {result.Message}"); + } + + // Quit if the handler didn't continue processing. + if (result.NextHandler is null) { break; } + + // Otherwise prompt for the next command. Console.Write(handler.Prompt); } diff --git a/MultiversalDiplomacy/Script/AdjudicationQueryScriptHandler.cs b/MultiversalDiplomacy/Script/AdjudicationQueryScriptHandler.cs index 588494a..2d14bc2 100644 --- a/MultiversalDiplomacy/Script/AdjudicationQueryScriptHandler.cs +++ b/MultiversalDiplomacy/Script/AdjudicationQueryScriptHandler.cs @@ -10,8 +10,7 @@ public class AdjudicationQueryScriptHandler( Action WriteLine, List validations, World world, - IPhaseAdjudicator adjudicator, - bool strict = false) + IPhaseAdjudicator adjudicator) : IScriptHandler { public string Prompt => "valid> "; @@ -20,17 +19,12 @@ public class AdjudicationQueryScriptHandler( public World World { get; private set; } = world; - /// - /// Whether unsuccessful commands should terminate the script. - /// - public bool Strict { get; } = strict; - - public IScriptHandler? HandleInput(string input) + public ScriptResult HandleInput(string input) { var args = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); if (args.Length == 0 || input.StartsWith('#')) { - return this; + return ScriptResult.Succeed(this); } var command = args[0]; @@ -38,55 +32,49 @@ public class AdjudicationQueryScriptHandler( { case "---": WriteLine("Ready for orders"); - return new GameScriptHandler(WriteLine, World, adjudicator, Strict); + return ScriptResult.Succeed(new GameScriptHandler(WriteLine, World, adjudicator)); case "assert" when args.Length == 1: WriteLine("Usage:"); break; case "assert": - if (!EvaluateAssertion(args[1])) return Strict ? null : this; - break; + return EvaluateAssertion(args[1]); case "status": throw new NotImplementedException(); default: - WriteLine($"Unrecognized command: \"{command}\""); - if (Strict) return null; - break; + return ScriptResult.Fail($"Unrecognized command: \"{command}\"", this); } - return this; + return ScriptResult.Succeed(this); } - private bool EvaluateAssertion(string assertion) + private ScriptResult EvaluateAssertion(string assertion) { var args = assertion.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); switch (args[0]) { case "true": - return true; + return ScriptResult.Succeed(this); case "false": - return false; + return ScriptResult.Fail("assert false", this); case "order-valid": case "order-invalid": OrderParser re = new(World); Regex prov = new($"^{re.FullLocation}$", RegexOptions.IgnoreCase); Match match = prov.Match(args[1]); - if (!match.Success) { - WriteLine($"Could not parse province from \"{args[1]}\""); - return !Strict; - } + if (!match.Success) return ScriptResult.Fail($"Could not parse province from \"{args[1]}\"", this); string timeline = match.Groups[1].Length > 0 ? match.Groups[1].Value : Season.First.Timeline; var seasonsInTimeline = World.Timelines.Seasons.Where(season => season.Timeline == timeline); - if (!seasonsInTimeline.Any()) return false; + if (!seasonsInTimeline.Any()) return ScriptResult.Fail($"No seasons in timeline {timeline}", this); int turn = match.Groups[4].Length > 0 ? int.Parse(match.Groups[4].Value) @@ -102,11 +90,15 @@ public class AdjudicationQueryScriptHandler( => val.Order is UnitOrder order && order.Unit.Season == season && World.Map.GetLocation(order.Unit.Location).ProvinceName == province.Name); - if (!matching.Any()) return false; + if (!matching.Any()) return ScriptResult.Fail("No matching validations"); - return args[0] == "order-valid" - ? matching.First().Valid - : !matching.First().Valid; + if (args[0] == "order-valid" && !matching.First().Valid) { + return ScriptResult.Fail($"Order \"{matching.First().Order} is invalid"); + } + if (args[0] == "order-invalid" && matching.First().Valid) { + return ScriptResult.Fail($"Order \"{matching.First().Order} is valid"); + } + return ScriptResult.Succeed(this); case "has-past": // Assert a timeline's past @@ -130,8 +122,7 @@ public class AdjudicationQueryScriptHandler( // Assert a unit's support was cut default: - WriteLine($"Unknown assertion \"{args[0]}\""); - return !Strict; + return ScriptResult.Fail($"Unknown assertion \"{args[0]}\"", this); } } } diff --git a/MultiversalDiplomacy/Script/GameScriptHandler.cs b/MultiversalDiplomacy/Script/GameScriptHandler.cs index b4e3a98..50f794a 100644 --- a/MultiversalDiplomacy/Script/GameScriptHandler.cs +++ b/MultiversalDiplomacy/Script/GameScriptHandler.cs @@ -9,30 +9,24 @@ namespace MultiversalDiplomacy.Script; public class GameScriptHandler( Action WriteLine, World world, - IPhaseAdjudicator adjudicator, - bool strict = false) + IPhaseAdjudicator adjudicator) : IScriptHandler { public string Prompt => "orders> "; public World World { get; private set; } = world; - /// - /// Whether unsuccessful commands should terminate the script. - /// - public bool Strict { get; } = strict; - private string? CurrentPower { get; set; } = null; public List Orders { get; } = []; - public IScriptHandler? HandleInput(string input) + public ScriptResult HandleInput(string input) { if (input == "") { CurrentPower = null; - return this; + return ScriptResult.Succeed(this); } - if (input.StartsWith('#')) return this; + if (input.StartsWith('#')) return ScriptResult.Succeed(this); // "---" submits the orders and allows queries about the outcome if (input == "---") { @@ -44,7 +38,8 @@ public class GameScriptHandler( .ToList(); var adjudication = adjudicator.AdjudicateOrders(World, validOrders); var newWorld = adjudicator.UpdateWorld(World, adjudication); - return new AdjudicationQueryScriptHandler(WriteLine, validation, newWorld, adjudicator, Strict); + return ScriptResult.Succeed(new AdjudicationQueryScriptHandler( + WriteLine, validation, newWorld, adjudicator)); } // "===" submits the orders and moves immediately to taking the next set of orders @@ -59,13 +54,13 @@ public class GameScriptHandler( var adjudication = adjudicator.AdjudicateOrders(World, validOrders); World = adjudicator.UpdateWorld(World, adjudication); WriteLine("Ready for orders"); - return this; + return ScriptResult.Succeed(this); } // A block of orders for a single power beginning with "{name}:" if (World.Powers.FirstOrDefault(p => input.EqualsAnyCase($"{p}:"), null) is string power) { CurrentPower = power; - return this; + return ScriptResult.Succeed(this); } // If it's not a comment, submit, or order block, assume it's an order. @@ -79,10 +74,7 @@ public class GameScriptHandler( // Outside a power block, the power is prefixed to each order. Regex re = new($"^{World.Map.PowerRegex}(?:[:])? (.*)$", RegexOptions.IgnoreCase); var match = re.Match(input); - if (!match.Success) { - WriteLine($"Could not determine ordering power in \"{input}\""); - return Strict ? null : this; - } + if (!match.Success) return ScriptResult.Fail($"Could not determine ordering power in \"{input}\"", this); orderPower = match.Groups[1].Value; orderText = match.Groups[2].Value; } @@ -90,10 +82,9 @@ public class GameScriptHandler( if (OrderParser.TryParseOrder(World, orderPower, orderText, out Order? order)) { WriteLine($"Parsed {orderPower} order: {order}"); Orders.Add(order); - return this; + return ScriptResult.Succeed(this); } - WriteLine($"Failed to parse \"{orderText}\""); - return Strict ? null : this; + return ScriptResult.Fail($"Failed to parse \"{orderText}\"", this); } } diff --git a/MultiversalDiplomacy/Script/IScriptHandler.cs b/MultiversalDiplomacy/Script/IScriptHandler.cs index 20ef562..840803a 100644 --- a/MultiversalDiplomacy/Script/IScriptHandler.cs +++ b/MultiversalDiplomacy/Script/IScriptHandler.cs @@ -14,8 +14,5 @@ public interface IScriptHandler /// /// Process a line of input. /// - /// - /// The handler that should handle the next line of input, or null if script handling should end. - /// - public IScriptHandler? HandleInput(string input); + public ScriptResult HandleInput(string input); } diff --git a/MultiversalDiplomacy/Script/ReplScriptHandler.cs b/MultiversalDiplomacy/Script/ReplScriptHandler.cs index 99a077d..d3cedaf 100644 --- a/MultiversalDiplomacy/Script/ReplScriptHandler.cs +++ b/MultiversalDiplomacy/Script/ReplScriptHandler.cs @@ -10,12 +10,12 @@ public class ReplScriptHandler(Action WriteLine) : IScriptHandler { public string Prompt => "5dp> "; - public IScriptHandler? HandleInput(string input) + public ScriptResult HandleInput(string input) { var args = input.Split(' ', StringSplitOptions.RemoveEmptyEntries); if (args.Length == 0 || input.StartsWith('#')) { - return this; + return ScriptResult.Succeed(this); } var command = args[0]; @@ -50,13 +50,15 @@ public class ReplScriptHandler(Action WriteLine) : IScriptHandler } World world = World.WithMap(Map.FromType(map)); WriteLine($"Created a new {map} game"); - return new SetupScriptHandler(WriteLine, world, MovementPhaseAdjudicator.Instance); + return ScriptResult.Succeed(new SetupScriptHandler( + WriteLine, + world, + MovementPhaseAdjudicator.Instance)); default: - WriteLine($"Unrecognized command: \"{command}\""); - break; + return ScriptResult.Fail($"Unrecognized command: \"{command}\"", this); } - return this; + return ScriptResult.Succeed(this); } } diff --git a/MultiversalDiplomacy/Script/ScriptResult.cs b/MultiversalDiplomacy/Script/ScriptResult.cs new file mode 100644 index 0000000..f4572da --- /dev/null +++ b/MultiversalDiplomacy/Script/ScriptResult.cs @@ -0,0 +1,36 @@ +namespace MultiversalDiplomacy.Script; + +/// +/// The result of an processing a line of input. +/// +/// Whether processing was successful. +/// The handler to continue script processing with. +/// If processing failed, the error message. +public class ScriptResult(bool success, IScriptHandler? next, string message) +{ + /// + /// Whether processing was successful. + /// + public bool Success { get; } = success; + + /// + /// The handler to continue script processing with. + /// + public IScriptHandler? NextHandler { get; } = next; + + /// + /// If processing failed, the error message. + /// + public string Message { get; } = message; + + /// + /// Mark the processing as successful and continue processing with the next handler. + /// + public static ScriptResult Succeed(IScriptHandler next) => new(true, next, ""); + + /// + /// Mark the processing as a failure and optionally continue with the next handler. + /// + /// The reason for the processing failure. + public static ScriptResult Fail(string message, IScriptHandler? next = null) => new(false, next, message); +} diff --git a/MultiversalDiplomacy/Script/SetupScriptHandler.cs b/MultiversalDiplomacy/Script/SetupScriptHandler.cs index 87189b8..ab61add 100644 --- a/MultiversalDiplomacy/Script/SetupScriptHandler.cs +++ b/MultiversalDiplomacy/Script/SetupScriptHandler.cs @@ -9,25 +9,19 @@ namespace MultiversalDiplomacy.Script; public class SetupScriptHandler( Action WriteLine, World world, - IPhaseAdjudicator adjudicator, - bool strict = false) + IPhaseAdjudicator adjudicator) : IScriptHandler { public string Prompt => "setup> "; public World World { get; private set; } = world; - /// - /// Whether unsuccessful commands should terminate the script. - /// - public bool Strict { get; } = strict; - - public IScriptHandler? HandleInput(string input) + public ScriptResult HandleInput(string input) { var args = input.Split(' ', StringSplitOptions.RemoveEmptyEntries); if (args.Length == 0 || input.StartsWith('#')) { - return this; + return ScriptResult.Succeed(this); } var command = args[0]; @@ -47,7 +41,7 @@ public class SetupScriptHandler( case "---": WriteLine("Starting game"); WriteLine("Ready for orders"); - return new GameScriptHandler(WriteLine, World, adjudicator, Strict); + return ScriptResult.Succeed(new GameScriptHandler(WriteLine, World, adjudicator)); case "list" when args.Length == 1: WriteLine("usage:"); @@ -83,18 +77,15 @@ public class SetupScriptHandler( if (OrderParser.TryParseUnit(World, unitSpec, out Unit? newUnit)) { World = World.Update(units: World.Units.Append(newUnit)); WriteLine($"Created {newUnit}"); - return this; + return ScriptResult.Succeed(this); } - WriteLine($"Could not match unit spec \"{unitSpec}\""); - if (Strict) return null; - break; + return ScriptResult.Fail($"Could not match unit spec \"{unitSpec}\"", this); default: - WriteLine($"Unrecognized command: \"{command}\""); - if (Strict) return null; + ScriptResult.Fail($"Unrecognized command: \"{command}\"", this); break; } - return this; + return ScriptResult.Succeed(this); } } diff --git a/MultiversalDiplomacyTests/ReplDriver.cs b/MultiversalDiplomacyTests/ReplDriver.cs index b6f1f15..fbb89b2 100644 --- a/MultiversalDiplomacyTests/ReplDriver.cs +++ b/MultiversalDiplomacyTests/ReplDriver.cs @@ -15,12 +15,6 @@ public class ReplDriver(IScriptHandler initialHandler, bool echo = false) /// bool Echo { get; } = echo; - /// - /// Input a multiline string into the repl. Call or at the end so the - /// statement is valid. - /// - public ReplDriver this[string input] => ExecuteAll(input); - public ReplDriver ExecuteAll(string multiline) { var lines = multiline.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); @@ -33,19 +27,22 @@ public class ReplDriver(IScriptHandler initialHandler, bool echo = false) $"Cannot execute \"{inputLine}\", handler quit. Last input was \"{LastInput}\""); if (Echo) Console.WriteLine($"{Handler.Prompt}{inputLine}"); - Handler = Handler.HandleInput(inputLine); + var result = Handler.HandleInput(inputLine); + if (!result.Success) Assert.Fail($"Script failed at \"{inputLine}\": {result.Message}"); + + Handler = result.NextHandler; LastInput = inputLine; return this; } - public void AssertReady() + public void AssertFails(string inputLine) { - if (Handler is null) Assert.Fail($"Handler terminated after \"{LastInput}\""); - } + if (Handler is null) throw new AssertionException( + $"Cannot execute \"{inputLine}\", handler quit. Last input was \"{LastInput}\""); + if (Echo) Console.WriteLine($"{Handler.Prompt}{inputLine}"); - public void AssertClosed() - { - if (Handler is not null) Assert.Fail($"Handler did not terminate after \"{LastInput}\""); + var result = Handler.HandleInput(inputLine); + if (result.Success) Assert.Fail($"Expected \"{inputLine}\" to fail, but it succeeded."); } } diff --git a/MultiversalDiplomacyTests/ReplTest.cs b/MultiversalDiplomacyTests/ReplTest.cs index 3bb60c9..738255f 100644 --- a/MultiversalDiplomacyTests/ReplTest.cs +++ b/MultiversalDiplomacyTests/ReplTest.cs @@ -10,21 +10,20 @@ public class ReplTest { private static ReplDriver StandardRepl() => new( new SetupScriptHandler( - (msg) => {/* discard*/}, + (msg) => {/* discard */}, World.WithStandardMap(), - Adjudicator.MovementPhase, - strict: true)); + Adjudicator.MovementPhase)); [Test] public void SetupHandler() { var repl = StandardRepl(); - repl[""" + repl.ExecuteAll(""" unit Germany A Munich unit Austria Army Tyrolia unit England F Lon - """].AssertReady(); + """); Assert.That(repl.Handler, Is.TypeOf()); SetupScriptHandler handler = (SetupScriptHandler)repl.Handler!; @@ -33,9 +32,7 @@ public class ReplTest Assert.That(handler.World.GetUnitAt("Tyr"), Is.Not.Null); Assert.That(handler.World.GetUnitAt("Lon"), Is.Not.Null); - repl[""" - --- - """].AssertReady(); + repl.Execute("---"); Assert.That(repl.Handler, Is.TypeOf()); } @@ -45,7 +42,7 @@ public class ReplTest { var repl = StandardRepl(); - repl[""" + repl.ExecuteAll(""" unit Germany A Mun unit Austria A Tyr unit England F Lon @@ -54,7 +51,7 @@ public class ReplTest Austria: Army Tyrolia - Vienna England: Lon h - """].AssertReady(); + """); Assert.That(repl.Handler, Is.TypeOf()); GameScriptHandler handler = (GameScriptHandler)repl.Handler!; @@ -66,9 +63,7 @@ public class ReplTest World before = handler.World; - repl[""" - --- - """].AssertReady(); + repl.Execute("---"); Assert.That(repl.Handler, Is.TypeOf()); var newHandler = (AdjudicationQueryScriptHandler)repl.Handler!; @@ -81,50 +76,41 @@ public class ReplTest { var repl = StandardRepl(); - repl[""" + repl.ExecuteAll(""" unit Germany A Munich --- --- assert true - """].AssertReady(); + """); - repl["assert false"].AssertClosed(); + repl.AssertFails("assert false"); } [Test] - public void AssertInvalidOrder() + public void AssertOrderValidity() { var repl = StandardRepl(); - repl[""" + repl.ExecuteAll(""" unit Germany A Mun --- Germany A Mun - Stp --- - """].AssertReady(); + """); - // Assertion should pass for an invalid order - repl["assert order-invalid Mun"].AssertReady(); - // Assertion should fail for an invalid order - repl["assert order-valid Mun"].AssertClosed(); - } + // Order should be invalid + repl.Execute("assert order-invalid Mun"); + repl.AssertFails("assert order-valid Mun"); - [Test] - public void AssertValidOrder() - { - var repl = StandardRepl(); - - repl[""" - unit Germany A Mun + repl.ExecuteAll(""" --- Germany A Mun - Tyr --- - """].AssertReady(); + """); - // Assertion should pass for a valid order - repl["assert order-valid Mun"].AssertReady(); - // Assertion should fail for a valid order - repl["assert order-invalid Mun"].AssertClosed(); + // Order should be valid + repl.Execute("assert order-valid Mun"); + repl.AssertFails("assert order-invalid Mun"); } [Test] @@ -133,16 +119,16 @@ public class ReplTest Assert.Ignore(); var repl = StandardRepl(); - repl[""" + repl.ExecuteAll(""" unit England F London --- --- - """].AssertReady(); + """); // Assertion should pass for a season's past - repl["assert has-past a1>a0"].AssertReady(); + repl.Execute("assert has-past a1>a0"); // Assertion should fail for an incorrect past - repl["assert has-past a0>a1"].AssertClosed(); + repl.AssertFails("assert has-past a0>a1"); } [Test] @@ -151,18 +137,18 @@ public class ReplTest Assert.Ignore(); var repl = StandardRepl(); - repl[""" + repl.ExecuteAll(""" unit Germany A Mun unit Austria A Tyr --- Germany Mun - Tyr --- - """].AssertReady(); + """); // Assertion should pass for a repelled move - repl["assert holds Tyr"].AssertReady(); + repl.Execute("assert holds Tyr"); // Assertion should fail for a repelled move - repl["assert dislodged Tyr"].AssertClosed(); + repl.Execute("assert dislodged Tyr"); } [Test] @@ -171,7 +157,7 @@ public class ReplTest Assert.Ignore(); var repl = StandardRepl(); - repl[""" + repl.ExecuteAll(""" unit Germany A Mun unit Germany A Boh unit Austria A Tyr @@ -179,12 +165,12 @@ public class ReplTest Germany Mun - Tyr Germany Boh s Mun - Tyr --- - """].AssertReady(); + """); // Assertion should pass for a dislodge - repl["assert dislodged Tyr"].AssertReady(); + repl.Execute("assert dislodged Tyr"); // Assertion should fail for a repelled move - repl["assert holds Tyr"].AssertClosed(); + repl.AssertFails("assert holds Tyr"); } [Test] @@ -193,17 +179,17 @@ public class ReplTest Assert.Ignore(); var repl = StandardRepl(); - repl[""" + repl.ExecuteAll(""" unit Germany A Mun --- Germany Mun - Tyr --- - """].AssertReady(); + """); // Assertion should pass for a move - repl["assert moves Mun"].AssertReady(); + repl.Execute("assert moves Mun"); // Assertion should fail for a successful move - repl["assert no-move Mun"].AssertClosed(); + repl.AssertFails("assert no-move Mun"); } [Test] @@ -212,18 +198,18 @@ public class ReplTest Assert.Ignore(); var repl = StandardRepl(); - repl[""" + repl.ExecuteAll(""" unit Germany A Mun unit Austria A Tyr --- Germany Mun - Tyr --- - """].AssertReady(); + """); // Assertion should pass for a repelled move - repl["assert no-move Mun"].AssertReady(); + repl.Execute("assert no-move Mun"); // Assertion should fail for no move - repl["assert moves Mun"].AssertClosed(); + repl.AssertFails("assert moves Mun"); } [Test] @@ -232,7 +218,7 @@ public class ReplTest Assert.Ignore(); var repl = StandardRepl(); - repl[""" + repl.ExecuteAll(""" unit Germany A Mun unit Germany A Boh unit Austria A Tyr @@ -241,11 +227,11 @@ public class ReplTest Mun - Tyr Boh s Mun - Tyr --- - """].AssertReady(); + """); // `supports` and `cut` are opposites - repl["assert supports Boh"].AssertReady(); - repl["assert cut Boh"].AssertClosed(); + repl.Execute("assert supports Boh"); + repl.AssertFails("assert cut Boh"); } [Test] @@ -254,7 +240,7 @@ public class ReplTest Assert.Ignore(); var repl = StandardRepl(); - repl[""" + repl.ExecuteAll(""" unit Germany A Mun unit Germany A Boh unit Austria A Tyr @@ -266,10 +252,10 @@ public class ReplTest Italy Vienna - Boh --- - """].AssertReady(); + """); // `supports` and `cut` are opposites - repl["assert cut Boh"].AssertReady(); - repl["assert supports Boh"].AssertClosed(); + repl.Execute("assert cut Boh"); + repl.AssertFails("assert supports Boh"); } } diff --git a/MultiversalDiplomacyTests/ScriptTests.cs b/MultiversalDiplomacyTests/ScriptTests.cs index 05e81b2..c9afade 100644 --- a/MultiversalDiplomacyTests/ScriptTests.cs +++ b/MultiversalDiplomacyTests/ScriptTests.cs @@ -22,15 +22,18 @@ public class ScriptTests Assert.Ignore("Script tests postponed until parsing tests are done"); string filename = Path.GetFileName(testScriptPath); int line = 0; - IScriptHandler? handler = new SetupScriptHandler( - (msg) => {/* discard*/}, + IScriptHandler handler = new SetupScriptHandler( + (msg) => {/* discard */}, World.WithStandardMap(), - Adjudicator.MovementPhase, - strict: true); + Adjudicator.MovementPhase); foreach (string input in File.ReadAllLines(testScriptPath)) { line++; - handler = handler?.HandleInput(input); - if (handler is null) Assert.Fail($"Script {filename} quit unexpectedly at line {line}: \"{input}\""); + var result = handler.HandleInput(input); + if (!result.Success) throw new AssertionException( + $"Script {filename} error at line {line}: {result.Message}"); + if (result.NextHandler is null) throw new AssertionException( + $"Script {filename} quit unexpectedly at line {line}: \"{input}\""); + handler = result.NextHandler; } } }