Compare commits

..

20 Commits

Author SHA1 Message Date
Tim Van Baak 7c0cdb0a21 Add the rest of DATC 6.A to the script tests 2024-09-06 16:17:55 +00:00
Tim Van Baak 5e2d495fa5 Update script tests 2024-09-06 16:04:25 +00:00
Tim Van Baak 7773c571e3 Add hold-order assertion for unparseable orders 2024-09-06 16:03:43 +00:00
Tim Van Baak 4096e4d517 Implement coastal accessibility checks for support-move orders 2024-09-06 15:19:58 +00:00
Tim Van Baak aaf2e78730 Implement coastal accessibility checks for move orders 2024-09-06 15:09:30 +00:00
Tim Van Baak 864a933ba0 Implement dislodge/hold assertions 2024-09-05 23:16:22 +00:00
Tim Van Baak c6f10868ae Implement support assertions 2024-09-05 23:11:29 +00:00
Tim Van Baak 5b32786904 Implement support-move parsing 2024-09-05 05:27:48 +00:00
Tim Van Baak ae5eb22010 Implement support-hold parsing 2024-09-05 05:22:07 +00:00
Tim Van Baak 26f7cee070 Add unit tests for move location disambiguation
Some of the coastal tests fail because the coast accessibility check isn't implemented yet
2024-09-05 05:11:12 +00:00
Tim Van Baak 80f340c0b2 Implement assert moves/no-move 2024-09-03 04:25:37 +00:00
Tim Van Baak e9c9999268 Implement assert has-past 2024-09-03 03:49:38 +00:00
Tim Van Baak 4fee854c4c Refactor script handlers to return a result type
This moves the point of strictness from the handler to the driver, which makes more sense and keeps it in one place. Drivers choose to be strict when a script result is a failure but still gives a continuation handler. The CLI driver prints an error and continues, while the test driver fails if it wasn't expecting the failure.
2024-09-03 03:20:59 +00:00
Tim Van Baak 569c9021e6 Disable some broken tests for now 2024-09-02 19:52:27 +00:00
Tim Van Baak 3984b814ca Implement assert order-valid 2024-09-01 04:54:28 +00:00
Tim Van Baak f18147f666 Enable suppressing adjudicator output in tests 2024-08-28 21:27:35 +00:00
Tim Van Baak 9f52c78b40 Enable repl output to /dev/null 2024-08-28 21:10:41 +00:00
Tim Van Baak 7b890046b6 Add assertion stubs and unit tests 2024-08-28 19:14:19 +00:00
Tim Van Baak 720ccc4329 Add standard repl helper 2024-08-28 15:09:57 +00:00
Tim Van Baak d2a46aa02d Implement dummy assertions 2024-08-28 15:01:27 +00:00
30 changed files with 1029 additions and 225 deletions

View File

@ -54,7 +54,7 @@ public class ReplOptions
// The last null is returned because an EOF means we should quit the repl.
}
IScriptHandler? handler = new ReplScriptHandler();
IScriptHandler? handler = new ReplScriptHandler(Console.WriteLine);
Console.Write(handler.Prompt);
foreach (string? nextInput in GetInputs())
@ -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);
}

View File

@ -214,7 +214,8 @@ public class OrderParser(World world)
return true;
}
public static bool TryParseOrder(World world, string power, string command, [NotNullWhen(true)] out Order? order) {
public static bool TryParseOrder(World world, string power, string command, [NotNullWhen(true)] out Order? order)
{
order = null;
OrderParser re = new(world);
@ -227,7 +228,7 @@ public class OrderParser(World world)
} else if (re.SupportMove.Match(command) is Match smoveMatch && smoveMatch.Success) {
return TryParseSupportMoveOrder(world, power, smoveMatch, out order);
} else {
throw new NotImplementedException();
return false;
}
}
@ -306,33 +307,44 @@ public class OrderParser(World world)
: subject.Season.Turn;
var destProvince = world.Map.Provinces.Single(province => province.Is(move.destProvince));
string? destLocationKey = null;
// DATC 4.B.6 requires that "irrelevant" locations like army to Spain nc be ignored.
// To satisfy this, any location of the wrong type is categorically ignored, so for an army the
// "north coast" location effectively doesn't exist here.
// DATC 4.B specifies how to interpret orders with missing or incorrect locations. These issues arise because
// of provinces with multiple locations of the same type, i.e. two-coast provinces in Classical. In general,
// DATC's only concern is to disambiguate the order, failing the order only when it is ineluctably ambiguous
// (4.B.1) or explicitly incorrect (4.B.3). Irrelevant or nonexistent locations can be ignored.
// If there is only one possible location for the moving unit, that location is used. The idea of land and
// water locations is an implementation detail of 5dplomacy and not part of the Diplomacy rules, so they will
// usually be omitted, and so moving an army to any land province or a fleet to a non-multi-coast province is
// naturally unambiguous even without the location.
var unitLocations = destProvince.Locations.Where(loc => loc.Type switch {
LocationType.Land => subject.Type == UnitType.Army,
LocationType.Water => subject.Type == UnitType.Fleet,
_ => false,
});
// DATC 4.6.B also requires that unknown coasts be ignored. To satisfy this, an additional filter by name.
// Doing both of these filters means "A - Spain/nc" is as meaningful as "F - Spain/wc".
var matchingLocations = unitLocations.Where(loc => loc.Is(move.destLocation));
// If one location matched, use that location. If the coast is inaccessible to the subject, the order will
// be invalidated by a path check later to satisfy DATC 4.B.3.
string? destLocationKey = matchingLocations.FirstOrDefault(defaultValue: null)?.Key;
if (!unitLocations.Any()) return false; // If *no* locations match, the move is illegal
if (unitLocations.Count() == 1) destLocationKey ??= unitLocations.Single().Key;
// If more than one location is possible for the unit, the order must be disambiguated by the dest location
// or the physical realities of which coast is accessible. DATC 4.B.3 makes an order illegal if the location
// is specified but it isn't an accessible coast, so successfully specifying a location takes precedence over
// there being one accessible coast.
if (destLocationKey is null) {
// If no location matched, location was omitted, nonexistent, or the wrong type.
// If one location is accessible, DATC 4.B.2 requires that it be used.
// If more than one location is accessible, DATC 4.B.1 requires the order fail.
// TODO check which locations are accessible per the above
destLocationKey = unitLocations.First().Key;
// return false;
var matchingLocations = unitLocations.Where(loc => loc.Is(move.destLocation));
if (matchingLocations.Any()) destLocationKey ??= matchingLocations.Single().Key;
}
// If the order location didn't disambiguate the coasts, either because it's missing or it's nonsense, the
// order can be disambiguated by there being one accessible coast from the order source.
if (destLocationKey is null) {
Location source = world.Map.GetLocation(subject.Location);
var accessibleLocations = destProvince.Locations.Where(loc => loc.Adjacents.Contains(source));
if (accessibleLocations.Count() == 1) destLocationKey ??= accessibleLocations.Single().Key;
}
// If the order is still ambiguous, fail per DATC 4.B.1.
if (destLocationKey is null) return false;
order = new MoveOrder(power, subject, new(destTimeline, destTurn), destLocationKey);
return true;
@ -346,7 +358,19 @@ public class OrderParser(World world)
{
order = null;
var support = ParseSupportHold(match);
throw new NotImplementedException();
if (!TryParseOrderSubject(world, support.timeline, support.turn, support.province, out Unit? subject)) {
return false;
}
if (!TryParseOrderSubject(
world, support.targetTimeline, support.targetTurn, support.targetProvince, out Unit? target))
{
return false;
}
order = new SupportHoldOrder(power, subject, target);
return true;
}
public static bool TryParseSupportMoveOrder(
@ -357,9 +381,71 @@ public class OrderParser(World world)
{
order = null;
var support = ParseSupportMove(match);
throw new NotImplementedException();
// It is possible to support a move to an inaccessible coast if another coast is accessible to the subject.
// DATC 4.B.4 prefers that automatic adjudicators strictly require matching coasts in supports.
if (!TryParseOrderSubject(world, support.timeline, support.turn, support.province, out Unit? subject)) {
return false;
}
if (!TryParseOrderSubject(
world, support.targetTimeline, support.targetTurn, support.targetProvince, out Unit? target))
{
return false;
}
string destTimeline = support.destTimeline.Length > 0
? support.destTimeline
// If the destination is unspecified, use the target's
: target.Season.Timeline;
int destTurn = support.destTurn.Length > 0
? int.Parse(support.destTurn)
// If the destination is unspecified, use the unit's
: target.Season.Turn;
var destProvince = world.Map.Provinces.Single(province => province.Is(support.destProvince));
string? destLocationKey = null;
// DATC 4.B specifies how to interpret orders with missing or incorrect locations. These issues arise because
// of provinces with multiple locations of the same type, i.e. two-coast provinces in Classical. In general,
// DATC's only concern is to disambiguate the order, failing the order only when it is ineluctably ambiguous
// (4.B.1) or explicitly incorrect (4.B.3). Irrelevant or nonexistent locations can be ignored.
// If there is only one possible location for the moving unit, that location is used. The idea of land and
// water locations is an implementation detail of 5dplomacy and not part of the Diplomacy rules, so they will
// usually be omitted, and so moving an army to any land province or a fleet to a non-multi-coast province is
// naturally unambiguous even without the location.
var unitLocations = destProvince.Locations.Where(loc => loc.Type switch {
LocationType.Land => target.Type == UnitType.Army,
LocationType.Water => target.Type == UnitType.Fleet,
_ => false,
});
if (!unitLocations.Any()) return false; // If *no* locations match, the move is illegal
if (unitLocations.Count() == 1) destLocationKey ??= unitLocations.Single().Key;
// If more than one location is possible for the unit, the order must be disambiguated by the dest location
// or the physical realities of which coast is accessible. DATC 4.B.3 makes an order illegal if the location
// is specified but it isn't an accessible coast, so successfully specifying a location takes precedence over
// there being one accessible coast.
if (destLocationKey is null) {
var matchingLocations = unitLocations.Where(loc => loc.Is(support.destLocation));
if (matchingLocations.Any()) destLocationKey ??= matchingLocations.Single().Key;
}
// If the order location didn't disambiguate the coasts, either because it's missing or it's nonsense, the
// order can be disambiguated by there being one accessible coast from the order source.
if (destLocationKey is null) {
Location source = world.Map.GetLocation(target.Location);
var accessibleLocations = destProvince.Locations.Where(loc => loc.Adjacents.Contains(source));
if (accessibleLocations.Count() == 1) destLocationKey ??= accessibleLocations.Single().Key;
}
// If the order is still ambiguous, fail per DATC 4.B.1. This also satisfies 4.B.4, which prefers for
// programmatic adjudicators with order validation to require the coasts instead of interpreting the ambiguous
// support by referring to the move order it supports.
if (destLocationKey is null) return false;
var destLocation = world.Map.GetLocation(destLocationKey);
order = new SupportMoveOrder(power, subject, target, new(destTimeline, destTurn), destLocation);
return true;
}
}

View File

@ -1,60 +1,276 @@
using System.Text.RegularExpressions;
using MultiversalDiplomacy.Adjudicate;
using MultiversalDiplomacy.Adjudicate.Decision;
using MultiversalDiplomacy.Model;
using MultiversalDiplomacy.Orders;
namespace MultiversalDiplomacy.Script;
public class AdjudicationQueryScriptHandler(World world, bool strict = false) : IScriptHandler
public class AdjudicationQueryScriptHandler(
Action<string> WriteLine,
List<OrderValidation> validations,
List<AdjudicationDecision> adjudications,
World world,
IPhaseAdjudicator adjudicator)
: IScriptHandler
{
public string Prompt => "valid> ";
public List<OrderValidation> Validations { get; } = validations;
public List<AdjudicationDecision> Adjudications { get; } = adjudications;
public World World { get; private set; } = world;
/// <summary>
/// Whether unsuccessful commands should terminate the script.
/// </summary>
public bool Strict { get; } = strict;
public IScriptHandler? HandleInput(string input)
public ScriptResult HandleInput(string input)
{
var args = input.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var args = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
if (args.Length == 0 || input.StartsWith('#'))
{
return this;
return ScriptResult.Succeed(this);
}
var command = args[0];
switch (command)
{
case "---":
Console.WriteLine("Ready for orders");
return new GameScriptHandler(World, Strict);
WriteLine("Ready for orders");
return ScriptResult.Succeed(new GameScriptHandler(WriteLine, World, adjudicator));
case "assert" when args.Length == 1:
Console.WriteLine("Usage:");
WriteLine("Usage:");
break;
case "assert":
string assertion = input["assert ".Length..];
Regex prov = new($"{World.Map.ProvinceRegex} (.*)");
Match match = prov.Match(assertion);
if (!match.Success) {
Console.WriteLine($"Could not parse province from \"{assertion}\"");
return Strict ? null : this;
}
// TODO look up order once orders are validated and adjudicated
Console.WriteLine("Order lookup not implemented yet");
return null;
return EvaluateAssertion(args[1]);
case "status":
throw new NotImplementedException();
default:
Console.WriteLine($"Unrecognized command: \"{command}\"");
if (Strict) return null;
break;
return ScriptResult.Fail($"Unrecognized command: \"{command}\"", this);
}
return this;
return ScriptResult.Succeed(this);
}
private ScriptResult EvaluateAssertion(string assertion)
{
var args = assertion.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
OrderParser re = new(World);
Regex prov = new($"^{re.FullLocation}$", RegexOptions.IgnoreCase);
Match match;
string timeline;
IEnumerable<Season> seasonsInTimeline;
int turn;
Season season;
Province province;
switch (args[0])
{
case "true":
return ScriptResult.Succeed(this);
case "false":
return ScriptResult.Fail("assert false", this);
case "hold-order":
// The hold-order assertion primarily serves to verify that a unit's order was illegal in cases where
// a written non-hold order was rejected before order validation and replaced with a hold order.
match = prov.Match(args[1]);
timeline = match.Groups[1].Length > 0
? match.Groups[1].Value
: Season.First.Timeline;
seasonsInTimeline = World.Timelines.Seasons.Where(season => season.Timeline == timeline);
if (!seasonsInTimeline.Any()) return ScriptResult.Fail($"No seasons in timeline {timeline}", this);
turn = match.Groups[4].Length > 0
? int.Parse(match.Groups[4].Value)
// If turn is unspecified, use the second-latest turn in the timeline,
// since we want to assert against the subjects of the orders just adjudicated,
// and adjudication created a new set of seasons.
: seasonsInTimeline.Max(season => season.Turn) - 1;
season = new(timeline, turn);
province = World.Map.Provinces.Single(province => province.Is(match.Groups[2].Value));
var matchingHolds = Validations.Where(val
=> val.Valid
&& val.Order is HoldOrder hold
&& hold.Unit.Season == season
&& World.Map.GetLocation(hold.Unit.Location).ProvinceName == province.Name);
if (!matchingHolds.Any()) return ScriptResult.Fail("No matching holds");
return ScriptResult.Succeed(this);
case "order-valid":
case "order-invalid":
match = prov.Match(args[1]);
if (!match.Success) return ScriptResult.Fail($"Could not parse province from \"{args[1]}\"", this);
timeline = match.Groups[1].Length > 0
? match.Groups[1].Value
: Season.First.Timeline;
seasonsInTimeline = World.Timelines.Seasons.Where(season => season.Timeline == timeline);
if (!seasonsInTimeline.Any()) return ScriptResult.Fail($"No seasons in timeline {timeline}", this);
turn = match.Groups[4].Length > 0
? int.Parse(match.Groups[4].Value)
// If turn is unspecified, use the second-latest turn in the timeline,
// since we want to assert against the subjects of the orders just adjudicated,
// and adjudication created a new set of seasons.
: seasonsInTimeline.Max(season => season.Turn) - 1;
season = new(timeline, turn);
province = World.Map.Provinces.Single(province => province.Is(match.Groups[2].Value));
var matching = Validations.Where(val
=> val.Order is UnitOrder order
&& order.Unit.Season == season
&& World.Map.GetLocation(order.Unit.Location).ProvinceName == province.Name);
if (!matching.Any()) return ScriptResult.Fail("No matching validations");
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":
Regex hasPast = new($"^([a-z]+[0-9]+)>([a-z]+[0-9]+)$");
match = hasPast.Match(args[1]);
if (!match.Success) return ScriptResult.Fail("Expected format s1>s2", this);
Season future = new(match.Groups[1].Value);
if (!World.Timelines.Pasts.TryGetValue(future.Key, out Season? actual)) {
return ScriptResult.Fail($"No such season \"{future}\"");
}
Season expected = new(match.Groups[2].Value);
if (actual != expected) return ScriptResult.Fail(
$"Expected past of {future} to be {expected}, but it was {actual}");
return ScriptResult.Succeed(this);
case "not-dislodged":
case "dislodged":
re = new(World);
prov = new($"^{re.FullLocation}$", RegexOptions.IgnoreCase);
match = prov.Match(args[1]);
if (!match.Success) return ScriptResult.Fail($"Could not parse province from \"{args[1]}\"", this);
timeline = match.Groups[1].Length > 0
? match.Groups[1].Value
: Season.First.Timeline;
seasonsInTimeline = World.Timelines.Seasons.Where(season => season.Timeline == timeline);
if (!seasonsInTimeline.Any()) return ScriptResult.Fail($"No seasons in timeline {timeline}", this);
turn = match.Groups[4].Length > 0
? int.Parse(match.Groups[4].Value)
// If turn is unspecified, use the second-latest turn in the timeline,
// since we want to assert against the subjects of the orders just adjudicated,
// and adjudication created a new set of seasons.
: seasonsInTimeline.Max(season => season.Turn) - 1;
season = new(timeline, turn);
province = World.Map.Provinces.Single(province => province.Is(match.Groups[2].Value));
var matchingDislodges = Adjudications.Where(adj
=> adj is IsDislodged dislodge
&& dislodge.Order.Unit.Season == season
&& World.Map.GetLocation(dislodge.Order.Unit.Location).ProvinceName == province.Name);
if (!matchingDislodges.Any()) return ScriptResult.Fail("No matching dislodge decisions");
var isDislodged = matchingDislodges.Cast<IsDislodged>().First();
if (args[0] == "not-dislodged" && isDislodged.Outcome != false) {
return ScriptResult.Fail($"Adjudication {isDislodged} is true");
}
if (args[0] == "dislodged" && isDislodged.Outcome != true) {
return ScriptResult.Fail($"Adjudication {isDislodged} is false");
}
return ScriptResult.Succeed(this);
case "moves":
case "no-move":
re = new(World);
prov = new($"^{re.FullLocation}$", RegexOptions.IgnoreCase);
match = prov.Match(args[1]);
if (!match.Success) return ScriptResult.Fail($"Could not parse province from \"{args[1]}\"", this);
timeline = match.Groups[1].Length > 0
? match.Groups[1].Value
: Season.First.Timeline;
seasonsInTimeline = World.Timelines.Seasons.Where(season => season.Timeline == timeline);
if (!seasonsInTimeline.Any()) return ScriptResult.Fail($"No seasons in timeline {timeline}", this);
turn = match.Groups[4].Length > 0
? int.Parse(match.Groups[4].Value)
// If turn is unspecified, use the second-latest turn in the timeline,
// since we want to assert against the subjects of the orders just adjudicated,
// and adjudication created a new set of seasons.
: seasonsInTimeline.Max(season => season.Turn) - 1;
season = new(timeline, turn);
province = World.Map.Provinces.Single(province => province.Is(match.Groups[2].Value));
var matchingMoves = Adjudications.Where(adj
=> adj is DoesMove moves
&& moves.Order.Unit.Season == season
&& World.Map.GetLocation(moves.Order.Unit.Location).ProvinceName == province.Name);
if (!matchingMoves.Any()) return ScriptResult.Fail("No matching movement decisions");
var doesMove = matchingMoves.Cast<DoesMove>().First();
if (args[0] == "moves" && doesMove.Outcome != true) {
return ScriptResult.Fail($"Adjudication {doesMove} is false");
}
if (args[0] == "no-move" && doesMove.Outcome != false) {
return ScriptResult.Fail($"Adjudication {doesMove} is true");
}
return ScriptResult.Succeed(this);
case "support-given":
case "support-cut":
re = new(World);
prov = new($"^{re.FullLocation}$", RegexOptions.IgnoreCase);
match = prov.Match(args[1]);
if (!match.Success) return ScriptResult.Fail($"Could not parse province from \"{args[1]}\"", this);
timeline = match.Groups[1].Length > 0
? match.Groups[1].Value
: Season.First.Timeline;
seasonsInTimeline = World.Timelines.Seasons.Where(season => season.Timeline == timeline);
if (!seasonsInTimeline.Any()) return ScriptResult.Fail($"No seasons in timeline {timeline}", this);
turn = match.Groups[4].Length > 0
? int.Parse(match.Groups[4].Value)
// If turn is unspecified, use the second-latest turn in the timeline,
// since we want to assert against the subjects of the orders just adjudicated,
// and adjudication created a new set of seasons.
: seasonsInTimeline.Max(season => season.Turn) - 1;
season = new(timeline, turn);
province = World.Map.Provinces.Single(province => province.Is(match.Groups[2].Value));
var matchingSupports = Adjudications.Where(adj
=> adj is GivesSupport sup
&& sup.Order.Unit.Season == season
&& World.Map.GetLocation(sup.Order.Unit.Location).ProvinceName == province.Name);
if (!matchingSupports.Any()) return ScriptResult.Fail("No matching support decisions");
var supports = matchingSupports.Cast<GivesSupport>().First();
if (args[0] == "support-given" && supports.Outcome != true) {
return ScriptResult.Fail($"Adjudication {supports} is false");
}
if (args[0] == "support-cut" && supports.Outcome != false) {
return ScriptResult.Fail($"Adjudication {supports} is true");
}
return ScriptResult.Succeed(this);
default:
return ScriptResult.Fail($"Unknown assertion \"{args[0]}\"", this);
}
}
}

View File

@ -6,33 +6,31 @@ using MultiversalDiplomacy.Orders;
namespace MultiversalDiplomacy.Script;
public class GameScriptHandler(World world, bool strict = false) : IScriptHandler
public class GameScriptHandler(
Action<string> WriteLine,
World world,
IPhaseAdjudicator adjudicator)
: IScriptHandler
{
public string Prompt => "orders> ";
public World World { get; private set; } = world;
/// <summary>
/// Whether unsuccessful commands should terminate the script.
/// </summary>
public bool Strict { get; } = strict;
private string? CurrentPower { get; set; } = null;
public List<Order> 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 == "---") {
Console.WriteLine("Submitting orders for adjudication");
var adjudicator = MovementPhaseAdjudicator.Instance;
WriteLine("Submitting orders for adjudication");
var validation = adjudicator.ValidateOrders(World, Orders);
var validOrders = validation
.Where(v => v.Valid)
@ -40,14 +38,14 @@ public class GameScriptHandler(World world, bool strict = false) : IScriptHandle
.ToList();
var adjudication = adjudicator.AdjudicateOrders(World, validOrders);
var newWorld = adjudicator.UpdateWorld(World, adjudication);
return new AdjudicationQueryScriptHandler(newWorld, Strict);
return ScriptResult.Succeed(new AdjudicationQueryScriptHandler(
WriteLine, validation, adjudication, newWorld, adjudicator));
}
// "===" submits the orders and moves immediately to taking the next set of orders
// i.e. it's "---" twice
if (input == "===") {
Console.WriteLine("Submitting orders for adjudication");
var adjudicator = MovementPhaseAdjudicator.Instance;
WriteLine("Submitting orders for adjudication");
var validation = adjudicator.ValidateOrders(World, Orders);
var validOrders = validation
.Where(v => v.Valid)
@ -55,14 +53,14 @@ public class GameScriptHandler(World world, bool strict = false) : IScriptHandle
.ToList();
var adjudication = adjudicator.AdjudicateOrders(World, validOrders);
World = adjudicator.UpdateWorld(World, adjudication);
Console.WriteLine("Ready for orders");
return this;
WriteLine("Ready for orders");
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.
@ -76,21 +74,17 @@ public class GameScriptHandler(World world, bool strict = false) : IScriptHandle
// 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) {
Console.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;
}
if (OrderParser.TryParseOrder(World, orderPower, orderText, out Order? order)) {
Console.WriteLine($"Parsed {orderPower} order: {order}");
WriteLine($"Parsed {orderPower} order: {order}");
Orders.Add(order);
return this;
return ScriptResult.Succeed(this);
}
Console.WriteLine($"Failed to parse \"{orderText}\"");
return Strict ? null : this;
return ScriptResult.Fail($"Failed to parse \"{orderText}\"", this);
}
}

View File

@ -14,8 +14,5 @@ public interface IScriptHandler
/// <summary>
/// Process a line of input.
/// </summary>
/// <returns>
/// The handler that should handle the next line of input, or null if script handling should end.
/// </returns>
public IScriptHandler? HandleInput(string input);
public ScriptResult HandleInput(string input);
}

View File

@ -1,3 +1,4 @@
using MultiversalDiplomacy.Adjudicate;
using MultiversalDiplomacy.Model;
namespace MultiversalDiplomacy.Script;
@ -5,16 +6,16 @@ namespace MultiversalDiplomacy.Script;
/// <summary>
/// A script handler for the interactive repl.
/// </summary>
public class ReplScriptHandler : IScriptHandler
public class ReplScriptHandler(Action<string> 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];
@ -22,40 +23,42 @@ public class ReplScriptHandler : IScriptHandler
{
case "help":
case "?":
Console.WriteLine("Commands:");
Console.WriteLine(" help, ?: print this message");
Console.WriteLine(" map <variant>: start a new game of the given variant");
Console.WriteLine(" stab: stab");
WriteLine("Commands:");
WriteLine(" help, ?: print this message");
WriteLine(" map <variant>: start a new game of the given variant");
WriteLine(" stab: stab");
break;
case "stab":
Console.WriteLine("stab");
WriteLine("stab");
break;
case "map" when args.Length == 1:
Console.WriteLine("Usage:");
Console.WriteLine(" map <variant>");
Console.WriteLine("Available variants:");
Console.WriteLine($" {string.Join(", ", Enum.GetNames<MapType>().Select(s => s.ToLowerInvariant()))}");
WriteLine("Usage:");
WriteLine(" map <variant>");
WriteLine("Available variants:");
WriteLine($" {string.Join(", ", Enum.GetNames<MapType>().Select(s => s.ToLowerInvariant()))}");
break;
case "map" when args.Length > 1:
string mapType = args[1].Trim();
if (!Enum.TryParse(mapType, ignoreCase: true, out MapType map)) {
Console.WriteLine($"Unknown variant \"{mapType}\"");
Console.WriteLine("Available variants:");
Console.WriteLine($" {string.Join(", ", Enum.GetNames<MapType>().Select(s => s.ToLowerInvariant()))}");
WriteLine($"Unknown variant \"{mapType}\"");
WriteLine("Available variants:");
WriteLine($" {string.Join(", ", Enum.GetNames<MapType>().Select(s => s.ToLowerInvariant()))}");
break;
}
World world = World.WithMap(Map.FromType(map));
Console.WriteLine($"Created a new {map} game");
return new SetupScriptHandler(world);
WriteLine($"Created a new {map} game");
return ScriptResult.Succeed(new SetupScriptHandler(
WriteLine,
world,
MovementPhaseAdjudicator.Instance));
default:
Console.WriteLine($"Unrecognized command: \"{command}\"");
break;
return ScriptResult.Fail($"Unrecognized command: \"{command}\"", this);
}
return this;
return ScriptResult.Succeed(this);
}
}

View File

@ -0,0 +1,36 @@
namespace MultiversalDiplomacy.Script;
/// <summary>
/// The result of an <see cref="IScriptHandler"/> processing a line of input.
/// </summary>
/// <param name="success">Whether processing was successful.</param>
/// <param name="next">The handler to continue script processing with.</param>
/// <param name="message">If processing failed, the error message.</param>
public class ScriptResult(bool success, IScriptHandler? next, string message)
{
/// <summary>
/// Whether processing was successful.
/// </summary>
public bool Success { get; } = success;
/// <summary>
/// The handler to continue script processing with.
/// </summary>
public IScriptHandler? NextHandler { get; } = next;
/// <summary>
/// If processing failed, the error message.
/// </summary>
public string Message { get; } = message;
/// <summary>
/// Mark the processing as successful and continue processing with the next handler.
/// </summary>
public static ScriptResult Succeed(IScriptHandler next) => new(true, next, "");
/// <summary>
/// Mark the processing as a failure and optionally continue with the next handler.
/// </summary>
/// <param name="message">The reason for the processing failure.</param>
public static ScriptResult Fail(string message, IScriptHandler? next = null) => new(false, next, message);
}

View File

@ -1,3 +1,4 @@
using MultiversalDiplomacy.Adjudicate;
using MultiversalDiplomacy.Model;
namespace MultiversalDiplomacy.Script;
@ -5,23 +6,22 @@ namespace MultiversalDiplomacy.Script;
/// <summary>
/// A script handler for modifying a game before it begins.
/// </summary>
public class SetupScriptHandler(World world, bool strict = false) : IScriptHandler
public class SetupScriptHandler(
Action<string> WriteLine,
World world,
IPhaseAdjudicator adjudicator)
: IScriptHandler
{
public string Prompt => "setup> ";
public World World { get; private set; } = world;
/// <summary>
/// Whether unsuccessful commands should terminate the script.
/// </summary>
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];
@ -29,39 +29,39 @@ public class SetupScriptHandler(World world, bool strict = false) : IScriptHandl
{
case "help":
case "?":
Console.WriteLine("commands:");
Console.WriteLine(" begin: complete setup and start the game (alias: ---)");
Console.WriteLine(" list <type>: list things in a game category");
Console.WriteLine(" option <name> <value>: set a game option");
Console.WriteLine(" unit <power> <type> <province> [location]: add a unit to the game");
Console.WriteLine(" <province> may be \"province/location\"");
WriteLine("commands:");
WriteLine(" begin: complete setup and start the game (alias: ---)");
WriteLine(" list <type>: list things in a game category");
WriteLine(" option <name> <value>: set a game option");
WriteLine(" unit <power> <type> <province> [location]: add a unit to the game");
WriteLine(" <province> may be \"province/location\"");
break;
case "begin":
case "---":
Console.WriteLine("Starting game");
Console.WriteLine("Ready for orders");
return new GameScriptHandler(World, Strict);
WriteLine("Starting game");
WriteLine("Ready for orders");
return ScriptResult.Succeed(new GameScriptHandler(WriteLine, World, adjudicator));
case "list" when args.Length == 1:
Console.WriteLine("usage:");
Console.WriteLine(" list powers: the powers in the game");
Console.WriteLine(" list units: units created so far");
WriteLine("usage:");
WriteLine(" list powers: the powers in the game");
WriteLine(" list units: units created so far");
break;
case "list" when args[1] == "powers":
Console.WriteLine("Powers:");
WriteLine("Powers:");
foreach (string powerName in World.Powers)
{
Console.WriteLine($" {powerName}");
WriteLine($" {powerName}");
}
break;
case "list" when args[1] == "units":
Console.WriteLine("Units:");
WriteLine("Units:");
foreach (Unit unit in World.Units)
{
Console.WriteLine($" {unit}");
WriteLine($" {unit}");
}
break;
@ -69,26 +69,23 @@ public class SetupScriptHandler(World world, bool strict = false) : IScriptHandl
throw new NotImplementedException("There are no supported options yet");
case "unit" when args.Length < 2:
Console.WriteLine("usage: unit [power] [type] [province]</location>");
WriteLine("usage: unit [power] [type] [province]</location>");
break;
case "unit":
string unitSpec = input["unit ".Length..];
if (OrderParser.TryParseUnit(World, unitSpec, out Unit? newUnit)) {
World = World.Update(units: World.Units.Append(newUnit));
Console.WriteLine($"Created {newUnit}");
return this;
WriteLine($"Created {newUnit}");
return ScriptResult.Succeed(this);
}
Console.WriteLine($"Could not match unit spec \"{unitSpec}\"");
if (Strict) return null;
break;
return ScriptResult.Fail($"Could not match unit spec \"{unitSpec}\"", this);
default:
Console.WriteLine($"Unrecognized command: \"{command}\"");
if (Strict) return null;
ScriptResult.Fail($"Unrecognized command: \"{command}\"", this);
break;
}
return this;
return ScriptResult.Succeed(this);
}
}

View File

@ -0,0 +1,8 @@
using MultiversalDiplomacy.Adjudicate;
namespace MultiversalDiplomacyTests;
public static class Adjudicator
{
public static MovementPhaseAdjudicator MovementPhase { get; } = new MovementPhaseAdjudicator(NullLogger.Instance);
}

View File

@ -3,6 +3,8 @@ using MultiversalDiplomacy.Model;
using MultiversalDiplomacy.Orders;
using NUnit.Framework;
using static MultiversalDiplomacyTests.Adjudicator;
namespace MultiversalDiplomacyTests;
public class DATC_A
@ -17,7 +19,7 @@ public class DATC_A
.Fleet("North Sea").MovesTo("Picardy").GetReference(out var order);
// Order should fail.
setup.ValidateOrders(MovementPhaseAdjudicator.Instance);
setup.ValidateOrders(MovementPhase);
Assert.That(order, Is.Invalid(ValidationReason.UnreachableDestination));
}
@ -59,7 +61,7 @@ public class DATC_A
.Fleet("Kiel").MovesTo("Kiel").GetReference(out var order);
// Program should not crash.
setup.ValidateOrders(MovementPhaseAdjudicator.Instance);
setup.ValidateOrders(MovementPhase);
Assert.That(order, Is.Invalid(ValidationReason.DestinationMatchesOrigin));
}
@ -77,14 +79,14 @@ public class DATC_A
.Army("Wales").Supports.Fleet("London").MoveTo("Yorkshire");
// The move of the army in Yorkshire is illegal. This makes the support of Liverpool also illegal.
setup.ValidateOrders(MovementPhaseAdjudicator.Instance);
setup.ValidateOrders(MovementPhase);
Assert.That(orderLon, Is.Valid);
Assert.That(orderNth, Is.Invalid(ValidationReason.DestinationMatchesOrigin));
Assert.That(orderYor, Is.Invalid(ValidationReason.DestinationMatchesOrigin));
var orderYorRepl = orderYor.GetReplacementReference<HoldOrder>();
// Without the support, the Germans have a stronger force. The army in London dislodges the army in Yorkshire.
setup.AdjudicateOrders(MovementPhaseAdjudicator.Instance);
setup.AdjudicateOrders(MovementPhase);
Assert.That(orderLon, Is.Victorious);
Assert.That(orderYorRepl, Is.Dislodged);
}
@ -98,7 +100,7 @@ public class DATC_A
.Fleet("London", powerName: "England").MovesTo("North Sea").GetReference(out var order);
// Order should fail.
setup.ValidateOrders(MovementPhaseAdjudicator.Instance);
setup.ValidateOrders(MovementPhase);
Assert.That(order, Is.Invalid(ValidationReason.InvalidUnitForPower));
}
@ -112,7 +114,7 @@ public class DATC_A
.Fleet("North Sea").Convoys.Army("London").To("Belgium").GetReference(out var order);
// Move from London to Belgium should fail.
setup.ValidateOrders(MovementPhaseAdjudicator.Instance);
setup.ValidateOrders(MovementPhase);
Assert.That(order, Is.Invalid(ValidationReason.InvalidOrderTypeForUnit));
}
@ -127,12 +129,12 @@ public class DATC_A
["Austria"]
.Fleet("Trieste").Supports.Fleet("Trieste").Hold().GetReference(out var orderTri);
setup.ValidateOrders(MovementPhaseAdjudicator.Instance);
setup.ValidateOrders(MovementPhase);
Assert.That(orderTri, Is.Invalid(ValidationReason.NoSelfSupport));
var orderTriRepl = orderTri.GetReplacementReference<HoldOrder>();
// The army in Trieste should be dislodged.
setup.AdjudicateOrders(MovementPhaseAdjudicator.Instance);
setup.AdjudicateOrders(MovementPhase);
Assert.That(orderTriRepl, Is.Dislodged);
}
@ -145,7 +147,7 @@ public class DATC_A
.Fleet("Rome").MovesTo("Venice").GetReference(out var order);
// Move fails. An army can go from Rome to Venice, but a fleet can not.
setup.ValidateOrders(MovementPhaseAdjudicator.Instance);
setup.ValidateOrders(MovementPhase);
Assert.That(order, Is.Invalid(ValidationReason.UnreachableDestination));
}
@ -160,13 +162,13 @@ public class DATC_A
.Army("Apulia").MovesTo("Venice")
.Fleet("Rome").Supports.Army("Apulia").MoveTo("Venice").GetReference(out var orderRom);
setup.ValidateOrders(MovementPhaseAdjudicator.Instance);
setup.ValidateOrders(MovementPhase);
// The support of Rome is illegal, because Venice can not be reached from Rome by a fleet.
Assert.That(orderRom, Is.Invalid(ValidationReason.UnreachableSupport));
// Venice is not dislodged.
setup.AdjudicateOrders(MovementPhaseAdjudicator.Instance);
setup.AdjudicateOrders(MovementPhase);
Assert.That(orderVen, Is.NotDislodged);
}
@ -180,12 +182,12 @@ public class DATC_A
["Italy"]
.Army("Venice").MovesTo("Tyrolia").GetReference(out var orderVen);
setup.ValidateOrders(MovementPhaseAdjudicator.Instance);
setup.ValidateOrders(MovementPhase);
Assert.That(orderVie, Is.Valid);
Assert.That(orderVen, Is.Valid);
// The two units bounce.
var adjudications = setup.AdjudicateOrders(MovementPhaseAdjudicator.Instance);
var adjudications = setup.AdjudicateOrders(MovementPhase);
Assert.That(orderVie, Is.Repelled);
Assert.That(orderVie, Is.NotDislodged);
Assert.That(orderVen, Is.Repelled);
@ -204,12 +206,12 @@ public class DATC_A
["Italy"]
.Army("Venice").MovesTo("Tyrolia").GetReference(out var orderVen);
var validations = setup.ValidateOrders(MovementPhaseAdjudicator.Instance);
var validations = setup.ValidateOrders(MovementPhase);
Assert.That(orderVie, Is.Valid);
Assert.That(orderMun, Is.Valid);
Assert.That(orderVen, Is.Valid);
var adjudications = setup.AdjudicateOrders(MovementPhaseAdjudicator.Instance);
var adjudications = setup.AdjudicateOrders(MovementPhase);
// The three units bounce.
Assert.That(orderVie, Is.Repelled);
Assert.That(orderVie, Is.NotDislodged);

View File

@ -1,7 +1,8 @@
using MultiversalDiplomacy.Adjudicate;
using MultiversalDiplomacy.Adjudicate.Decision;
using MultiversalDiplomacy.Model;
using static MultiversalDiplomacyTests.Adjudicator;
using NUnit.Framework;
namespace MultiversalDiplomacyTests;
@ -11,7 +12,7 @@ public class TimeTravelTest
[Test]
public void MDATC_3_A_1_MoveIntoOwnPastForksTimeline()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
// Hold to move into the future, then move back into the past.
setup[("a", 0)]
@ -57,7 +58,7 @@ public class TimeTravelTest
[Test]
public void MDATC_3_A_2_SupportToRepelledPastMoveForksTimeline()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
// Fail to dislodge on the first turn, then support the move so it succeeds.
setup[("a", 0)]
@ -107,7 +108,7 @@ public class TimeTravelTest
[Test]
public void MDATC_3_A_3_FailedMoveDoesNotForkTimeline()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
// Hold to create a future, then attempt to attack in the past.
setup[("a", 0)]
@ -147,7 +148,7 @@ public class TimeTravelTest
[Test]
public void MDATC_3_A_4_SuperfluousSupportDoesNotForkTimeline()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
// Move, then support the past move even though it succeeded already.
setup[("a", 0)]
@ -189,7 +190,7 @@ public class TimeTravelTest
[Test]
public void MDATC_3_A_5_CrossTimelineSupportDoesNotForkHead()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
// London creates two timelines by moving into the past.
setup[("a", 0)]
@ -242,7 +243,7 @@ public class TimeTravelTest
[Test]
public void MDATC_3_A_6_CuttingCrossTimelineSupportDoesNotFork()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
// As above, only now London creates three timelines.
setup[("a", 0)]

View File

@ -1,9 +1,10 @@
using MultiversalDiplomacy.Adjudicate;
using MultiversalDiplomacy.Adjudicate.Decision;
using MultiversalDiplomacy.Model;
using NUnit.Framework;
using static MultiversalDiplomacyTests.Adjudicator;
namespace MultiversalDiplomacyTests;
public class MovementAdjudicatorTest
@ -11,7 +12,7 @@ public class MovementAdjudicatorTest
[Test]
public void Validation_ValidHold()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
setup["Germany"]
.Army("Mun").Holds().GetReference(out var order);
@ -24,7 +25,7 @@ public class MovementAdjudicatorTest
[Test]
public void Validation_ValidMove()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
setup["Germany"]
.Army("Mun").MovesTo("Tyr").GetReference(out var order);
@ -37,7 +38,7 @@ public class MovementAdjudicatorTest
[Test]
public void Validation_ValidConvoy()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
setup["Germany"]
.Fleet("Nth").Convoys.Army("Hol").To("Lon").GetReference(out var order);
@ -50,7 +51,7 @@ public class MovementAdjudicatorTest
[Test]
public void Validation_ValidSupportHold()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
setup["Germany"]
.Army("Mun").Supports.Army("Kie").Hold().GetReference(out var order);
@ -63,7 +64,7 @@ public class MovementAdjudicatorTest
[Test]
public void Validation_ValidSupportMove()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
setup["Germany"]
.Army("Mun").Supports.Army("Kie").MoveTo("Ber").GetReference(out var order);
@ -76,12 +77,12 @@ public class MovementAdjudicatorTest
[Test]
public void Adjudication_Hold()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
setup["Germany"]
.Army("Mun").Holds().GetReference(out var order);
setup.ValidateOrders();
setup.AdjudicateOrders(MovementPhaseAdjudicator.Instance);
setup.AdjudicateOrders(MovementPhase);
var adjMun = order.Adjudications;
Assert.That(adjMun.All(adj => adj.Resolved), Is.True);
@ -96,7 +97,7 @@ public class MovementAdjudicatorTest
[Test]
public void Adjudication_Move()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
setup["Germany"]
.Army("Mun").MovesTo("Tyr").GetReference(out var order);
@ -122,7 +123,7 @@ public class MovementAdjudicatorTest
[Test]
public void Adjudication_Support()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
setup["Germany"]
.Army("Mun").MovesTo("Tyr").GetReference(out var move)
.Army("Boh").Supports.Army("Mun").MoveTo("Tyr").GetReference(out var support);
@ -156,7 +157,7 @@ public class MovementAdjudicatorTest
[Test]
public void Update_SingleHold()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
setup["Germany"]
.Army("Mun").Holds().GetReference(out var mun);
@ -183,7 +184,7 @@ public class MovementAdjudicatorTest
[Test]
public void Update_DoubleHold()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
setup[("a", 0)]
.GetReference(out Season s1)
["Germany"]
@ -233,7 +234,7 @@ public class MovementAdjudicatorTest
[Test]
public void Update_DoubleMove()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
setup[("a", 0)]
.GetReference(out Season s1)
["Germany"]

View File

@ -0,0 +1,10 @@
using MultiversalDiplomacy.Adjudicate.Logging;
namespace MultiversalDiplomacyTests;
public class NullLogger : IAdjudicatorLogger
{
public static NullLogger Instance { get; } = new();
public void Log(int contextLevel, string message, params object[] args) {}
}

View File

@ -5,7 +5,7 @@ using MultiversalDiplomacy.Orders;
namespace MultiversalDiplomacyTests;
public class RegexTest
public class OrderParserTest
{
private static TestCaseData Test(string order, params string[] expected)
=> new TestCaseData(order, expected).SetName($"{{m}}(\"{order}\")");
@ -216,4 +216,120 @@ public class RegexTest
Assert.That(move.Location, Is.EqualTo("Tyrolia/l"));
Assert.That(move.Season.Key, Is.EqualTo("a0"));
}
[Test]
public void OrderDisambiguation()
{
World world = World.WithStandardMap().AddUnits("Germany A Mun");
OrderParser.TryParseOrder(world, "Germany", "Mun h", out Order? parsed);
Assert.That(parsed?.ToString(), Is.EqualTo("G A a-Munich/l@0 holds"));
}
[Test]
public void UnitTypeDisambiguatesCoastalLocation()
{
World world = World.WithStandardMap().AddUnits("England F Nth", "Germany A Ruhr");
Assert.That(
OrderParser.TryParseOrder(world, "England", "North Sea - Holland", out Order? fleetOrder),
"Failed to parse fleet order");
Assert.That(
OrderParser.TryParseOrder(world, "Germany", "Ruhr - Holland", out Order? armyOrder),
"Failed to parse army order");
Assert.That(fleetOrder, Is.TypeOf<MoveOrder>(), "Unexpected fleet order");
Assert.That(armyOrder, Is.TypeOf<MoveOrder>(), "Unexpected army order");
Location fleetDest = world.Map.GetLocation(((MoveOrder)fleetOrder!).Location);
Location armyDest = world.Map.GetLocation(((MoveOrder)armyOrder!).Location);
Assert.That(fleetDest.ProvinceName, Is.EqualTo(armyDest.ProvinceName));
Assert.That(fleetDest.Type, Is.EqualTo(LocationType.Water), "Unexpected fleet movement location");
Assert.That(armyDest.Type, Is.EqualTo(LocationType.Land), "Unexpected army movement location");
}
[Test]
public void UnitTypeOverrulesNonsenseLocation()
{
World world = World.WithStandardMap().AddUnits("England F Nth", "Germany A Ruhr");
Assert.That(
OrderParser.TryParseOrder(world, "England", "F North Sea - Holland/l", out Order? fleetOrder),
"Failed to parse fleet order");
Assert.That(
OrderParser.TryParseOrder(world, "Germany", "A Ruhr - Holland/w", out Order? armyOrder),
"Failed to parse army order");
Assert.That(fleetOrder, Is.TypeOf<MoveOrder>(), "Unexpected fleet order");
Assert.That(armyOrder, Is.TypeOf<MoveOrder>(), "Unexpected army order");
Location fleetDest = world.Map.GetLocation(((MoveOrder)fleetOrder!).Location);
Location armyDest = world.Map.GetLocation(((MoveOrder)armyOrder!).Location);
Assert.That(fleetDest.ProvinceName, Is.EqualTo(armyDest.ProvinceName));
Assert.That(fleetDest.Type, Is.EqualTo(LocationType.Water), "Unexpected fleet movement location");
Assert.That(armyDest.Type, Is.EqualTo(LocationType.Land), "Unexpected army movement location");
}
[Test]
public void DisambiguateSingleAccessibleCoast()
{
World world = World.WithStandardMap().AddUnits("France F Gascony", "France F Marseilles");
Assert.That(
OrderParser.TryParseOrder(world, "France", "Gascony - Spain", out Order? northOrder),
"Failed to parse north coast order");
Assert.That(
OrderParser.TryParseOrder(world, "France", "Marseilles - Spain", out Order? southOrder),
"Failed to parse south coast order");
Assert.That(northOrder, Is.TypeOf<MoveOrder>(), "Unexpected north coast order");
Assert.That(southOrder, Is.TypeOf<MoveOrder>(), "Unexpected south coast order");
Location north = world.Map.GetLocation(((MoveOrder)northOrder!).Location);
Location south = world.Map.GetLocation(((MoveOrder)southOrder!).Location);
Assert.That(north.Name, Is.EqualTo("north coast"), "Unexpected disambiguation");
Assert.That(south.Name, Is.EqualTo("south coast"), "Unexpected disambiguation");
}
[Test]
public void DisambiguateMultipleAccessibleCoasts()
{
World world = World.WithStandardMap().AddUnits("France F Portugal");
Assert.That(
OrderParser.TryParseOrder(world, "France", "Portugal - Spain", out Order? _),
Is.False,
"Should not parse ambiguous coastal move");
}
[Test]
public void DisambiguateSupportToSingleAccessibleCoast()
{
World world = World.WithStandardMap().AddUnits("France F Gascony", "France F Marseilles");
Assert.That(
OrderParser.TryParseOrder(world, "France", "Gascony S Marseilles - Spain", out Order? northOrder),
"Failed to parse north coast order");
Assert.That(
OrderParser.TryParseOrder(world, "France", "Marseilles S Gascony - Spain", out Order? southOrder),
"Failed to parse south coast order");
Assert.That(northOrder, Is.TypeOf<SupportMoveOrder>(), "Unexpected north coast order");
Assert.That(southOrder, Is.TypeOf<SupportMoveOrder>(), "Unexpected south coast order");
Location northTarget = ((SupportMoveOrder)northOrder!).Location;
Location southTarget = ((SupportMoveOrder)southOrder!).Location;
Assert.That(northTarget.Name, Is.EqualTo("south coast"), "Unexpected disambiguation");
Assert.That(southTarget.Name, Is.EqualTo("north coast"), "Unexpected disambiguation");
}
[Test]
public void DisambiguateSupportToMultipleAccessibleCoasts()
{
World world = World.WithStandardMap().AddUnits("France F Portugal", "France F Marseilles");
Assert.That(
OrderParser.TryParseOrder(world, "France", "Marseilles S Portugal - Spain", out Order? _),
Is.False,
"Should not parse ambiguous coastal support");
}
}

View File

@ -15,15 +15,9 @@ public class ReplDriver(IScriptHandler initialHandler, bool echo = false)
/// </summary>
bool Echo { get; } = echo;
/// <summary>
/// Input a multiline string into the repl. Call <see cref="Ready"/> or <see cref="Closed"/> at the end so the
/// statement is valid.
/// </summary>
public ReplDriver this[string input] => ExecuteAll(input);
public ReplDriver ExecuteAll(string multiline)
{
var lines = multiline.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var lines = multiline.Split('\n', StringSplitOptions.TrimEntries);
return lines.Aggregate(this, (repl, line) => repl.Execute(line));
}
@ -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 Ready()
public void AssertFails(string inputLine)
{
Assert.That(Handler, Is.Not.Null, "Handler is closed");
}
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 Closed()
{
Assert.That(Handler, Is.Null, "Handler is not closed");
var result = Handler.HandleInput(inputLine);
if (result.Success) Assert.Fail($"Expected \"{inputLine}\" to fail, but it succeeded.");
}
}

View File

@ -8,17 +8,22 @@ namespace MultiversalDiplomacyTests;
public class ReplTest
{
private static ReplDriver StandardRepl() => new(
new SetupScriptHandler(
(msg) => {/* discard */},
World.WithStandardMap(),
Adjudicator.MovementPhase));
[Test]
public void SetupHandler()
{
SetupScriptHandler setup = new(World.WithStandardMap(), strict: true);
ReplDriver repl = new(setup);
var repl = StandardRepl();
repl["""
repl.ExecuteAll("""
unit Germany A Munich
unit Austria Army Tyrolia
unit England F Lon
"""].Ready();
""");
Assert.That(repl.Handler, Is.TypeOf<SetupScriptHandler>());
SetupScriptHandler handler = (SetupScriptHandler)repl.Handler!;
@ -27,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["""
---
"""].Ready();
repl.Execute("---");
Assert.That(repl.Handler, Is.TypeOf<GameScriptHandler>());
}
@ -37,20 +40,18 @@ public class ReplTest
[Test]
public void SubmitOrders()
{
SetupScriptHandler setup = new(World.WithStandardMap(), strict: true);
ReplDriver repl = new ReplDriver(setup)["""
var repl = StandardRepl();
repl.ExecuteAll("""
unit Germany A Mun
unit Austria A Tyr
unit England F Lon
begin
"""];
repl["""
---
Germany A Mun hold
Austria: Army Tyrolia - Vienna
England:
Lon h
"""].Ready();
""");
Assert.That(repl.Handler, Is.TypeOf<GameScriptHandler>());
GameScriptHandler handler = (GameScriptHandler)repl.Handler!;
@ -62,13 +63,214 @@ public class ReplTest
World before = handler.World;
repl["""
---
"""].Ready();
repl.Execute("---");
Assert.That(repl.Handler, Is.TypeOf<AdjudicationQueryScriptHandler>());
var newHandler = (AdjudicationQueryScriptHandler)repl.Handler!;
Assert.That(newHandler.World, Is.Not.EqualTo(before));
Assert.That(newHandler.World.Timelines.Pasts.Count, Is.EqualTo(2));
}
[Test]
public void AssertBasic()
{
var repl = StandardRepl();
repl.ExecuteAll("""
unit Germany A Munich
---
---
assert true
""");
repl.AssertFails("assert false");
}
[Test]
public void AssertOrderValidity()
{
var repl = StandardRepl();
repl.ExecuteAll("""
unit Germany A Mun
---
Germany A Mun - Stp
---
""");
// Order should be invalid
repl.Execute("assert order-invalid Mun");
repl.AssertFails("assert order-valid Mun");
repl.ExecuteAll("""
---
Germany A Mun - Tyr
---
""");
// Order should be valid
repl.Execute("assert order-valid Mun");
repl.AssertFails("assert order-invalid Mun");
}
[Test]
public void AssertSeasonPast()
{
var repl = StandardRepl();
repl.ExecuteAll("""
unit England F London
---
---
""");
// Expected past
repl.Execute("assert has-past a1>a0");
// Incorrect past
repl.AssertFails("assert has-past a0>a1");
repl.AssertFails("assert has-past a1>a1");
// Missing season
repl.AssertFails("assert has-past a2>a1");
}
[Test]
public void AssertHoldOrder()
{
var repl = StandardRepl();
repl.ExecuteAll("""
unit Germany A Mun
---
""");
repl.AssertFails("Germany A Mun - The Sun");
repl.Execute("---");
// Order is invalid
repl.Execute("assert hold-order Mun");
// order-invalid requires the order be parsable, which this isn't
repl.AssertFails("assert order-invalid Mun");
}
[Test]
public void AssertMovement()
{
var repl = StandardRepl();
repl.ExecuteAll("""
unit Germany A Mun
unit Austria A Tyr
---
Germany Mun - Tyr
---
""");
// Movement fails
repl.Execute("assert no-move Mun");
repl.AssertFails("assert moves Mun");
repl.ExecuteAll("""
---
Germany Mun - Boh
---
""");
// Movement succeeds
repl.Execute("assert moves Mun");
repl.AssertFails("assert no-move Mun");
}
[Test]
public void AssertSupportHold()
{
var repl = StandardRepl();
repl.ExecuteAll("""
unit Germany A Mun
unit Germany A Boh
unit Austria A Tyr
---
Germany Mun s Boh
---
""");
// Support is given
repl.Execute("assert support-given Mun");
repl.AssertFails("assert support-cut Mun");
repl.ExecuteAll("""
---
Germany Mun s Boh
Austria Tyr - Mun
---
""");
// Support is cut
repl.Execute("assert support-cut Mun");
repl.AssertFails("assert support-given Mun");
}
[Test]
public void AssertSupportMove()
{
var repl = StandardRepl();
repl.ExecuteAll("""
unit Germany A Berlin
unit Germany A Bohemia
unit Austria A Tyrolia
---
Germany:
Berlin - Silesia
Bohemia s Berlin - Silesia
---
""");
// Support is given
repl.Execute("assert support-given Boh");
repl.AssertFails("assert support-cut Boh");
repl.ExecuteAll("""
---
Germany:
Silesia - Munich
Bohemia s Silesia - Munich
Austria Tyrolia - Bohemia
---
""");
// Support is cut
repl.AssertFails("assert support-given Boh");
repl.Execute("assert support-cut Boh");
}
[Test]
public void AssertDislodged()
{
var repl = StandardRepl();
repl.ExecuteAll("""
unit Germany A Mun
unit Germany A Boh
unit Austria A Tyr
---
Germany Mun - Tyr
---
""");
// Move repelled
repl.Execute("assert not-dislodged Tyr");
repl.AssertFails("assert dislodged Tyr");
repl.ExecuteAll("""
---
Germany Mun - Tyr
Germany Boh s Mun - Tyr
---
""");
// Move succeeds
repl.Execute("assert dislodged Tyr");
repl.AssertFails("assert not-dislodged Tyr");
}
}

View File

@ -19,14 +19,37 @@ public class ScriptTests
[TestCaseSource(nameof(DatcTestCases))]
public void Test_DATC(string testScriptPath)
{
Assert.Ignore("Script tests postponed until parsing tests are done");
string filename = Path.GetFileName(testScriptPath);
int line = 0;
IScriptHandler? handler = new SetupScriptHandler(World.WithStandardMap(), strict: true);
bool expectFailure = false;
IScriptHandler handler = new SetupScriptHandler(
(msg) => {/* discard */},
World.WithStandardMap(),
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}\"");
// Handle test directives
if (input == "#test:skip") {
Assert.Ignore($"Script {filename} skipped at line {line}");
}
if (input == "#test:fails") {
expectFailure = true;
continue;
}
var result = handler.HandleInput(input);
if (expectFailure && result.Success) throw new AssertionException(
$"Script {filename} expected line {line} to fail, but it succeeded");
if (!expectFailure && !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;
expectFailure = false;
}
}
}

View File

@ -11,4 +11,4 @@ F North Sea - Picardy
---
# Order should fail.
assert North Sea holds
assert hold-order North Sea

View File

@ -0,0 +1,21 @@
# 6.A.10. TEST CASE, SUPPORT ON UNREACHABLE DESTINATION NOT POSSIBLE
# The destination of the move that is supported must be reachable by the supporting unit.
unit Austria A Venice
unit Italy F Rome
unit Italy A Apulia
---
Austria:
A Venice Hold
Italy:
F Rome Supports A Apulia - Venice
A Apulia - Venice
---
# The support of Rome is illegal, because Venice cannot be reached from Rome by a fleet. Venice is not dislodged.
assert hold-order Rome
assert not-dislodged Venice

View File

@ -0,0 +1,19 @@
# 6.A.11. TEST CASE, SIMPLE BOUNCE
# Two armies bouncing on each other.
unit Austria A Vienna
unit Italy A Venice
---
Austria:
A Vienna - Tyrolia
Italy:
A Venice - Tyrolia
---
# The two units bounce.
assert no-move Vienna
assert no-move Venice

View File

@ -0,0 +1,24 @@
# 6.A.12. TEST CASE, BOUNCE OF THREE UNITS
# If three units move to the same area, the adjudicator should not bounce the first two units and then let the third unit go to the now open area.
unit Austria A Vienna
unit Germany A Munich
unit Italy A Venice
---
Austria:
A Vienna - Tyrolia
Germany:
A Munich - Tyrolia
Italy:
A Venice - Tyrolia
---
# The three units bounce.
assert no-move Vienna
assert no-move Munich
assert no-move Venice

View File

@ -6,9 +6,10 @@ unit England A Liverpool
---
England:
#test:fails
A Liverpool - Irish Sea
---
# Order should fail.
assert Liverpool holds
assert hold-order Liverpool

View File

@ -1,14 +1,15 @@
# 6.A.3. TEST CASE, MOVE FLEET TO LAND
# Check whether a fleet cannot move to land.
unit Germany Army Kiel
unit Germany F Kiel
---
Germany:
#test:fails
F Kiel - Munich
---
# Order should fail.
assert Kiel holds
assert hold-order Kiel

View File

@ -1,7 +1,7 @@
# 6.A.4. TEST CASE, MOVE TO OWN SECTOR
# Moving to the same sector is an illegal move (2023 rulebook, page 7, "An Army can be ordered to move into an adjacent inland or coastal province.").
unit Germany Army Kiel
unit Germany F Kiel
---
@ -11,4 +11,4 @@ F Kiel - Kiel
---
# Program should not crash.
assert Kiel holds
assert hold-order Kiel

View File

@ -1,6 +1,9 @@
# 6.A.5. TEST CASE, MOVE TO OWN SECTOR WITH CONVOY
# Moving to the same sector is still illegal with convoy (2023 rulebook, page 7, "Note: An Army can move across water provinces from one coastal province to another...").
# TODO convoy order parsing
#test:skip
unit England F North Sea
unit England A Yorkshire
unit England A Liverpool
@ -21,11 +24,11 @@ A Wales Supports F London - Yorkshire
---
# The move of the army in Yorkshire is illegal.
assert Yorkshire holds
assert hold-order Yorkshire
# This makes the support of Liverpool also illegal and without the support, the Germans have a stronger force.
assert North Sea holds
assert Liverpool holds
assert London moves
assert hold-order North Sea
assert hold-order Liverpool
assert moves London
# The army in London dislodges the army in Yorkshire.
assert Wales supports
assert Yorkshire dislodged
assert support-given Wales
assert dislodged Yorkshire

View File

@ -13,4 +13,4 @@ F London - North Sea
---
# Order should fail.
assert London holds
assert hold-order London

View File

@ -1,8 +1,11 @@
# 6.A.7. TEST CASE, ONLY ARMIES CAN BE CONVOYED
# A fleet cannot be convoyed.
# TODO convoy order parsing
#test:skip
unit England F London
unit England North Sea
unit England F North Sea
---
@ -13,4 +16,4 @@ F North Sea Convoys A London - Belgium
---
# Move from London to Belgium should fail.
assert London holds
assert hold-order London

View File

@ -0,0 +1,20 @@
# 6.A.8. TEST CASE, SUPPORT TO HOLD YOURSELF IS NOT POSSIBLE
# An army cannot get an additional hold power by supporting itself.
unit Italy A Venice
unit Italy A Tyrolia
unit Austria F Trieste
---
Italy:
A Venice - Trieste
A Tyrolia Supports A Venice - Trieste
Austria:
F Trieste Supports F Trieste
---
# The army in Trieste should be dislodged.
assert dislodged Trieste

View File

@ -0,0 +1,14 @@
# 6.A.9. TEST CASE, FLEETS MUST FOLLOW COAST IF NOT ON SEA
# If two provinces are adjacent, that does not mean that a fleet can move between those two provinces. An implementation that only holds one list of adjacent provinces for each province is incorrect.
unit Italy F Rome
---
Italy:
F Rome - Venice
---
# Move fails. An army can go from Rome to Venice, but a fleet cannot.
assert hold-order Rome

View File

@ -1,11 +1,12 @@
using System.Text.Json;
using MultiversalDiplomacy.Adjudicate;
using MultiversalDiplomacy.Adjudicate.Decision;
using MultiversalDiplomacy.Model;
using NUnit.Framework;
using static MultiversalDiplomacyTests.Adjudicator;
namespace MultiversalDiplomacyTests;
public class SerializationTest
@ -74,7 +75,7 @@ public class SerializationTest
public void SerializeRoundTrip_MDATC_3_A_2()
{
// Set up MDATC 3.A.2
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
setup[("a", 0)]
.GetReference(out Season s0)
["Germany"]
@ -107,7 +108,7 @@ public class SerializationTest
});
// Resume the test case
setup = new(reserialized, MovementPhaseAdjudicator.Instance);
setup = new(reserialized, MovementPhase);
setup[("a", 1)]
["Germany"]
.Army("Mun").Supports.Army("Mun", season: s0).MoveTo("Tyr").GetReference(out var mun1)